diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e09200258..776ce8d4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,11 @@ name: CI on: push: - branches: [main, dev] # dev takes direct pushes, so they get a result too + # dev takes direct pushes, so they get a result too. A release branch (0.1.6, ...) + # takes merges from pull requests: the run on the merge is what saves the compiled + # engines where the NEXT pull request into it can restore them — a cache a pull + # request saves is visible to that pull request alone. + branches: [main, dev, '0.*'] pull_request: merge_group: workflow_dispatch: @@ -78,6 +82,10 @@ jobs: echo "code=true" >> "$GITHUB_OUTPUT"; echo "engines=false" >> "$GITHUB_OUTPUT" echo "push to dev: suites run, platform engines wait for main"; exit 0 fi + if [ "${{ github.event_name }}" = push ] && [[ "${{ github.ref }}" == refs/heads/0.* ]]; then + echo "code=true" >> "$GITHUB_OUTPUT"; echo "engines=false" >> "$GITHUB_OUTPUT" + echo "push to a release branch: suites run (and save its engines for pull requests into it), platform engines wait for main"; exit 0 + fi # A push to main is a release when its version has no tag yet: that commit, and only that one, # builds every platform and runs the five-platform e2e, and release.yml drafts from it once # it is green. A push whose version is already tagged released nothing new and builds nothing. @@ -246,6 +254,10 @@ jobs: if: needs.changes.outputs.code == 'true' runs-on: ubuntu-24.04 timeout-minutes: 45 + env: + # the engine is compiled for any x86-64 runner, so a cached one can be restored + # on whichever runner this job lands (see the engine cache step below) + AXIOM_ENGINE_MARCH: portable strategy: fail-fast: false matrix: @@ -396,20 +408,35 @@ jobs: # one language recompiles that language only, and an unchanged one never does. # Two things decide the binary that ENGINE_ID does not cover, so they are in the # key too: the driver that compiles it (run-souffle.sh holds the compiler flags), - # and the runner's CPU, because the driver compiles with -march=native and a - # binary built on one CPU model can die with an illegal instruction on another. + # and the target it is compiled for. + # + # THE TARGET IS PORTABLE, NOT THE RUNNER'S CPU. The driver's default is + # -march=native, and a binary built on one hosted runner died with SIGILL on + # another (runners that report the same model name do not all expose the same + # instruction set). The key used to carry a hash of the runner's CPU flags to + # keep such a binary off other CPUs — and so it missed on almost every run, + # since which CPU a job lands on is luck: a three-minute compile per language, + # per run, for rules nothing had changed. AXIOM_ENGINE_MARCH=portable compiles + # for the compiler's baseline x86-64 target instead, as the published engines + # are, so any runner can run any runner's binary and the CPU leaves the key. + # + # A cache saved by a pull request is visible to that pull request only. The + # ones every pull request can restore are the base branch's and the default + # branch's, which is why a push to a release branch runs the suites too (see + # `on.push` and the `changes` job): it leaves the engine for its rules where + # every pull request into that branch finds it. - name: this language's engine id id: eid run: | echo "id=$(bash graph/pipeline/run-souffle.sh --language ${{ matrix.lang }} --print-engine-id)" >> "$GITHUB_OUTPUT" - echo "cpu=$(grep -m1 'model name' /proc/cpuinfo | sha256sum | cut -c1-12)" >> "$GITHUB_OUTPUT" - - name: cache the compiled Soufflé engine + - name: restore the compiled Soufflé engine + id: engine-cache if: ${{ !inputs.fresh }} - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: .souffle-cache - key: souffle-engine-${{ matrix.lang }}-${{ steps.eid.outputs.id }}-${{ hashFiles('graph/pipeline/run-souffle.sh') }}-${{ steps.eid.outputs.cpu }} + key: souffle-engine-${{ matrix.lang }}-${{ steps.eid.outputs.id }}-${{ hashFiles('graph/pipeline/run-souffle.sh') }}-march-${{ env.AXIOM_ENGINE_MARCH }} - name: ${{ matrix.lang }} regression suite env: @@ -417,8 +444,25 @@ jobs: # The driver's default cache is ~/.cache/axiomcode/souffle; point it at the # directory the cache step above saves and restores, or it never hits. AXIOM_SOUFFLE_CACHE: ${{ github.workspace }}/.souffle-cache + # The cases run concurrently, up to one per CPU (graph/test/tools/case-pool.sh); + # AXIOM_SUITE_JOBS=1 here would run them one at a time, as they used to. run: bash .github/scripts/run-suite.sh ${{ matrix.lang }} ${{ matrix.oracle }} + # Saved whether or not the suite passed. The binary does not depend on the verdict: + # run-souffle.sh publishes it only whole and verified (a temp name, then a rename), + # so a red run's engine is as good as a green one's, and the run that most needs the + # next push to be fast is the red one. Not when cancelled, and not unless this + # language's engine is actually there: a key saved without it would stay taken, and + # every later run would restore the gap and could never save over it. + - name: save the compiled Soufflé engine + if: >- + ${{ !cancelled() && !inputs.fresh && steps.engine-cache.outputs.cache-hit != 'true' + && hashFiles(format('.souffle-cache/souffle-engine-{0}-{1}', matrix.lang, steps.eid.outputs.id)) != '' }} + uses: actions/cache/save@v4 + with: + path: .souffle-cache + key: souffle-engine-${{ matrix.lang }}-${{ steps.eid.outputs.id }}-${{ hashFiles('graph/pipeline/run-souffle.sh') }}-march-${{ env.AXIOM_ENGINE_MARCH }} + # Every language's engine, every platform, built once per release: on the push that # lands a new version on main (and in the nightly). publish-npm ships these very # artifacts, so what the e2e below tested is what users install (#1350). diff --git a/README.md b/README.md index 93413fa5b..3bd2a0187 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,7 @@ about one change had to read 95 of 43,793 methods, and every true direct caller | `axiomcode test-impact` | which tests have to run for the current edit, with the chain that reaches each | | `axiomcode changed` | which declarations an edit changed, and how (signature, type, body, added, removed). `--impact` adds what that reaches | | `axiomcode context ""` | where a task's words land in the code, when you have a problem statement and not yet a name | -| `axiomcode graph` | the whole graph as one self-contained HTML page, at `.axiomcode/graph/graph.html` | +| `axiomcode graph` | the whole graph as one self-contained HTML page, at `.axiomcode/graph/graph.html`, drawn from the existing graph (rebuilt first only when stale, with the flags it was indexed with) | | `axiomcode index` | build or rebuild the graph explicitly; `--lang`, `--src` and `--library` narrow it | | `axiomcode mcp` | serve the graph to an agent as MCP tools over stdio | diff --git a/gemini-extension.json b/gemini-extension.json index 200b15780..2c6ca0db2 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.4", + "version": "0.1.6", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", "contextFileName": "plugins/axiomcode/AGENTS.md", "mcpServers": { diff --git a/graph/bundle/SCHEMA.md b/graph/bundle/SCHEMA.md index e5e44c854..7bd4051a8 100644 --- a/graph/bundle/SCHEMA.md +++ b/graph/bundle/SCHEMA.md @@ -436,6 +436,7 @@ One row per place a call is written (or, for a synthesised edge, the construct t - **typescript** — caller_id is normally the parser's caller method, or the module initializer for top-level code; when neither exists it is the TS_MODULE_ hash itself, kept as a greppable marker rather than a blank. - **java** — callee_name for `new X()` and for `new X() { … }` (anon_new) is the class name written at the site; NULL for ctor_delegate (`this(…)`/`super(…)`) and record_accessor, which write no name. A by-name lookup must therefore exclude kind IN (new, anon_new) to avoid counting a construction as a call to a same-named method. - **java** — A record_accessor site is the RECORD_PATTERN expression, positioned where the pattern is written. +- **java** — A resource_close site is the resource LOCAL of a try-with-resources, positioned at its declaration (line only, no column); callee_name is NULL. - **python** — A DECORATOR_APPLICATION edge targets the callable the decorator factory RETURNS, not the name written at the `@` — `@deco(X)` applies the inner callable that `deco` returned. The written name is carried by the separate DECORATOR_CALL row, so a by-name lookup must exclude DECORATOR_APPLICATION or it will read the wrapper as a mismatch. - **typescript** — end_line / end_column come from the expression row; the call-site row itself records only the start. - **javascript** — caller_id is the parser's enclosing method, or the module initializer for top-level code. end_line / end_column come from the expression row. `require()` is a module edge, not a call site. @@ -481,7 +482,9 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h | `lambda_body` | java | An edge from the method that INVOKES a lambda to something the lambda body calls. The body is not a method of its own, so its calls are attributed to the invoker rather than lost (call-edge-generation/lambda_dispatch.dl). | | `ctor_delegate` | java | `this(…)` / `super(…)` inside a constructor. | | `anon_new` | java | `new X() { … }` — an anonymous class creation. | +| `event` | java | Synthesised: the publishEvent call site to a listener it runs (tier event_dispatch). Not a written call to that method. | | `record_accessor` | java | Synthesised: a record pattern `case Pair(var l, var r)` calls each accessor. Not a written call; the site is the pattern expression. | +| `resource_close` | java | Synthesised: a try-with-resources resource `try (var w = open())` is closed when the block exits, so its close() is called. Not a written call; the site is the resource local. | | `FUNCTION_CALL` | typescript | `f(…)` — a bare callee. | | `METHOD_CALL` | typescript | `obj.m(…)`. | | `CONSTRUCTOR_CALL` | typescript | `new X(…)`. | @@ -538,6 +541,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h | `boundary_generated` | csharp | A read of a property the compiler generated (a positional record's property). There is no written accessor to point at, so callee_method_id is NULL, callee_label names the member and callee_provenance is `generated`. A correct end, not a blind spot. | | `runtime_observed` | csharp | Only when a runtime trace is supplied: an edge the trace saw and the static pass did not name. Real by construction, and added only, never used to remove an edge. The site is the CALLER METHOD, not an expression (the tracer sees method entry, not the call site), and kind is `runtime`. | | `known_implicit_ctor` | csharp | `new Foo()` where Foo declares no constructor. The compiler supplies a parameterless one, so there is no user code to call and nothing resolving is the right answer. | +| `event_dispatch` | csharp | A mediator's `Send(request)` reaching the `Handle` of the handler registered for the request's type (`IRequestHandler`, with a generic request's type arguments), or `Publish(notification)` reaching every `INotificationHandler` for its type and each of its bases. Not the site's own callee: the site also keeps its row for the library method. The container picks the handler in process, before the call returns, so this is a call edge and not a remote_edge. A request declared as a base (not an interface) fans to the handler of each type in its family. | | `known_edge` | all | Exactly one target resolved. The strongest claim. | | `multi_inferred` | all | A sound SET of possible targets; each member is one row. The set over-approximates — every member is a real possibility, but not every member runs. HOW WIDE the set is differs by language: see the per-language notes on this table for whether the fan is narrowed by the instantiation set. | | `boundary_lib` | all | The target is outside the client (library, builtin, or unstaged external). The chain is not expanded past it here. | @@ -548,8 +552,9 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h | `implicit_constructor` | javascript | `new C()` / `super()` where no constructor exists up the chain: the synthesized default runs. A correct end; callee is NULL. | | `dynamic_terminal` | javascript | `obj[expr]()`, `eval`, `import()`: no static target by construction; callee is NULL. | | `fan_capped` | javascript, java, csharp | More targets than --dispatch-cap: the set was refused rather than emitted. JavaScript: callee is NULL. Java and C#: callee is the declared base method the fan would have started from; dispatch-capped-sites.csv carries the refused count. | -| `callback_registered` | javascript | The site HANDS the callee this function (`xs.forEach(f)`, `p.then(f)`, `emitter.on('x', h)`, `setTimeout(f)`), which may invoke it. Not the site's own callee; a reachability edge, labelled so it is never read as a resolved call. | +| `callback_registered` | javascript, typescript | The site HANDS the callee this function (`xs.forEach(f)`, `p.then(f)`, `emitter.on('x', h)`, `setTimeout(f)`), which may invoke it. Not the site's own callee; a reachability edge, labelled so it is never read as a resolved call. | | `event_dispatch` | javascript | `x.emit('name')` reaching a handler registered by `x.on('name', h)` on a value x may hold — name-sensitive for literal names, every handler on that value for a computed one. | +| `event_dispatch` | java | A Spring application event: `publishEvent(e)` reaching each listener (`@EventListener`, `@TransactionalEventListener`, `ApplicationListener.onApplicationEvent`) whose declared event type e's static type is, or is a subtype of. Added beside the publishEvent boundary row, never in place of it (call-edge-generation/event_dispatch.dl). | | `intrinsic_terminal` | typescript | The site is a JSX intrinsic element or a dynamic `import()` — a runtime intrinsic, not a function the graph can name. | **`call_edges.callee_provenance` values** @@ -569,7 +574,7 @@ THE GRAPH. One row per (site, resolved target). A site with N possible targets h - **python** — A `boundary_lib` edge may point at a builtin (callee_provenance builtin, callee_label `builtin:NAME`) or at an unstaged import path (callee_provenance external) — neither has a methods row. - **java** — A `boundary_lib` edge with callee_provenance external names a method of an ancestor type no staged IR declares (callee_label `external:.`, no methods row). A site whose receiver is declared as such a type is multi_inferred even with one client override: the platform method itself, and the platform's own subclasses, are the other possible targets. Stage the library to replace the label with the real method. - **python** — The reason a site is ambiguous_unknown is exported per site in ext_call_site_unresolved (site, caller, reason, detail). -- **all** — THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list. +- **all** — THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript, callback_registered in TypeScript; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list. - **java** — A multi_inferred fan is CHA-wide: it is every override the hierarchy admits, bounded only by the dispatch cap. type_instantiated is computed and exported but NOT read by any rule, so the fan is not narrowed to types the program constructs. Narrow it yourself by joining dispatch_candidates to type_instantiated — see the dispatch_envelope_of query. A receiver is ALSO typed by what flows into it (a local's initializer, the arguments callers pass to a parameter, the receivers callers invoke a method on for its `this`), and each flow-in type resolves its member directly, outside the fan: that is why a `fan_capped` site still carries edges, and why they are the types the program was seen to hand over, not the whole hierarchy. - **typescript** — A multi_inferred fan is CHA-wide, as in Java: type_instantiated is computed and exported but NOT read by any rule. The fan also has sources that are not virtual dispatch at all — an overload set or a union-typed receiver produces one too. - **python** — A multi_inferred fan IS narrowed by the instantiation set: type_instantiated_reachable (the constructed classes and their bases) bounds dispatch in resolution/dispatch.dl. Python is the only front end where that narrowing is applied, so a fan here is tighter than the same shape would be in Java or TypeScript. @@ -642,7 +647,7 @@ Methods the runtime invokes without a client call site — process roots, test m | `bean_ctor` | java, typescript | Constructor of a container-managed class. TypeScript: the class carries a framework decorator (`@Injectable`, `@Component`, `@Module`), so the container constructs it and nothing in the repository does. | | `factory` | java | A `@Bean` factory method. | | `lifecycle` | java, typescript, csharp | Java: `@PostConstruct` / `@PreDestroy` and similar hooks. TypeScript: a hook the container calls by name on a decorated class (`ngOnInit`, `onModuleInit`), which has no call site anywhere. C#: a method the host calls on a hosted service (`ExecuteAsync`, `StartAsync`, `StopAsync`, and the `IHostedLifecycleService` hooks), including one that derives from the host's base through the project's own base class. | -| `queue` | java, csharp | A message-listener method. C#: a broker consumer or a bus message handler. | +| `queue` | java, csharp | A message-listener method. Java: also a Spring application event listener (`@EventListener`, `@TransactionalEventListener`, an `ApplicationListener` implementation). C#: a broker consumer or a bus message handler. | | `scheduled` | java | A `@Scheduled` method. | | `grpc_service` | java, python, csharp | A gRPC service implementation the server invokes on a request, with no call site reaching it: a generated `ImplBase` override (Java); a class deriving from a generated `*Servicer` base in a `_pb2_grpc` module, overriding a method that base declares (Python). | | `web_servlet` | java | A servlet class named in `web.xml` (``): its container callbacks (`doGet`, `service`, …) and the library methods it overrides are invoked by the container. | @@ -653,6 +658,8 @@ Methods the runtime invokes without a client call site — process roots, test m | `lifecycle_factory` | java | The method an XML bean definition names as `factory-method`; the container calls it to build the bean. | | `config_handler` | java | A callback of a class a configuration file names under a key that expects a class (not an annotation); the container instantiates it and calls it. | | `service_loader` | java | A callback of a provider listed in `META-INF/services`; `ServiceLoader` instantiates it and the caller reaches it through the service interface. | +| `auto_configuration` | java | The constructor or a container callback of a configuration class named in `META-INF/spring/*.imports` or under a configuration key of `META-INF/spring.factories` (`EnableAutoConfiguration`); Spring Boot registers it as a bean. | +| `spring_factories` | java | The constructor or callback of a class named under any other key of `META-INF/spring.factories` (`ApplicationContextInitializer`, `EnvironmentPostProcessor`, …); `SpringFactoriesLoader` instantiates it and calls it. Not a bean. | | `unimported_module` | typescript, javascript | The initializer of a module nothing imports — a script or a bundle root. | | `exported_from_entry_module` | typescript | A named function exported from a module nothing in the project imports. Its caller is the package's consumer, which is not in the repository. Placeholder names (``) are not roots. | | `package_export` | typescript | A named function exported from a module the project's `package.json` publishes (`main`, `module`, `exports`, `types`, `source`), mapped from build output back to its source. Its caller is the package's consumer. Covers the entry a project's own tests import, which `exported_from_entry_module` cannot see. | @@ -667,7 +674,7 @@ Methods the runtime invokes without a client call site — process roots, test m **Notes** -- **python** — EMPTY. The Python rule set does not derive entry points; entry_reachable is therefore empty too. +- **python** — Framework entry points only: url, http, orm_hook, task, signal_receiver, fixture, di_provider and grpc_service. There is no test and no main reason: a pytest test is recognised by the query layer from its file and name, not here. ### `entry_reachable` @@ -724,7 +731,7 @@ Every field-like storage location the graph refers to: all client fields and enu **Notes** -- **all** — JAVA AND TYPESCRIPT, for the same reason as field_access: declared everywhere, populated by those two front ends. +- **all** — JAVA, TYPESCRIPT AND C#. EMPTY for Python and JavaScript: a Python attribute is a symbols row of kind field. In C# it holds true fields only; a property is a symbols row of kind field with a property id, and its accessors are methods rows (PROPERTY_GET, PROPERTY_SET, PROPERTY_INIT). - **typescript** — An enum member is absent: the parser gives it its own table with no declared type, and the property-access relation resolves through the field table. `Colour.Red` is therefore an unresolved field access, unlike Java where an enum constant is a fields row. - **java** — A library field is listed when some field_access edge reaches it, exactly as methods lists only the library methods an edge reaches, OR when a config_binding row names it as the field a configuration key binds to. The second was added in #890: a @Value field on a library type that nothing reads has no access edge, so config_binding named a field the table did not list and the join lost the row silently. @@ -883,7 +890,7 @@ THE OTHER HALF OF CHANGE IMPACT: one row per place a type is NAMED, with the con **Notes** -- **all** — JAVA AND TYPESCRIPT. Declared in every bundle and EMPTY for Python and JavaScript, so the schema does not churn as the remaining front ends land (#663). +- **all** — JAVA AND TYPESCRIPT. Declared in every bundle and EMPTY for Python, JavaScript and C#, so the schema does not churn as the remaining front ends land (#663). - **typescript** — The context set is TypeScript's own and is wider than Java's: AS_TARGET, SATISFIES_TARGET, TYPE_ALIAS_RHS, the CONDITIONAL_* family, MAPPED_*, INDEX_SIGNATURE_* and TEMPLATE_SPAN have no Java counterpart. A use inside a conditional type IS a use of that type and is recorded as one. - **typescript** — Only a reference whose KIND can name a declaration is a row: TYPE_REFERENCE and IMPORT_TYPE. ARRAY, UNION, TUPLE and PARENTHESIZED are structure whose CHILDREN are the named references; PRIMITIVE, LITERAL, TYPE_VARIABLE, MAPPED, CONDITIONAL, INDEXED_ACCESS and INTRINSIC name nothing declared. - **java** — EVERY DEPTH is here, unlike the receiver-typing relations the engine uses internally, which filter to depth 0. A field of type `Map` produces three rows. Filter on `depth = 0` when you want the type an expression has rather than every type its declaration mentions. @@ -940,6 +947,8 @@ Types this run creates an instance of — the rapid-type-analysis set that bound | `anonymous` | java | An anonymous class exists only by being instantiated. | | `enum_constant` | java | An enum's constants are its instances. | | `service_loader` | java | A provider listed in `META-INF/services`: `ServiceLoader` constructs it reflectively, with no `new` in the source. | +| `auto_configuration` | java | A configuration class named in `META-INF/spring/*.imports` or `spring.factories` (`EnableAutoConfiguration`): Spring Boot constructs it reflectively. | +| `spring_factories` | java | A class named under another key of `META-INF/spring.factories`: `SpringFactoriesLoader` constructs it reflectively. | **Notes** diff --git a/graph/bundle/build.ts b/graph/bundle/build.ts index 0723c4d46..c2a515f67 100644 --- a/graph/bundle/build.ts +++ b/graph/bundle/build.ts @@ -515,6 +515,20 @@ export async function buildCore(inp: BuildInputs): Promise { } } } + if (A.ir.localSites) { + const L = A.ir.localSites; + const src = await clientSource(inp.clientIrDir, L.file); + if (src) { + const h = src.header; + const ci = h.col(L.id), cl = h.col(L.startLine), cel = h.col(L.endLine); + for await (const r of rowsOf(src)) { + const row = sites.get(r[ci] ?? ''); + if (!row) continue; + fill(row, 4, fileOf(L.fileVia, h, r)); + fill(row, 5, int(r[cl])); fill(row, 7, int(r[cel])); + } + } + } // a site keyed on a type (Python METACLASS_CREATION) is positioned at the class declaration for (const row of sites.values()) { if (row[5] !== null) continue; diff --git a/graph/bundle/languages.ts b/graph/bundle/languages.ts index cfb657d2b..baee29540 100644 --- a/graph/bundle/languages.ts +++ b/graph/bundle/languages.ts @@ -94,6 +94,14 @@ export interface CallSitesIR { file: string; expressionId: string; calleeName: string; startLine: string; startColumn: string; endLine?: string; fileVia: { column: string; through: 'modules' }; } +/** + * Java try-with-resources: the implicit close() edge is keyed on the resource LOCAL, which has no + * expression row, so the site is positioned at the local's declaration instead. + */ +export interface LocalSitesIR { + file: string; id: string; startLine: string; endLine: string; + fileVia: { column: string; through: 'modules' | 'types' }; +} /** * Python decorators — a site id may be the decorator's own hash (a bare decorator with no * expression row) or the decorator's EXPRESSION hash; the row links both, so it is matched @@ -150,6 +158,7 @@ export interface LanguageAdapter { expressions: ExpressionsIR; callSites?: CallSitesIR; decorators?: DecoratorsIR; + localSites?: LocalSitesIR; /** absent where the front end writes no skipped-files report */ skipped?: SkippedIR; }; @@ -207,6 +216,11 @@ const JAVA: LanguageAdapter = { // the parser puts the invoked name / created class name in literalValue for these kinds calleeName: { column: 'literalValue', kinds: ['METHOD_INVOCATION', 'OBJECT_CREATION', 'METHOD_REFERENCE', 'ANONYMOUS_CLASS_CREATION'] }, }, + // a resource_close site is the try-with-resources resource local, positioned at its declaration + localSites: { + file: 'all-local-variables.csv', id: 'localVariableRegistryUniqueHash', startLine: 'startLine', endLine: 'endLine', + fileVia: { column: 'typeRegistryLinkHash', through: 'types' }, + }, // The Java report carries no construct and no position: the three reasons it emits // (EMPTY_CONTENT, FILE_TOO_LARGE, READ_ERROR) are properties of the whole file. skipped: { file: 'skipped-java-files.csv', filePath: 'filePath', reason: 'reason' }, diff --git a/graph/bundle/schema.ts b/graph/bundle/schema.ts index 5e6c5d4d8..8467c9467 100644 --- a/graph/bundle/schema.ts +++ b/graph/bundle/schema.ts @@ -374,6 +374,7 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'call_edges', column: 'tier', value: 'boundary_generated', languages: C, meaning: 'A read of a property the compiler generated (a positional record\'s property). There is no written accessor to point at, so callee_method_id is NULL, callee_label names the member and callee_provenance is `generated`. A correct end, not a blind spot.' }, { table: 'call_edges', column: 'tier', value: 'runtime_observed', languages: C, meaning: 'Only when a runtime trace is supplied: an edge the trace saw and the static pass did not name. Real by construction, and added only, never used to remove an edge. The site is the CALLER METHOD, not an expression (the tracer sees method entry, not the call site), and kind is `runtime`.' }, { table: 'call_edges', column: 'tier', value: 'known_implicit_ctor', languages: C, meaning: '`new Foo()` where Foo declares no constructor. The compiler supplies a parameterless one, so there is no user code to call and nothing resolving is the right answer.' }, + { table: 'call_edges', column: 'tier', value: 'event_dispatch', languages: C, meaning: 'A mediator\'s `Send(request)` reaching the `Handle` of the handler registered for the request\'s type (`IRequestHandler`, with a generic request\'s type arguments), or `Publish(notification)` reaching every `INotificationHandler` for its type and each of its bases. Not the site\'s own callee: the site also keeps its row for the library method. The container picks the handler in process, before the call returns, so this is a call edge and not a remote_edge. A request declared as a base (not an interface) fans to the handler of each type in its family.' }, { table: 'call_edges', column: 'callee_provenance', value: 'external', languages: 'all', meaning: 'The target is outside every staged IR. callee_method_id is NULL and callee_label names it, as `external:.`. A client-only run of a C# project reaches the BCL this way for most of its calls.' }, // run.key @@ -510,8 +511,9 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'call_edges', column: 'tier', value: 'implicit_constructor', languages: S, meaning: '`new C()` / `super()` where no constructor exists up the chain: the synthesized default runs. A correct end; callee is NULL.' }, { table: 'call_edges', column: 'tier', value: 'dynamic_terminal', languages: S, meaning: '`obj[expr]()`, `eval`, `import()`: no static target by construction; callee is NULL.' }, { table: 'call_edges', column: 'tier', value: 'fan_capped', languages: ['javascript', 'java', 'csharp'], meaning: 'More targets than --dispatch-cap: the set was refused rather than emitted. JavaScript: callee is NULL. Java and C#: callee is the declared base method the fan would have started from; dispatch-capped-sites.csv carries the refused count.' }, - { table: 'call_edges', column: 'tier', value: 'callback_registered', languages: S, meaning: 'The site HANDS the callee this function (`xs.forEach(f)`, `p.then(f)`, `emitter.on(\'x\', h)`, `setTimeout(f)`), which may invoke it. Not the site\'s own callee; a reachability edge, labelled so it is never read as a resolved call.' }, + { table: 'call_edges', column: 'tier', value: 'callback_registered', languages: ['javascript', 'typescript'], meaning: 'The site HANDS the callee this function (`xs.forEach(f)`, `p.then(f)`, `emitter.on(\'x\', h)`, `setTimeout(f)`), which may invoke it. Not the site\'s own callee; a reachability edge, labelled so it is never read as a resolved call.' }, { table: 'call_edges', column: 'tier', value: 'event_dispatch', languages: S, meaning: '`x.emit(\'name\')` reaching a handler registered by `x.on(\'name\', h)` on a value x may hold — name-sensitive for literal names, every handler on that value for a computed one.' }, + { table: 'call_edges', column: 'tier', value: 'event_dispatch', languages: J, meaning: 'A Spring application event: `publishEvent(e)` reaching each listener (`@EventListener`, `@TransactionalEventListener`, `ApplicationListener.onApplicationEvent`) whose declared event type e\'s static type is, or is a subtype of. Added beside the publishEvent boundary row, never in place of it (call-edge-generation/event_dispatch.dl).' }, { table: 'call_edges', column: 'tier', value: 'intrinsic_terminal', languages: T, meaning: 'The site is a JSX intrinsic element or a dynamic `import()` — a runtime intrinsic, not a function the graph can name.' }, // fields.kind / field_access.access / field_access.tier (#663) @@ -612,7 +614,9 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'call_edges', column: 'kind', value: 'lambda_body', languages: J, meaning: 'An edge from the method that INVOKES a lambda to something the lambda body calls. The body is not a method of its own, so its calls are attributed to the invoker rather than lost (call-edge-generation/lambda_dispatch.dl).' }, { table: 'call_edges', column: 'kind', value: 'ctor_delegate', languages: J, meaning: '`this(…)` / `super(…)` inside a constructor.' }, { table: 'call_edges', column: 'kind', value: 'anon_new', languages: J, meaning: '`new X() { … }` — an anonymous class creation.' }, + { table: 'call_edges', column: 'kind', value: 'event', languages: J, meaning: 'Synthesised: the publishEvent call site to a listener it runs (tier event_dispatch). Not a written call to that method.' }, { table: 'call_edges', column: 'kind', value: 'record_accessor', languages: J, meaning: 'Synthesised: a record pattern `case Pair(var l, var r)` calls each accessor. Not a written call; the site is the pattern expression.' }, + { table: 'call_edges', column: 'kind', value: 'resource_close', languages: J, meaning: 'Synthesised: a try-with-resources resource `try (var w = open())` is closed when the block exits, so its close() is called. Not a written call; the site is the resource local.' }, // — TypeScript (the parser's callKind) { table: 'call_edges', column: 'kind', value: 'FUNCTION_CALL', languages: T, meaning: '`f(…)` — a bare callee.' }, { table: 'call_edges', column: 'kind', value: 'METHOD_CALL', languages: T, meaning: '`obj.m(…)`.' }, @@ -671,7 +675,7 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'entry_points', column: 'reason', value: 'bean_ctor', languages: ['java', 'typescript'], meaning: 'Constructor of a container-managed class. TypeScript: the class carries a framework decorator (`@Injectable`, `@Component`, `@Module`), so the container constructs it and nothing in the repository does.' }, { table: 'entry_points', column: 'reason', value: 'factory', languages: J, meaning: 'A `@Bean` factory method.' }, { table: 'entry_points', column: 'reason', value: 'lifecycle', languages: ['java', 'typescript', 'csharp'], meaning: 'Java: `@PostConstruct` / `@PreDestroy` and similar hooks. TypeScript: a hook the container calls by name on a decorated class (`ngOnInit`, `onModuleInit`), which has no call site anywhere. C#: a method the host calls on a hosted service (`ExecuteAsync`, `StartAsync`, `StopAsync`, and the `IHostedLifecycleService` hooks), including one that derives from the host\'s base through the project\'s own base class.' }, - { table: 'entry_points', column: 'reason', value: 'queue', languages: ['java', 'csharp'], meaning: 'A message-listener method. C#: a broker consumer or a bus message handler.' }, + { table: 'entry_points', column: 'reason', value: 'queue', languages: ['java', 'csharp'], meaning: 'A message-listener method. Java: also a Spring application event listener (`@EventListener`, `@TransactionalEventListener`, an `ApplicationListener` implementation). C#: a broker consumer or a bus message handler.' }, { table: 'entry_points', column: 'reason', value: 'scheduled', languages: J, meaning: 'A `@Scheduled` method.' }, { table: 'entry_points', column: 'reason', value: 'grpc_service', languages: ['java', 'python', 'csharp'], meaning: 'A gRPC service implementation the server invokes on a request, with no call site reaching it: a generated `ImplBase` override (Java); a class deriving from a generated `*Servicer` base in a `_pb2_grpc` module, overriding a method that base declares (Python).' }, { table: 'entry_points', column: 'reason', value: 'web_servlet', languages: J, meaning: 'A servlet class named in `web.xml` (``): its container callbacks (`doGet`, `service`, …) and the library methods it overrides are invoked by the container.' }, @@ -682,6 +686,8 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'entry_points', column: 'reason', value: 'lifecycle_factory', languages: J, meaning: 'The method an XML bean definition names as `factory-method`; the container calls it to build the bean.' }, { table: 'entry_points', column: 'reason', value: 'config_handler', languages: J, meaning: 'A callback of a class a configuration file names under a key that expects a class (not an annotation); the container instantiates it and calls it.' }, { table: 'entry_points', column: 'reason', value: 'service_loader', languages: J, meaning: 'A callback of a provider listed in `META-INF/services`; `ServiceLoader` instantiates it and the caller reaches it through the service interface.' }, + { table: 'entry_points', column: 'reason', value: 'auto_configuration', languages: J, meaning: 'The constructor or a container callback of a configuration class named in `META-INF/spring/*.imports` or under a configuration key of `META-INF/spring.factories` (`EnableAutoConfiguration`); Spring Boot registers it as a bean.' }, + { table: 'entry_points', column: 'reason', value: 'spring_factories', languages: J, meaning: 'The constructor or callback of a class named under any other key of `META-INF/spring.factories` (`ApplicationContextInitializer`, `EnvironmentPostProcessor`, …); `SpringFactoriesLoader` instantiates it and calls it. Not a bean.' }, { table: 'entry_points', column: 'reason', value: 'unimported_module', languages: ['typescript', 'javascript'], meaning: 'The initializer of a module nothing imports — a script or a bundle root.' }, { table: 'entry_points', column: 'reason', value: 'exported_from_entry_module', languages: T, meaning: 'A named function exported from a module nothing in the project imports. Its caller is the package\'s consumer, which is not in the repository. Placeholder names (``) are not roots.' }, { table: 'entry_points', column: 'reason', value: 'package_export', languages: T, meaning: 'A named function exported from a module the project\'s `package.json` publishes (`main`, `module`, `exports`, `types`, `source`), mapped from build output back to its source. Its caller is the package\'s consumer. Covers the entry a project\'s own tests import, which `exported_from_entry_module` cannot see.' }, @@ -705,6 +711,8 @@ export const VOCAB: readonly VocabSpec[] = [ { table: 'type_instantiated', column: 'how', value: 'anonymous', languages: J, meaning: 'An anonymous class exists only by being instantiated.' }, { table: 'type_instantiated', column: 'how', value: 'enum_constant', languages: J, meaning: 'An enum\'s constants are its instances.' }, { table: 'type_instantiated', column: 'how', value: 'service_loader', languages: J, meaning: 'A provider listed in `META-INF/services`: `ServiceLoader` constructs it reflectively, with no `new` in the source.' }, + { table: 'type_instantiated', column: 'how', value: 'auto_configuration', languages: J, meaning: 'A configuration class named in `META-INF/spring/*.imports` or `spring.factories` (`EnableAutoConfiguration`): Spring Boot constructs it reflectively.' }, + { table: 'type_instantiated', column: 'how', value: 'spring_factories', languages: J, meaning: 'A class named under another key of `META-INF/spring.factories`: `SpringFactoriesLoader` constructs it reflectively.' }, // skipped.reason — parser/src/enums/SkippedFileReason.ts. The sets differ per front end // because they decline for different things; a value listed for no language is one no @@ -726,6 +734,7 @@ export const NOTES: readonly NoteSpec[] = [ { language: 'typescript', table: 'call_sites', note: 'caller_id is normally the parser\'s caller method, or the module initializer for top-level code; when neither exists it is the TS_MODULE_ hash itself, kept as a greppable marker rather than a blank.' }, { language: 'java', table: 'call_sites', note: 'callee_name for `new X()` and for `new X() { … }` (anon_new) is the class name written at the site; NULL for ctor_delegate (`this(…)`/`super(…)`) and record_accessor, which write no name. A by-name lookup must therefore exclude kind IN (new, anon_new) to avoid counting a construction as a call to a same-named method.' }, { language: 'java', table: 'call_sites', note: 'A record_accessor site is the RECORD_PATTERN expression, positioned where the pattern is written.' }, + { language: 'java', table: 'call_sites', note: 'A resource_close site is the resource LOCAL of a try-with-resources, positioned at its declaration (line only, no column); callee_name is NULL.' }, { language: 'python', table: 'call_sites', note: 'A DECORATOR_APPLICATION edge targets the callable the decorator factory RETURNS, not the name written at the `@` — `@deco(X)` applies the inner callable that `deco` returned. The written name is carried by the separate DECORATOR_CALL row, so a by-name lookup must exclude DECORATOR_APPLICATION or it will read the wrapper as a mismatch.' }, { language: 'typescript', table: 'call_sites', note: 'end_line / end_column come from the expression row; the call-site row itself records only the start.' }, { language: 'javascript', table: 'methods', note: 'signature is empty and owner_qualified_name is NULL: JavaScript declares neither. owner_type_id is set for class members, including members declared by assignment.' }, @@ -740,21 +749,21 @@ export const NOTES: readonly NoteSpec[] = [ { language: 'python', table: 'call_edges', note: 'A `boundary_lib` edge may point at a builtin (callee_provenance builtin, callee_label `builtin:NAME`) or at an unstaged import path (callee_provenance external) — neither has a methods row.' }, { language: 'java', table: 'call_edges', note: 'A `boundary_lib` edge with callee_provenance external names a method of an ancestor type no staged IR declares (callee_label `external:.`, no methods row). A site whose receiver is declared as such a type is multi_inferred even with one client override: the platform method itself, and the platform\'s own subclasses, are the other possible targets. Stage the library to replace the label with the real method.' }, { language: 'python', table: 'call_edges', note: 'The reason a site is ambiguous_unknown is exported per site in ext_call_site_unresolved (site, caller, reason, detail).' }, - { language: 'python', table: 'entry_points', note: 'EMPTY. The Python rule set does not derive entry points; entry_reachable is therefore empty too.' }, + { language: 'python', table: 'entry_points', note: 'Framework entry points only: url, http, orm_hook, task, signal_receiver, fixture, di_provider and grpc_service. There is no test and no main reason: a pytest test is recognised by the query layer from its file and name, not here.' }, { language: 'python', table: 'overrides', note: 'EMPTY — this table is Java-shaped. The Python dispatch envelope is in dispatch_candidates with basis `mro`, and `value` for a function assigned onto an instance\'s member; the raw linearisation is in ext_mro_position.' }, { language: 'python', table: 'type_instantiated', note: 'Every row has how = `new`: the rule set records that some client call constructs the class, not which form.' }, - { language: 'all', table: 'call_edges', note: 'THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list.' }, + { language: 'all', table: 'call_edges', note: 'THE TRUST LINE, and it is not the same set of tiers in every language. RESOLVED (callee_method_id is set): known_edge and multi_inferred in every language, and boundary_lib where the library is staged (--library) — without it boundary_lib names the target in callee_label and leaves callee_method_id NULL; ALSO ambient_terminal in TypeScript, fan_capped in Java and C# (the declared base, the fan refused), and runtime_observed in C#. HANDED OVER (callee set, but the site passes the function rather than calling it): callback_registered and event_dispatch in JavaScript, callback_registered in TypeScript; event_dispatch in C# too, where a mediator Send or Publish runs the handler for the request type, beside the row for the site itself. CORRECT END (callee NULL, and nothing is missing): intrinsic_terminal in TypeScript; ambient_terminal, implicit_constructor and dynamic_terminal in JavaScript; known_implicit_ctor, known_builtin_operator and boundary_generated (callee_label set) in C#. BLIND SPOT (callee NULL; exactly the tiers named `ambiguous_*`, which are what unresolved_sites holds): ambiguous_unknown everywhere, ALSO ambiguous_anon in Java and ambiguous_dynamic in C#. CAPPED (callee NULL, not in unresolved_sites): fan_capped in JavaScript. Python emits only the four shared tiers. A filter written as `tier IN (known_edge, multi_inferred)` therefore drops resolved edges in every language but Python — derive the set from this note or from unresolved_sites, never from a hardcoded list.' }, { language: 'java', table: 'call_edges', note: 'A multi_inferred fan is CHA-wide: it is every override the hierarchy admits, bounded only by the dispatch cap. type_instantiated is computed and exported but NOT read by any rule, so the fan is not narrowed to types the program constructs. Narrow it yourself by joining dispatch_candidates to type_instantiated — see the dispatch_envelope_of query. A receiver is ALSO typed by what flows into it (a local\'s initializer, the arguments callers pass to a parameter, the receivers callers invoke a method on for its `this`), and each flow-in type resolves its member directly, outside the fan: that is why a `fan_capped` site still carries edges, and why they are the types the program was seen to hand over, not the whole hierarchy.' }, { language: 'typescript', table: 'call_edges', note: 'A multi_inferred fan is CHA-wide, as in Java: type_instantiated is computed and exported but NOT read by any rule. The fan also has sources that are not virtual dispatch at all — an overload set or a union-typed receiver produces one too.' }, { language: 'python', table: 'call_edges', note: 'A multi_inferred fan IS narrowed by the instantiation set: type_instantiated_reachable (the constructed classes and their bases) bounds dispatch in resolution/dispatch.dl. Python is the only front end where that narrowing is applied, so a fan here is tighter than the same shape would be in Java or TypeScript.' }, - { language: 'all', table: 'type_use', note: 'JAVA AND TYPESCRIPT. Declared in every bundle and EMPTY for Python and JavaScript, so the schema does not churn as the remaining front ends land (#663).' }, + { language: 'all', table: 'type_use', note: 'JAVA AND TYPESCRIPT. Declared in every bundle and EMPTY for Python, JavaScript and C#, so the schema does not churn as the remaining front ends land (#663).' }, { language: 'typescript', table: 'type_use', note: 'The context set is TypeScript\'s own and is wider than Java\'s: AS_TARGET, SATISFIES_TARGET, TYPE_ALIAS_RHS, the CONDITIONAL_* family, MAPPED_*, INDEX_SIGNATURE_* and TEMPLATE_SPAN have no Java counterpart. A use inside a conditional type IS a use of that type and is recorded as one.' }, { language: 'typescript', table: 'type_use', note: 'Only a reference whose KIND can name a declaration is a row: TYPE_REFERENCE and IMPORT_TYPE. ARRAY, UNION, TUPLE and PARENTHESIZED are structure whose CHILDREN are the named references; PRIMITIVE, LITERAL, TYPE_VARIABLE, MAPPED, CONDITIONAL, INDEXED_ACCESS and INTRINSIC name nothing declared.' }, { language: 'java', table: 'type_use', note: 'EVERY DEPTH is here, unlike the receiver-typing relations the engine uses internally, which filter to depth 0. A field of type `Map` produces three rows. Filter on `depth = 0` when you want the type an expression has rather than every type its declaration mentions.' }, { language: 'java', table: 'type_use', note: 'A TYPE_VARIABLE reference (`T`, `E`) is not a row: it names the declaration\'s own parameter, not a type. Where the parameter has a written bound the USE resolves to that bound and IS a row, so ` void f(T t)` records a use of Node.' }, { language: 'java', table: 'type_use', note: 'The reference rows carry no line in the Java IR (every all-type-references row has an empty startLine), so this table has no position columns. Use owner_method_id, or owner_type_id plus the types row, to locate a use.' }, { language: 'all', table: 'field_access', note: 'JAVA AND TYPESCRIPT. The table is declared in every bundle and is EMPTY for Python, JavaScript and C# (C# fills `fields`, not `field_access`), so the schema does not churn as the remaining front ends land (#663). Check `SELECT count(*) FROM field_access` before reading an empty result as "nothing reads this field".' }, - { language: 'all', table: 'fields', note: 'JAVA AND TYPESCRIPT, for the same reason as field_access: declared everywhere, populated by those two front ends.' }, + { language: 'all', table: 'fields', note: 'JAVA, TYPESCRIPT AND C#. EMPTY for Python and JavaScript: a Python attribute is a symbols row of kind field. In C# it holds true fields only; a property is a symbols row of kind field with a property id, and its accessors are methods rows (PROPERTY_GET, PROPERTY_SET, PROPERTY_INIT).' }, { language: 'typescript', table: 'field_access', note: 'AN ACCESSOR IS NOT HERE. `get url()` read as `c.url` is a CALL, and call_edges already carries it with kind PROPERTY_READ or PROPERTY_WRITE (#703). The two tables are disjoint by construction: this one holds properties, call_edges holds accessors. Ask both when you want every read of a member.' }, { language: 'typescript', table: 'field_access', note: 'AN ELEMENT ACCESS IS NOT HERE either: `obj["x"]` with a literal key is a different node kind and is not yet a site. A known gap, not a silent one.' }, { language: 'typescript', table: 'field_access', note: 'A METHOD IS NOT A SITE. The callee of `obj.m()` is a PROPERTY_ACCESS node (37% of them, measured on one TypeScript library), and `const f = obj.m` reads a method as a value; neither is a data edge, and admitting them would fill the ambiguous_unknown tier with sites the engine HAS resolved elsewhere. Both are excluded and counted in ext_field_site_excluded with reasons method_callee and method_value.' }, diff --git a/graph/csharp/engine/call-edge-generation/call_chain.dl b/graph/csharp/engine/call-edge-generation/call_chain.dl index fce505f12..fdd5d50a5 100644 --- a/graph/csharp/engine/call-edge-generation/call_chain.dl +++ b/graph/csharp/engine/call-edge-generation/call_chain.dl @@ -34,6 +34,10 @@ // ambiguous_dynamic a call through `dynamic`. Unresolvable BY DESIGN: the DLR // decides at runtime. Separated from ambiguous_unknown so a // known-undecidable site is never counted as an engine failure. +// event_dispatch NOT the site's own target: a framework runs this method for the +// site in process (a mediator's Send reaching the handler for the +// request type). Added beside the site's row, by +// framework-behavior/dispatch.dl. // ============================================================================ // ── THE SITE UNIVERSE ─────────────────────────────────────────────────────── diff --git a/graph/csharp/engine/framework-behavior/destinations.dl b/graph/csharp/engine/framework-behavior/destinations.dl index b9170858a..4c7a0e875 100644 --- a/graph/csharp/engine/framework-behavior/destinations.dl +++ b/graph/csharp/engine/framework-behavior/destinations.dl @@ -45,6 +45,12 @@ call_recv_written_name(e, n) :- ref_denotes("client", r, "LOCAL_VARIABLE", v), var_is_implicit("client", v), var_value_initializer("client", v, init), expr_new_type_ref("client", init, tr), type_ref_name("client", tr, n). +// …and a property read off another value, `services.Mediator.Send(..)`: a minimal-API +// endpoint takes its services as one parameter object, and the receiver is that +// object's property, whose declared type name is what the project wrote. +call_recv_written_name(e, n) :- + call_receiver_expr("client", e, r), property_access("client", r, p, _), + property_type_name("client", p, n, _), n != "". // …and a `var` from the service locator, which holds its type argument call_recv_written_name(e, n) :- call_receiver_expr("client", e, r), @@ -706,24 +712,26 @@ msg_bus_arg(e, a) :- msg_bus_send_site(e, _, _), http_arg(e, 0, a), !msg_first_i msg_bus_arg(e, a) :- msg_bus_send_site(e, _, _), msg_first_is_address(e), http_arg(e, 1, a). msg_first_is_address(e) :- msg_bus_send_site(e, _, _), http_arg(e, 0, a0), http_arg(e, 1, _), expr_literal("client", a0, k, _), k != "NULL". -// the message's type NAME, however it is written -msg_arg_type_name(a, n) :- msg_bus_arg(_, a), expr_new_type_ref("client", a, tr), type_ref_name("client", tr, n0), last_segment(n0, n). -msg_arg_type_name(a, n) :- msg_bus_arg(_, a), expr_kind("client", a, "OBJECT_CREATION"), +// the message's type NAME, however it is written. Read for a bus send here and for an +// in-process mediator send (dispatch.dl), which carries its request the same way. +msg_typed_arg(a) :- msg_bus_arg(_, a). +msg_arg_type_name(a, n) :- msg_typed_arg(a), expr_new_type_ref("client", a, tr), type_ref_name("client", tr, n0), last_segment(n0, n). +msg_arg_type_name(a, n) :- msg_typed_arg(a), expr_kind("client", a, "OBJECT_CREATION"), call_callee_name("client", a, n0), last_segment(n0, n). -msg_arg_type_name(a, n) :- msg_bus_arg(_, a), expr_type("client", a, gk), type_group("client", t, gk), +msg_arg_type_name(a, n) :- msg_typed_arg(a), expr_type("client", a, gk), type_group("client", t, gk), type_decl("client", n, _, _, _, t). -msg_arg_type_name(a, n) :- msg_bus_arg(_, a), ref_denotes("client", a, "LOCAL_VARIABLE", v), +msg_arg_type_name(a, n) :- msg_typed_arg(a), ref_denotes("client", a, "LOCAL_VARIABLE", v), var_type_name("client", v, n0, _), n0 != "", last_segment(n0, n). -msg_arg_type_name(a, n) :- msg_bus_arg(_, a), ref_denotes("client", a, "PARAMETER", p), +msg_arg_type_name(a, n) :- msg_typed_arg(a), ref_denotes("client", a, "PARAMETER", p), param_type_name("client", p, n0, _), n0 != "", last_segment(n0, n). // A local declared as a BASE is usually assigned a concrete message on each branch, // Notice evt; if (ok) evt = new Accepted(..); else evt = new Rejected(..); // and those constructions, in the same method, are what it can hold. -msg_arg_type_name(a, n) :- msg_bus_arg(_, a), ref_denotes("client", a, "LOCAL_VARIABLE", v), +msg_arg_type_name(a, n) :- msg_typed_arg(a), ref_denotes("client", a, "LOCAL_VARIABLE", v), msg_local_assigned(v, c), msg_creation_name(c, n). -msg_arg_type_name(a, n) :- msg_bus_arg(_, a), ref_denotes("client", a, "LOCAL_VARIABLE", v), +msg_arg_type_name(a, n) :- msg_typed_arg(a), ref_denotes("client", a, "LOCAL_VARIABLE", v), var_value_initializer("client", v, c), msg_creation_name(c, n). -msg_local_assigned(v, c) :- msg_bus_arg(_, a), ref_denotes("client", a, "LOCAL_VARIABLE", v), +msg_local_assigned(v, c) :- msg_typed_arg(a), ref_denotes("client", a, "LOCAL_VARIABLE", v), expr_kind("client", asg, "ASSIGNMENT"), expr_child("client", asg, "ASSIGNMENT_TARGET", _, t), ref_denotes("client", t, "LOCAL_VARIABLE", v), expr_child("client", asg, "ASSIGNMENT_VALUE", _, c). msg_creation_name(c, n) :- expr_kind("client", c, "OBJECT_CREATION"), expr_new_type_ref("client", c, tr), diff --git a/graph/csharp/engine/framework-behavior/dispatch.dl b/graph/csharp/engine/framework-behavior/dispatch.dl new file mode 100644 index 000000000..8b8e07a1e --- /dev/null +++ b/graph/csharp/engine/framework-behavior/dispatch.dl @@ -0,0 +1,157 @@ +// ============================================================================ +// FRAMEWORK-BEHAVIOR · DISPATCH (the in-process hop no call site expresses) +// +// A mediator hands a request to its handler inside the same process: +// +// await mediator.Send(new GetOrder(id)); // the sender +// class GetOrderHandler : IRequestHandler +// { public Task Handle(GetOrder r, CancellationToken ct) ... } +// +// The container picks the handler by the request's TYPE, so no call site names it and +// every rule in call-edge-generation is silent: `path` called the two independent and +// `impact` on the handler said the change was local. +// +// WHY A CALL EDGE HERE, AND NOT remote_edge. destinations.dl joins the same shape for a +// message bus, where the two ends are in different processes, and emits remote_edge. +// This hop is on the sender's call stack: Handle runs before Send returns. So it is a +// call_chain_edge, labelled `event_dispatch` (the tier the JavaScript engine gives an +// emit reaching its handler), which consumers already rank below a resolved call and +// print as `registered` rather than `resolved`. It is ADDED beside the site's own row +// (the call to the library's Send); nothing the resolution phases derived changes. +// +// Every framework name is a knob (knobs.dl: cs_med_dispatch, over the cs_framework_callback +// table that makes a handler an entry point). The request's type name is read by the +// rules that already read a bus message's (destinations.dl, msg_arg_type_name). +// ============================================================================ + +// A handler: a type that implements or for a +// Base in cs_med_dispatch, and its method that cs_framework_callback names for that Base +// (the table that already makes it an entry point), whose first parameter is written as +// that request type. The parameter check keeps a class that handles two requests from +// answering both with each Handle. +med_handler(m, bn, tn, ar) :- heritage_slot("client", t, _, bn0, ba, href), (ba = "1" ; ba = "2"), + last_segment(bn0, bn), cs_med_dispatch(_, bn, _), cs_framework_callback(bn, mn, _), + type_ref_arg("client", href, "0", ar), type_ref_name("client", ar, tn0), last_segment(tn0, tn), + method_owner("client", t, m), method_name("client", m, mn), + param_decl("client", m, "0", _, p), param_type_name("client", p, pn0, _), last_segment(pn0, tn). + +// A send: a dispatching method on a mediator receiver, with the request as its first +// argument. The method decides which handler base answers it (Send: IRequestHandler, +// Publish: INotificationHandler), so a notification Sent reaches no notification handler. +med_send_site(e, from, bn, fan) :- msg_recv_name(e, rn), cs_med_sender_type(rn), + call_callee_name("client", e, sn), cs_med_dispatch(sn, bn, fan), call_from("client", e, from). +med_send_arg(e, a) :- med_send_site(e, _, _, _), http_arg(e, 0, a). +msg_typed_arg(a) :- med_send_arg(_, a). + +// A name is a BASE when some handled request type derives from it. A request declared +// as the base (a parameter, a local) is any of them at run time: it is not matched by +// name here, and med_declared_handler below fans it over the family instead. +med_base_name(n) :- med_handler(_, _, _, ar), type_ref_resolves("client", ar, hgk), type_ancestor("client", hgk, bgk), + type_group("client", t, bgk), type_decl("client", n, _, _, _, t). +med_send(e, from, n) :- med_send_site(e, from, _, _), med_send_arg(e, a), msg_arg_type_name(a, n), !med_base_name(n). +// ...but a request CONSTRUCTED at the send has exactly the type written after `new`. +med_send(e, from, n) :- med_send_site(e, from, _, _), med_send_arg(e, a), expr_kind("client", a, "OBJECT_CREATION"), + msg_creation_name(a, n). + +// THE NAME IS THE KEY, AND THE TYPE DECIDES WHEN BOTH ENDS HAVE ONE. A simple name is +// not unique: two test classes each declaring a nested `Ping` and `PingHandler` is the +// ordinary shape of a mediator's own tests, and matching by name linked every Send(new +// Ping()) to every PingHandler. So where the handler's type argument resolves and the +// request resolves to a type OF THAT NAME, the two types must be the same one. The name +// alone decides only where one end has no resolved type: the request comes from a +// package, or the send's value is typed by nothing the engine can see. +med_send_type(e, gk) :- med_send_arg(e, a), expr_type("client", a, gk). +med_send_type(e, gk) :- med_send_arg(e, a), ref_denotes("client", a, "LOCAL_VARIABLE", v), + msg_local_assigned(v, c), expr_type("client", c, gk). +med_send_type(e, gk) :- med_send_arg(e, a), ref_denotes("client", a, "LOCAL_VARIABLE", v), + var_value_initializer("client", v, c), expr_type("client", c, gk). +med_send_named_type(e, n, gk) :- med_send_type(e, gk), type_group("client", t, gk), type_decl("client", n, _, _, _, t). +med_send_has_named_type(e, n) :- med_send_named_type(e, n, _). +med_handler_type(ar, gk) :- med_handler(_, _, _, ar), type_ref_resolves("client", ar, gk). +med_handler_typed(ar) :- med_handler_type(ar, _). + +med_match(e, m, ar) :- med_send(e, _, n), med_send_site(e, _, bn, _), med_handler(m, bn, n, ar), + med_handler_type(ar, gk), med_send_named_type(e, n, gk). +med_match(e, m, ar) :- med_send(e, _, n), med_send_site(e, _, bn, _), med_handler(m, bn, n, ar), + !med_handler_typed(ar), med_written_agrees(e, n, ar). +med_match(e, m, ar) :- med_send(e, _, n), med_send_site(e, _, bn, _), med_handler(m, bn, n, ar), + !med_send_has_named_type(e, n), med_typed_agrees(e, n, ar). + +// A handler's argument that does not resolve may still be written QUALIFIED, +// `IRequestHandler`: the simple name is then not the evidence, the +// written tail is, and a request resolved to Shop.GetOrder is not Shop.Legacy.GetOrder. +med_written_agrees(e, n, ar) :- med_send(e, _, n), med_handler(_, _, n, ar), type_ref_name("client", ar, w), !contains(".", w). +med_written_agrees(e, n, ar) :- med_send(e, _, n), med_handler(_, _, n, ar), !med_send_has_named_type(e, n). +med_written_agrees(e, n, ar) :- med_handler(_, _, n, ar), type_ref_name("client", ar, w), contains(".", w), + med_send_named_type(e, n, gk), type_group("client", t, gk), type_decl("client", _, qn, _, _, t), + lq = strlen(qn), lw = strlen(w), lq > lw, substr(qn, lq - lw - 1, lw + 1) = cat(".", w). +// …and the same at the send: `Send(new Legacy.GetOrder(id))` names Shop.Legacy.GetOrder +// even where the engine does not resolve it, and a handler of Shop.GetOrder is not it. +med_send_written(e, w) :- med_send_arg(e, a), expr_new_type_ref("client", a, tr), type_ref_name("client", tr, w). +med_send_written(e, w) :- med_send_arg(e, a), ref_denotes("client", a, "LOCAL_VARIABLE", v), var_type_name("client", v, w, _), w != "". +med_send_written(e, w) :- med_send_arg(e, a), ref_denotes("client", a, "PARAMETER", p), param_type_name("client", p, w, _), w != "". +med_send_written(e, w) :- med_send_arg(e, a), ref_denotes("client", a, "LOCAL_VARIABLE", v), + msg_local_assigned(v, c), expr_new_type_ref("client", c, tr), type_ref_name("client", tr, w). +med_send_written(e, w) :- med_send_arg(e, a), ref_denotes("client", a, "LOCAL_VARIABLE", v), + var_value_initializer("client", v, c), expr_new_type_ref("client", c, tr), type_ref_name("client", tr, w). +med_send_qualified(e, n) :- med_send_written(e, w), contains(".", w), last_segment(w, n). +med_typed_agrees(e, n, ar) :- med_send(e, _, n), med_handler(_, _, n, ar), !med_send_qualified(e, n). +med_typed_agrees(e, n, ar) :- med_send_written(e, w), contains(".", w), last_segment(w, n), med_handler(_, _, n, ar), + med_handler_type(ar, gk), type_group("client", t, gk), type_decl("client", _, qn, _, _, t), + lq = strlen(qn), lw = strlen(w), lq > lw, substr(qn, lq - lw - 1, lw + 1) = cat(".", w). + +// A GENERIC request is one type to the resolver, `GetById`, and the handler is +// chosen with its arguments: `IRequestHandler, Order>` does not answer +// `Send(new GetById(id))`. Where both ends write an argument at the same position +// and the handler's is not its own type parameter (an open generic handler, +// `ListAllHandler : IRequestHandler, List>`, serves any), the names must agree. +med_send_new_ref(e, tr) :- med_send_arg(e, a), expr_new_type_ref("client", a, tr). +med_send_new_ref(e, tr) :- med_send_arg(e, a), ref_denotes("client", a, "LOCAL_VARIABLE", v), + msg_local_assigned(v, c), expr_new_type_ref("client", c, tr). +med_send_new_ref(e, tr) :- med_send_arg(e, a), ref_denotes("client", a, "LOCAL_VARIABLE", v), + var_value_initializer("client", v, c), expr_new_type_ref("client", c, tr). +med_arg_clash(e, ar) :- med_match(e, _, ar), med_send_new_ref(e, tr), + type_ref_arg("client", ar, i, ha), type_ref_arg("client", tr, i, sa), !type_ref_type_param("client", ha, _), + type_ref_name("client", ha, hn0), last_segment(hn0, hn), type_ref_name("client", sa, sn0), last_segment(sn0, sn), + hn != sn. +med_exact(e, m) :- med_match(e, m, ar), !med_arg_clash(e, ar). + +// Publish runs every handler whose type the notification CONVERTS to: INotificationHandler +// is contravariant, and a handler of OrderEvent runs for an OrderPlaced. Only where the +// notification's type is resolved, since a base is a resolved ancestor. +med_send_concrete(e, gk) :- med_send(e, _, n), med_send_named_type(e, n, gk). +med_base_handler(e, m) :- med_send_site(e, _, bn, "all"), med_send_concrete(e, gk), type_ancestor("client", gk, agk), + med_handler(m, bn, _, ar), med_handler_type(ar, agk). + +// A request DECLARED as a base, `Task Any(ShapeQuery q) => sender.Send(q)`, holds +// the base or any type derived from it, and the container picks the handler for that +// runtime type: the fan is the handler of each type in the family (and, for a Publish, +// of each base as well). Only where no construction is visible to name the value, and +// never for an interface, which would be every request of the project. +med_send_sees_new(e) :- med_send_arg(e, a), expr_kind("client", a, "OBJECT_CREATION"). +med_send_sees_new(e) :- med_send_new_ref(e, _). +med_send_declared(e, bgk) :- med_send_site(e, _, _, _), !med_send_sees_new(e), med_send_arg(e, a), + msg_arg_type_name(a, n), med_base_name(n), expr_type("client", a, bgk), + type_group("client", t, bgk), type_decl("client", n, _, _, kd, t), kd != "INTERFACE". +med_declared_handler(e, m) :- med_send_declared(e, bgk), med_send_site(e, _, bn, _), + med_handler(m, bn, _, ar), med_handler_type(ar, bgk). +med_declared_handler(e, m) :- med_send_declared(e, bgk), med_send_site(e, _, bn, _), + med_handler(m, bn, _, ar), med_handler_type(ar, hgk), type_ancestor("client", hgk, bgk). +med_declared_handler(e, m) :- med_send_declared(e, bgk), med_send_site(e, _, bn, "all"), + med_handler(m, bn, _, ar), med_handler_type(ar, agk), type_ancestor("client", bgk, agk). + +// A pipeline step written open over the request, `TimingBehavior : +// IPipelineBehavior`, is registered for every request and runs on every Send. +// Only for a "wrap" base: an open request HANDLER is not how a request is answered. +med_open_step(m, bn) :- cs_med_dispatch(_, bn, "wrap"), med_handler(m, bn, _, ar), type_ref_type_param("client", ar, _). +med_open_dispatch(e, m) :- med_send_site(e, _, bn, "wrap"), med_open_step(m, bn). + +med_dispatch(e, m) :- med_exact(e, m). +med_dispatch(e, m) :- med_open_dispatch(e, m). +med_dispatch(e, m) :- med_base_handler(e, m). +med_dispatch(e, m) :- med_declared_handler(e, m). +// A send whose request is an argument MATCHER is a mock's stub or verification, not a +// dispatch (knobs.dl, cs_mock_arg_matcher). +med_send_matcher(e) :- med_send_arg(e, a), call_callee_name("client", a, n0), last_segment(n0, n), cs_mock_arg_matcher(n). +call_chain_edge(e, from, "-", m, "client", "event_dispatch", kind) :- med_send_site(e, from, _, _), med_dispatch(e, m), + !med_send_matcher(e), from != m, invocation_site("client", e, kind). diff --git a/graph/csharp/engine/framework-behavior/knobs.dl b/graph/csharp/engine/framework-behavior/knobs.dl index aa4b3e4a2..a44b0be60 100644 --- a/graph/csharp/engine/framework-behavior/knobs.dl +++ b/graph/csharp/engine/framework-behavior/knobs.dl @@ -146,7 +146,8 @@ cs_servicebus_handler_event("ProcessMessageAsync"). // A handler implements (interface, method) for its message type; a sender calls one // of these methods on one of these receivers with a message instance. MediatR's // INotificationHandler and IMediator are deliberately ABSENT: they dispatch in -// process, and a remote edge there would be a wrong answer. +// process, and a remote edge there would be a wrong answer: Send and Publish are joined in +// process by cs_med_dispatch below. cs_msg_handler_iface("IConsumer", "Consume", "masstransit"). cs_msg_handler_iface("IHandleMessages", "Handle", "nservicebus"). cs_msg_handler_iface("IIntegrationEventHandler", "Handle", "event_bus"). @@ -168,6 +169,39 @@ cs_msg_bus_send("SendAsync"). cs_remote_confidence_binding("amqp_binding"). +// ── IN PROCESS: a mediator keyed on the request TYPE (dispatch.dl) ───────── +// `mediator.Send(new GetOrder(id))` runs Handle on the one handler the container holds +// for GetOrder: `IRequestHandler`, or `IRequestHandler` when the +// request answers nothing. `Publish(new OrderPlaced(id))` runs EVERY +// `INotificationHandler` whose T is OrderPlaced or one of its bases. Same process, +// same call stack, so the hop is a call edge and never a remote_edge. +// cs_med_dispatch(SendMethod, HandlerBase, Fan): the handler's method is the one +// cs_framework_callback (below) already names for HandlerBase, so a handler is an entry +// point and a sent handler also has its sender. Fan "one": the handler of the exact +// type (the container registers a request handler for the type it closes, and a base's +// handler is not looked up for a derived request); "all": every handler of the type and +// of each of its bases (INotificationHandler is contravariant); "wrap": a pipeline +// step, run around the handler for its own request type, and around EVERY request when +// it is open generic over the request (`TimingBehavior : IPipelineBehavior`). +cs_med_dispatch("Send", "IRequestHandler", "one"). +cs_med_dispatch("Send", "IPipelineBehavior", "wrap"). +cs_med_dispatch("Send", "IRequestPreProcessor", "wrap"). +cs_med_dispatch("Send", "IRequestPostProcessor", "wrap"). +cs_med_dispatch("CreateStream", "IStreamRequestHandler", "one"). +cs_med_dispatch("CreateStream", "IStreamPipelineBehavior", "wrap"). +cs_med_dispatch("Publish", "INotificationHandler", "all"). +// A test that stubs or verifies the mediator writes the send with an argument MATCHER, +// `mediator.Send(Arg.Any())` or `m.Send(It.IsAny(), ..)` inside a +// mock setup: nothing is dispatched, so no handler is linked from it. +cs_mock_arg_matcher("Any"). cs_mock_arg_matcher("Is"). // NSubstitute Arg.* +cs_mock_arg_matcher("IsAny"). cs_mock_arg_matcher("IsNotNull"). // Moq It.* +cs_mock_arg_matcher("That"). // FakeItEasy A.That +// the receivers a request or notification is sent through +cs_med_sender_type("IMediator"). +cs_med_sender_type("ISender"). +cs_med_sender_type("IPublisher"). +cs_med_sender_type("Mediator"). + // Kafka clients are built, `new ProducerBuilder(config).Build()`, usually into a // `var`: the builder's name is what says which client the local holds. cs_msg_builder("ProducerBuilder", "kafka_producer"). diff --git a/graph/csharp/souffle/decls_all.dl b/graph/csharp/souffle/decls_all.dl index d8993f6ff..37161bfd0 100644 --- a/graph/csharp/souffle/decls_all.dl +++ b/graph/csharp/souffle/decls_all.dl @@ -168,6 +168,9 @@ .decl cs_http_verb_any(c0:symbol) .decl cs_http_verb_attr(c0:symbol,c1:symbol) .decl cs_lower(c0:symbol,c1:symbol) +.decl cs_med_dispatch(c0:symbol,c1:symbol,c2:symbol) +.decl cs_med_sender_type(c0:symbol) +.decl cs_mock_arg_matcher(c0:symbol) .decl cs_msg_builder(c0:symbol,c1:symbol) .decl cs_msg_bus_send(c0:symbol) .decl cs_msg_bus_type(c0:symbol,c1:symbol) @@ -417,6 +420,33 @@ .decl map_handler(c0:symbol,c1:symbol) .decl marker_scrub(c0:symbol,c1:symbol) .decl mb_demand(c0:symbol) +.decl med_arg_clash(c0:symbol,c1:symbol) +.decl med_base_handler(c0:symbol,c1:symbol) +.decl med_base_name(c0:symbol) +.decl med_declared_handler(c0:symbol,c1:symbol) +.decl med_dispatch(c0:symbol,c1:symbol) +.decl med_exact(c0:symbol,c1:symbol) +.decl med_handler(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl med_handler_type(c0:symbol,c1:symbol) +.decl med_handler_typed(c0:symbol) +.decl med_match(c0:symbol,c1:symbol,c2:symbol) +.decl med_open_dispatch(c0:symbol,c1:symbol) +.decl med_open_step(c0:symbol,c1:symbol) +.decl med_send(c0:symbol,c1:symbol,c2:symbol) +.decl med_send_arg(c0:symbol,c1:symbol) +.decl med_send_concrete(c0:symbol,c1:symbol) +.decl med_send_declared(c0:symbol,c1:symbol) +.decl med_send_has_named_type(c0:symbol,c1:symbol) +.decl med_send_named_type(c0:symbol,c1:symbol,c2:symbol) +.decl med_send_matcher(c0:symbol) +.decl med_send_new_ref(c0:symbol,c1:symbol) +.decl med_send_qualified(c0:symbol,c1:symbol) +.decl med_send_sees_new(c0:symbol) +.decl med_send_site(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl med_send_type(c0:symbol,c1:symbol) +.decl med_send_written(c0:symbol,c1:symbol) +.decl med_typed_agrees(c0:symbol,c1:symbol,c2:symbol) +.decl med_written_agrees(c0:symbol,c1:symbol,c2:symbol) .decl member_access(c0:symbol,c1:symbol,c2:symbol) .decl member_access_is_callee(c0:symbol,c1:symbol) .decl member_access_resolved(c0:symbol,c1:symbol) @@ -532,6 +562,7 @@ .decl msg_recv_name(c0:symbol,c1:symbol) .decl msg_request_site(c0:symbol,c1:symbol,c2:symbol) .decl msg_type_arg(c0:symbol,c1:symbol) +.decl msg_typed_arg(c0:symbol) .decl name_to_type(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl nested_declares(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) .decl new_target_decl_name(c0:symbol,c1:symbol,c2:symbol,c3:symbol) diff --git a/graph/java/engine/call-edge-generation/call_chain.dl b/graph/java/engine/call-edge-generation/call_chain.dl index b509e6ed2..2e904be31 100644 --- a/graph/java/engine/call-edge-generation/call_chain.dl +++ b/graph/java/engine/call-edge-generation/call_chain.dl @@ -221,6 +221,48 @@ call_chain_edge(patternExpr, caller, "-", accessor, "lib", "boundary_lib", "reco record_pattern_accessor(patternExpr, accessor, "lib"), expr_ultimate_method("client", patternExpr, caller). +// ── TRY-WITH-RESOURCES: the implicit close() ───────────────────────────────────────────────── +// `try (var w = Widget.create()) { … }` closes w when the block exits, and javac writes that call +// (`invokevirtual Widget.close()`) into the method. No source line spells it, so there was no site +// and no edge, and a close() that only ever runs this way had no caller at all. A real edge, not +// lowering: close() is the resource's own method, often client code with a body of its own. The +// suppressed-exception plumbing beside it (`addSuppressed`) IS lowering, and both oracles drop it. +// +// Exactly one close() per resource, declared or `var`: the resource's type is its declared type, +// or for `var` its initializer's (JLS 14.4.1). The target is the close() with no parameter that +// lookup finds on that type, plus, as for a written call, the overrides below an abstract or +// interface declaration unless the dispatch cap refuses that fan. +// +// The site is the resource LOCAL: it has no expression row, and the initializer's expression is a +// written call site of its own. The `kind` column says resource_close, so a synthesised edge is +// never mistaken for a written call; like record_accessor it is not an invocation_site. +// Java 9's `try (existing)` names a variable declared elsewhere and has no resource local, so it +// is not covered here. +// java_local_variable (19): 8 scopeKind 11 isVarInferred 13 method 18 hash +twr_resource(local, m) :- java_local_variable(_, _, _, _, _, _, _, _, "TRY_WITH_RESOURCES", _, _, _, _, m, _, _, _, _, local), + m != "". +twr_resource_type(local, t) :- twr_resource(local, _), local_var_type_resolves(_, local, _, t). +twr_resource_type(local, t) :- twr_resource(local, _), + java_local_variable(_, _, _, _, _, _, _, _, _, _, _, "true", _, _, _, _, _, _, local), + local_init_expr(local, e), + expr_type(p, e, t), p != "external". +// java_method / lib_method (22): 0 name 17 parameterCount 21 hash +close_no_arg(m) :- java_method("close", _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, "0", _, _, _, m). +close_no_arg(m) :- lib_method("close", _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, "0", _, _, _, m). +twr_close_decl(local, d) :- twr_resource_type(local, t), method_name_in_type("close", t, d), close_no_arg(d). +twr_close(local, d) :- twr_close_decl(local, d). +twr_close(local, c) :- twr_close_decl(local, d), method_override(c, d), !cha_wide_base(d). +twr_close_count(local, n) :- twr_close(local, _), n = count : { twr_close(local, _) }. + +call_chain_edge(local, caller, "-", c, "client", "known_edge", "resource_close") :- + twr_close(local, c), method_owner("client", _, c), + twr_close_count(local, 1), twr_resource(local, caller). +call_chain_edge(local, caller, "-", c, "client", "multi_inferred", "resource_close") :- + twr_close(local, c), method_owner("client", _, c), + twr_close_count(local, n), n >= 2, twr_resource(local, caller). +call_chain_edge(local, caller, "-", c, "lib", "boundary_lib", "resource_close") :- + twr_close(local, c), method_owner("lib", _, c), twr_resource(local, caller). + call_chain_edge(call, caller, "-", "-", "-", "ambiguous_anon", kind) :- call_class(call, "ambiguous_anon"), call_from(call, caller), invocation_site(call, kind). diff --git a/graph/java/engine/call-edge-generation/event_dispatch.dl b/graph/java/engine/call-edge-generation/event_dispatch.dl new file mode 100644 index 000000000..2888a322c --- /dev/null +++ b/graph/java/engine/call-edge-generation/event_dispatch.dl @@ -0,0 +1,114 @@ +// ============================================================================ +// Call-edge generation · APPLICATION EVENTS (publishEvent -> the listeners it runs) +// +// publisher.publishEvent(new OrderPlaced(id)); // written here +// @EventListener void on(OrderPlaced e) { … } // run from there +// class Ship implements ApplicationListener { void onApplicationEvent(OrderShipped e) } +// +// Spring's multicaster hands the event to every listener whose declared event type the +// event is assignable to, synchronously and in the publishing thread unless @Async is +// on the listener. No call site names the listener, so without this file the publisher +// ended at `external:…ApplicationEventPublisher.publishEvent`, `path` from publisher to +// listener said the two were independent, and `impact` on a listener said the change +// was local (#1391). +// +// WHY A CALL EDGE AND NOT A remote_edge. framework-behavior/destinations.dl joins two +// ends that live in different processes on a written STRING, and emits remote_edge so a +// reader can tell that from an invocation. An application event crosses no process and +// has no string: it is a dispatch inside one JVM, keyed on a TYPE, and the listener runs +// before publishEvent returns. That is the JavaScript `emit` -> `on` shape, so it uses +// the tier the JavaScript front end already emits for it, `event_dispatch`, and every +// tool that walks call_chain_edge (path, impact, entry_reachable) sees through it with +// no change. The boundary row for the publishEvent call itself is kept: the edge is +// ADDED beside it, never in place of it. +// +// THE KEY IS THE EVENT'S TYPE, with a subtype rule, exactly as Spring matches it: +// an event of static type E reaches a listener for L when E is L or a subtype of L. +// So a listener for OrderPlaced receives an ExpressOrderPlaced, a listener for +// ExpressOrderPlaced does NOT receive a plain OrderPlaced, and a listener for an +// unrelated type receives neither. A listener for the event's SUPERTYPE (ApplicationEvent, +// or a project base event) receives it too, which is what Spring does. +// +// WHAT IS NOT MODELLED, and stays unlinked rather than guessed: +// - a publisher wrapped in a project helper (`Events.publish(e)` whose body calls +// publishEvent on an Object parameter): the static type there is Object, so only an +// Object listener would match, which is the honest static answer. +// - generic events (EntityCreated) are matched on the erased type, and a +// PayloadApplicationEvent listener is not matched to a published T. +// - @EventListener(condition = …) is not evaluated; the listener is linked. +// - a listener's returned event is not re-published. +// ============================================================================ + +// ── the publish sites ─────────────────────────────────────────────────────── +// A type NAMED as a publisher or a listener interface: an external node (the library is +// not staged, which is the usual case) or a staged library type. The external node's id +// is `external:`, or `external:` when the name came from an +// on-demand import, so both spellings are matched. Demand-driven from the knob lists, so +// this never walks the type table. +event_knob_type(n) :- cfg_event_publisher(n, _). +event_knob_type(n) :- cfg_event_listener_type(n, _). +event_knob_type(n) :- cfg_event_catch_all(n). +event_named_type(x, n) :- event_knob_type(n), external_type(x), x = cat("external:", n). +event_named_type(x, n) :- event_knob_type(n), external_type(x), + strlen(x) > strlen(n) + 1, + substr(x, max(0, strlen(x) - strlen(n) - 1), strlen(n) + 1) = cat(".", n). +event_named_type(t, n) :- event_knob_type(n), lib_type(n, _, _, _, _, _, _, _, _, _, _, _, _, t). + +// a receiver declared as a publisher type, or as a staged subtype of one +event_publisher_recv_type(t, name) :- event_named_type(t, n), cfg_event_publisher(n, name). +event_publisher_recv_type(t, name) :- event_named_type(a, n), cfg_event_publisher(n, name), + type_ancestor(t, a). + +event_publish_site(call) :- call_site(call, name, recv), cfg_event_publisher(_, name), + expr_type(_, recv, t), event_publisher_recv_type(t, name). + +// what is published: the static type of the first argument +event_published(call, caller, et) :- event_publish_site(call), + expr_child("client", call, "ARGUMENT", arg), expr_position("client", arg, "0"), + expr_type(_, arg, et), call_from(call, caller). + +// ── the listeners, and the event type each one declares ───────────────────── +event_param_type(m, t) :- java_method_parameter(_, _, m, _, _, _, _, _, _, _, _, _, p), + method_param_type_resolves(_, p, _, t). +event_param_type(m, x) :- java_method_parameter(_, _, m, _, _, _, _, _, _, _, _, _, p), + method_param_type_external(p, x). + +// (a) @EventListener / @TransactionalEventListener. The types named on the annotation +// win when present (Spring then ignores the parameter for matching); otherwise the +// one parameter's type. +event_listener_ann_method(m, a) :- ann_on_method(_, a, n, m, _), cfg_event_listener_ann(n), + method_owner("client", _, m). +event_listener_names_classes(m) :- event_listener_ann_method(m, a), + ann_arg(_, a, arg, _, "CLASS_REFERENCE", _), cfg_event_listener_arg(arg). +event_listens(m, t) :- event_listener_ann_method(m, a), + ann_arg(_, a, arg, v, "CLASS_REFERENCE", _), cfg_event_listener_arg(arg), + config_class_ref("annotation", a, v, t, _). +event_listens(m, t) :- event_listener_ann_method(m, _), !event_listener_names_classes(m), + method_paramc(m, 1), event_param_type(m, t). + +// (b) an ApplicationListener implementation: its onApplicationEvent(E) override. The +// override's parameter IS the type argument, so it is read from there rather than +// from the implements clause. +event_listener_class(t, mname) :- event_named_type(x, n), cfg_event_listener_type(n, mname), + type_ancestor(t, x), java_type(_, _, _, _, _, _, _, _, _, _, _, _, _, t). +event_listener_method(m) :- event_listener_class(t, mname), + java_method(mname, _, _, _, _, _, _, t, _, _, _, _, _, _, _, _, "INSTANCE_METHOD", "1", _, _, _, m). +event_listens(m, t) :- event_listener_method(m), event_param_type(m, t). + +// ── the edge ──────────────────────────────────────────────────────────────── +// Two clauses rather than one disjunction, so each joins on the type column: the exact +// type, then every ancestor of it. +event_dispatch_edge(call, caller, m) :- event_published(call, caller, et), event_listens(m, et). +event_dispatch_edge(call, caller, m) :- event_published(call, caller, et), type_ancestor(et, lt), + event_listens(m, lt). + +// A listener declared for a type EVERY event is: Spring wraps an event that is not an +// ApplicationEvent in a PayloadApplicationEvent before multicasting it, so a listener for +// ApplicationEvent receives a published OrderPlaced as well as every ApplicationEvent +// subtype, and a listener for Object receives everything. Neither is reached by the +// subtype rule for a plain object event. +event_catch_all_listener(m) :- event_named_type(x, n), cfg_event_catch_all(n), event_listens(m, x). +event_dispatch_edge(call, caller, m) :- event_published(call, caller, _), event_catch_all_listener(m). + +call_chain_edge(call, caller, "-", m, "client", "event_dispatch", "event") :- + event_dispatch_edge(call, caller, m). diff --git a/graph/java/engine/config-resolution/di.dl b/graph/java/engine/config-resolution/di.dl index d2f1b7ffd..45ec9be7b 100644 --- a/graph/java/engine/config-resolution/di.dl +++ b/graph/java/engine/config-resolution/di.dl @@ -200,6 +200,110 @@ cfg_decap_demand(sn) :- cfg_repo_iface(t), type_decl(_, sn, _, _, _, _, t). bean_def(name, t, "repository_proxy", t) :- cfg_repo_iface(t), !cfg_repo_excluded(t), type_decl(_, sn, _, _, _, _, t), cfg_decap(sn, name). +// (b'') REGISTRY-NAMED CONFIGURATION: a class META-INF/spring/*.imports names, or a +// configuration key of META-INF/spring.factories (services.dl). Boot registers each one +// as a bean named by its fully-qualified name. Not a stereotype on @AutoConfiguration: +// component scanning EXCLUDES an auto-configuration, so the file is what makes it a +// bean, and a stereotype rule would give it the decapitalized name Boot never uses. +cfg_registry_config(p, t) :- svc_provider_resolves(p, d, t), svc_registration(d, "auto_configuration"). +cfg_registry_config(p, t) :- svc_provider_resolves_lib(p, d, t), svc_registration(d, "auto_configuration"). +bean_def(qname, t, "auto_configuration", p) :- cfg_registry_config(p, t), + type_decl(_, _, qname, _, _, _, t). + +// ── class arguments of an annotation, resolved in the annotation's own file ── +// config_class_ref resolves a simple name through ANY file that names it; a bean is a +// claim about one class, so this resolves it through the imports, package and file +// of the annotation that wrote it, or as a fully-qualified name. +cfg_ann_class_arg(prov, a, an, t) :- ann_arg(prov, a, an, v, "CLASS_REFERENCE", _), + ann_file(prov, a, f), type_resolves_in_file(_, v, f, t). +cfg_ann_class_arg(prov, a, an, t) :- ann_arg(prov, a, an, v, "CLASS_REFERENCE", _), + type_decl(_, _, v, _, _, _, t). + +// (d) PROPERTIES CLASSES registered by @EnableConfigurationProperties({A.class}) or by a +// @ConfigurationPropertiesScan over their package. The bean is named +// "-", or the fully-qualified name when the class names no +// prefix. Not container-CONSTRUCTED in the injection sense: a constructor of a +// properties class is BOUND from configuration keys (impact.dl), so its parameters are +// not bean injection points. +cfg_props_bean_type(a, t) :- ann_on_type(prov, a, n, _), cfg_enable_props_ann(n), + cfg_ann_class_arg(prov, a, "value", t). +cfg_props_bean_type(a, t) :- cfg_scan_hit(a, "properties", t). +cfg_props_type_prefix(t, pre) :- cfg_props_bean_type(_, t), cfg_props_prefix(t, pre), pre != "". +cfg_props_type_has_prefix(t) :- cfg_props_type_prefix(t, _). +bean_def(cat(pre, cat("-", qname)), t, "enabled_properties", a) :- cfg_props_bean_type(a, t), + cfg_props_type_prefix(t, pre), type_decl(_, _, qname, _, _, _, t). +bean_def(qname, t, "enabled_properties", a) :- cfg_props_bean_type(a, t), + !cfg_props_type_has_prefix(t), type_decl(_, _, qname, _, _, _, t). + +// (e) MYBATIS MAPPER PROXIES: an interface carrying @Mapper, or any interface in a +// package a @MapperScan covers. mybatis-spring registers a MapperFactoryBean per +// interface, which injects a JDK proxy of it; the interface has no implementation in any +// source tree, so without this every injection of a mapper reads `unsatisfied`. +cfg_foreign_ann_file(n, fp) :- cfg_foreign_ann(n, pkg), import_single(_, n, cat(pkg, cat(".", n)), fp, _). +cfg_foreign_ann_file(n, fp) :- cfg_foreign_ann(n, pkg), import_wildcard(_, pkg, fp, _). +cfg_mapper_site(a, t) :- ann_on_type(prov, a, n, t), cfg_mapper_ann(n), + type_decl(prov, _, _, fp, "INTERFACE_TYPE", _, t), !cfg_foreign_ann_file(n, fp). +cfg_mapper_site(a, t) :- cfg_scan_hit(a, "mapper", t). +cfg_decap_demand(sn) :- cfg_mapper_site(_, t), type_decl(_, sn, _, _, _, _, t). +bean_def(name, t, "mapper_proxy", a) :- cfg_mapper_site(a, t), + type_decl(_, sn, _, _, _, _, t), cfg_decap(sn, name). + +// ── PACKAGE SCANS (knobs.dl §19) ───────────────────────────────────────────── +// cfg_scan_pattern(Ann, Pattern): a base package as written, or the package of a +// basePackageClasses class, or with none of the three arguments the annotated class's own. +// A placeholder ("${…}") is left out: its value is not known here, and guessing a package +// would register beans the container never sees. +cfg_scan(kind, prov, a, owner) :- ann_on_type(prov, a, n, owner), cfg_pkg_scan_ann(n, kind). +cfg_scan_names_base(a) :- cfg_scan(_, prov, a, _), ann_arg(prov, a, an, _, _, _), cfg_pkg_scan_arg(an). +cfg_scan_names_base(a) :- cfg_scan(_, prov, a, _), ann_arg(prov, a, "basePackageClasses", _, _, _). +cfg_scan_pattern(a, pat) :- cfg_scan(_, prov, a, _), ann_arg(prov, a, an, pat, "STRING_LITERAL", _), + cfg_pkg_scan_arg(an), pat != "", !contains("$", pat), !contains("{", pat). +cfg_scan_pattern(a, pkg) :- cfg_scan(_, prov, a, _), cfg_ann_class_arg(prov, a, "basePackageClasses", c), + cfg_scan_type_pkg(c, pkg), pkg != "". +cfg_scan_pattern(a, pkg) :- cfg_scan(_, _, a, owner), !cfg_scan_names_base(a), + cfg_scan_type_pkg(owner, pkg), pkg != "". +// type_pkg / lib_type_pkg keep the trailing dot ("a.b."), which is how the import +// resolution joins on them; a scan pattern is written without it. +cfg_scan_type_pkg(t, pkg) :- type_pkg(t, d), strlen(d) > 1, pkg = substr(d, 0, strlen(d) - 1). +cfg_scan_type_pkg(t, pkg) :- lib_type_pkg(t, d), strlen(d) > 1, pkg = substr(d, 0, strlen(d) - 1). + +// Pattern -> regex, one character at a time: `.` is literal, `*` is any run within one +// package segment, `.**` any number of further segments. The package itself and every +// subpackage below it are covered, as a classpath scan of `pkg/**/*.class` covers them. +cfg_scan_rx_step(p, 0, "") :- cfg_scan_pattern(_, p). +cfg_scan_rx_step(p, i + 3, cat(r, "([.][^.]+)*")) :- cfg_scan_rx_step(p, i, r), i < strlen(p), + substr(p, i, min(3, strlen(p) - i)) = ".**". +cfg_scan_rx_step(p, i + 1, cat(r, "[.]")) :- cfg_scan_rx_step(p, i, r), i < strlen(p), + substr(p, i, 1) = ".", substr(p, i, min(3, strlen(p) - i)) != ".**". +cfg_scan_rx_step(p, i + 1, cat(r, "[^.]*")) :- cfg_scan_rx_step(p, i, r), i < strlen(p), + substr(p, i, 1) = "*". +cfg_scan_rx_step(p, i + 1, cat(r, c)) :- cfg_scan_rx_step(p, i, r), i < strlen(p), + c = substr(p, i, 1), c != ".", c != "*". +cfg_scan_rx(p, cat(r, "([.].+)?")) :- cfg_scan_rx_step(p, strlen(p), r). + +// What each kind of scan picks up: an interface (mapper), or a @ConfigurationProperties +// class that is not already a @Component (properties). +cfg_scan_candidate("mapper", t, pkg) :- type_decl(_, _, _, _, "INTERFACE_TYPE", _, t), cfg_scan_type_pkg(t, pkg). +cfg_scan_candidate("properties", t, pkg) :- ann_on_type(_, _, n, t), cfg_props_ann(n), + !cfg_stereotype(_, _, t), cfg_scan_type_pkg(t, pkg). +cfg_scan_match(a, kind, t) :- cfg_scan(kind, _, a, _), cfg_scan_pattern(a, pat), cfg_scan_rx(pat, rx), + cfg_scan_candidate(kind, t, pkg), match(rx, pkg). + +// @MapperScan(annotationClass = X.class) / (markerInterface = Y.class) narrow the scan to +// interfaces carrying X or extending Y (either one, when both are given); the marker +// itself is not a mapper. +cfg_scan_filter_ann(a, v) :- cfg_scan("mapper", prov, a, _), ann_arg(prov, a, "annotationClass", v, _, _), v != "". +cfg_scan_filter_marker(a, m) :- cfg_scan("mapper", prov, a, _), cfg_ann_class_arg(prov, a, "markerInterface", m). +cfg_scan_filtered(a) :- cfg_scan("mapper", prov, a, _), ann_arg(prov, a, "annotationClass", _, _, _). +cfg_scan_filtered(a) :- cfg_scan("mapper", prov, a, _), ann_arg(prov, a, "markerInterface", _, _, _). +cfg_scan_hit(a, kind, t) :- cfg_scan_match(a, kind, t), !cfg_scan_filtered(a). +cfg_scan_hit(a, "mapper", t) :- cfg_scan_match(a, "mapper", t), cfg_scan_filter_ann(a, v), + ann_on_type(_, _, n, t), n = v. +cfg_scan_hit(a, "mapper", t) :- cfg_scan_match(a, "mapper", t), cfg_scan_filter_ann(a, v), + ann_on_type(_, _, n, t), match(cat(".*[.]", n), v). +cfg_scan_hit(a, "mapper", t) :- cfg_scan_match(a, "mapper", t), cfg_scan_filter_marker(a, m), + type_ancestor(t, m). + // (c) XML (xml-wiring.dl) bean_def(id, t, "xml", elem) :- xml_bean(elem, id, t, _). @@ -251,6 +355,7 @@ cfg_inject_ctor(m, owner) :- ann_on_method(prov, _, n, m, owner), cfg_inject_ann // constructor-injected and Spring disagreed — ctor_inject precision 0.500 -> 1.000. cfg_container_constructed(t) :- bean_def(_, t, "stereotype", _). cfg_container_constructed(t) :- bean_def(_, t, "xml", _). +cfg_container_constructed(t) :- bean_def(_, t, "auto_configuration", _). cfg_type_ctor(t, m) :- method_kind(prov, "CONSTRUCTOR", _, m), method_owner(prov, t, m). cfg_type_ctor_count(t, n) :- cfg_container_constructed(t), n = count : { cfg_type_ctor(t, _) }. cfg_inject_ctor(m, t) :- cfg_type_ctor_count(t, 1), cfg_type_ctor(t, m), diff --git a/graph/java/engine/config-resolution/entry-points.dl b/graph/java/engine/config-resolution/entry-points.dl index 4b426d765..98c0535e8 100644 --- a/graph/java/engine/config-resolution/entry-points.dl +++ b/graph/java/engine/config-resolution/entry-points.dl @@ -35,6 +35,11 @@ cfg_container_callback_name("sessionCreated"). cfg_container_callback_name("s cfg_container_callback_name("onMessage"). cfg_container_callback_name("onEvent"). cfg_container_callback_name("run"). cfg_container_callback_name("call"). cfg_container_callback_name("handle"). cfg_container_callback_name("execute"). +// The methods SpringFactoriesLoader's documented extension points are called through, +// for a class a spring.factories key names (services.dl). Kept apart from the list above: +// `initialize` and `analyze` are ordinary names on a servlet or a ServiceLoader provider. +cfg_factories_callback_name("initialize"). cfg_factories_callback_name("onApplicationEvent"). +cfg_factories_callback_name("postProcessEnvironment"). cfg_factories_callback_name("analyze"). // cfg_registered_type(TypeHash, Kind): a type the container instantiates because // config named it. Everything below fans out from here. @@ -46,6 +51,9 @@ cfg_registered_type(t, "config_handler") :- config_class_ref(fmt, k, _, t, "clie config_entry_point(m, kind, t) :- cfg_registered_type(t, kind), java_method(name, _, _, _, _, _, _, t, _, _, _, _, _, _, _, _, "INSTANCE_METHOD", _, _, _, _, m), cfg_container_callback_name(name). +config_entry_point(m, "spring_factories", t) :- cfg_registered_type(t, "spring_factories"), + java_method(name, _, _, _, _, _, _, t, _, _, _, _, _, _, _, _, "INSTANCE_METHOD", _, _, _, _, m), + cfg_factories_callback_name(name). // … and by "overrides something declared in a library supertype", which is what a // servlet/filter/listener actually is. Independent of the name list, so a container // API that IS in the library set is covered exactly rather than approximately. @@ -67,6 +75,9 @@ config_entry_point(m, kind, t) :- xml_lifecycle_method(t, mname, kind), config_entry_point(m, "lifecycle", m) :- ann_on_method(_, _, n, m, _), cfg_lifecycle_ann(n). config_entry_point(m, "scheduled", m) :- ann_on_method(_, _, n, m, _), cfg_scheduled_ann(n). config_entry_point(m, "queue", m) :- ann_on_method(_, _, n, m, _), cfg_listener_ann(n). +// … and an ApplicationListener implementation's onApplicationEvent, which Spring calls +// for every matching published event without any annotation on the method (#1391). +config_entry_point(m, "queue", m) :- event_listener_method(m). // A listener/scheduler annotation on the TYPE (@KafkaListener on a class, with // @KafkaHandler methods) registers the type, not one method. config_entry_point(m, "queue", t) :- ann_on_type(_, _, n, t), cfg_listener_ann(n), @@ -80,6 +91,10 @@ config_entry_point(m, "bean_ctor", t) :- bean_def(_, t, "stereotype", _), method_kind("client", "CONSTRUCTOR", _, m), method_owner("client", t, m). config_entry_point(m, "bean_ctor", t) :- bean_def(_, t, "xml", _), method_kind("client", "CONSTRUCTOR", _, m), method_owner("client", t, m). +// A properties class registered by @EnableConfigurationProperties / a properties scan is +// constructed by the binder (di.dl (d)). +config_entry_point(m, "bean_ctor", t) :- bean_def(_, t, "enabled_properties", _), + method_kind("client", "CONSTRUCTOR", _, m), method_owner("client", t, m). // (6) GENERATED-STUB OVERRIDES — the gRPC shape. A method that OVERRIDES one declared // in a supertype whose simple name matches a stub-base suffix (GreeterImplBase) is diff --git a/graph/java/engine/config-resolution/knobs.dl b/graph/java/engine/config-resolution/knobs.dl index 4782efe5d..82f96b0b9 100644 --- a/graph/java/engine/config-resolution/knobs.dl +++ b/graph/java/engine/config-resolution/knobs.dl @@ -54,6 +54,7 @@ cfg_props_ann("ConfigurationProperties"). cfg_lifecycle_ann("PostConstruct"). cfg_lifecycle_ann("PreDestroy"). cfg_scheduled_ann("Scheduled"). cfg_listener_ann("EventListener"). cfg_listener_ann("KafkaListener"). +cfg_listener_ann("TransactionalEventListener"). cfg_listener_ann("RabbitListener"). cfg_listener_ann("JmsListener"). cfg_listener_ann("SqsListener"). cfg_listener_ann("StreamListener"). cfg_listener_ann("RabbitHandler"). cfg_listener_ann("SqsHandler"). @@ -301,3 +302,76 @@ cfg_repository_base_suffix("Dao"). // An annotation that marks a repository base as NOT itself a bean. A house base over // a framework base carries it, and the framework does not register a proxy for it. cfg_no_repository_ann("NoRepositoryBean"). + +// ── (17) REGISTRIES THAT NAME CONFIGURATION CLASSES ───────────────────────── +// Spring Boot registers a starter's classes from a FILE, not from a scan: +// META-INF/spring/.imports (every entry is a configuration class) and, in the +// older form, META-INF/spring.factories. In spring.factories only these keys name +// configuration classes, which the container registers as beans named by their +// fully-qualified name; every other key (ApplicationContextInitializer, +// EnvironmentPostProcessor, …) names a class SpringFactoriesLoader instantiates and +// calls, which is a registered type and not a bean. +cfg_config_registry_key("org.springframework.boot.autoconfigure.EnableAutoConfiguration"). +cfg_config_registry_key("org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration"). +cfg_config_registry_key("org.springframework.cloud.bootstrap.BootstrapConfiguration"). + +// ── (18) CLASSES AN ANNOTATION ARGUMENT REGISTERS AS BEANS ────────────────── +// @EnableConfigurationProperties({A.class, B.class}) registers each listed class as a +// bean named "-" (the fully-qualified name alone when the +// class names no prefix), the name Boot's registrar gives it. +cfg_enable_props_ann("EnableConfigurationProperties"). + +// ── (19) PACKAGE SCANS THAT REGISTER BEANS WITHOUT A STEREOTYPE ───────────── +// cfg_pkg_scan_ann(Annotation, Kind). The annotation names base packages (value / +// basePackages, Ant-style `*` and `**` allowed) or classes whose packages are scanned +// (basePackageClasses); with none of the three, the annotated class's own package. +// mapper every INTERFACE in a scanned package becomes a MyBatis mapper proxy bean +// (mybatis-spring's ClassPathMapperScanner) +// properties every @ConfigurationProperties class that is not also a @Component +cfg_pkg_scan_ann("MapperScan", "mapper"). +cfg_pkg_scan_ann("ConfigurationPropertiesScan", "properties"). +cfg_pkg_scan_arg("value"). cfg_pkg_scan_arg("basePackages"). + +// An annotation that makes the INTERFACE it sits on a proxy bean, as a Spring Data +// repository base does for its children: MyBatis @Mapper (registered by the Boot +// starter's scan of the application package). +cfg_mapper_ann("Mapper"). +// The same simple name imported from this package is a different annotation. MapStruct's +// @Mapper marks an interface it GENERATES an implementation of, and whether that is a +// bean depends on its componentModel, so it is not a proxy bean. +cfg_foreign_ann("Mapper", "org.mapstruct"). + +// ── (20) APPLICATION EVENTS: publishEvent(e) runs every listener e's type fits ── +// Spring hands a published event to each listener whose declared event type the +// event's type is assignable to. No call site names the listener, so without a model +// the publisher and the listener are independent in the graph +// (call-edge-generation/event_dispatch.dl). +// +// cfg_event_publisher(TypeSimpleName, Method): a call to Method on a receiver declared +// as this type publishes its first argument. Simple names, matched against an +// external node (`external:`) or a staged library type. A project +// class that happens to declare its own publishEvent is neither, so a call on it stays +// the ordinary call it is. +cfg_event_publisher("ApplicationEventPublisher", "publishEvent"). +cfg_event_publisher("ApplicationContext", "publishEvent"). +cfg_event_publisher("ConfigurableApplicationContext", "publishEvent"). +cfg_event_publisher("AbstractApplicationContext", "publishEvent"). +cfg_event_publisher("GenericApplicationContext", "publishEvent"). +cfg_event_publisher("AnnotationConfigApplicationContext", "publishEvent"). +cfg_event_publisher("WebApplicationContext", "publishEvent"). +cfg_event_publisher("ConfigurableWebApplicationContext", "publishEvent"). +cfg_event_publisher("ApplicationEventMulticaster", "multicastEvent"). +cfg_event_publisher("SimpleApplicationEventMulticaster", "multicastEvent"). +// A method annotation that makes the method a listener. Its event type is the one +// parameter's type, or the types named in cfg_event_listener_arg when given. +cfg_event_listener_ann("EventListener"). +cfg_event_listener_ann("TransactionalEventListener"). +cfg_event_listener_arg("classes"). cfg_event_listener_arg("value"). +// cfg_event_listener_type(TypeSimpleName, Method): implementing this type makes +// Method a listener, for the type of its one parameter (ApplicationListener is +// implemented by onApplicationEvent(E)). +cfg_event_listener_type("ApplicationListener", "onApplicationEvent"). +// A listener declared for one of these receives every published event: a plain object +// is wrapped in a PayloadApplicationEvent, which is an ApplicationEvent. +cfg_event_catch_all("ApplicationEvent"). +cfg_event_catch_all("Object"). diff --git a/graph/java/engine/config-resolution/lib-bean-scope.dl b/graph/java/engine/config-resolution/lib-bean-scope.dl index 3537a0b01..0e42c5c58 100644 --- a/graph/java/engine/config-resolution/lib-bean-scope.dl +++ b/graph/java/engine/config-resolution/lib-bean-scope.dl @@ -73,6 +73,10 @@ cfg_factory_type(m, t) :- cfg_factory("lib", _, m, _), lib_method_return_type(m, // makes client the default, which is where XML elements come from. cfg_lib_bean_site(a) :- cfg_stereotype("lib", a, _). cfg_lib_bean_site(m) :- cfg_factory("lib", _, m, _). +// A bean a library's own annotation registers (@EnableConfigurationProperties, @Mapper, +// a package scan), or a library class a registration file names. +cfg_lib_bean_site(a) :- ann_on_type("lib", a, _, _). +cfg_lib_bean_site(p) :- svc_provider_resolves_lib(p, _, _). bean_origin(name, t, "lib") :- bean_def(name, t, _, site), cfg_lib_bean_site(site). bean_origin(name, t, "client") :- bean_def(name, t, _, site), !cfg_lib_bean_site(site). diff --git a/graph/java/engine/config-resolution/services.dl b/graph/java/engine/config-resolution/services.dl index c5add2f8f..61e92b59f 100644 --- a/graph/java/engine/config-resolution/services.dl +++ b/graph/java/engine/config-resolution/services.dl @@ -40,16 +40,42 @@ svc_provider_resolves_lib(p, d, t) :- svc_provider_lib(p, d, pkg, simple), svc_provider_resolves(p, d, t) :- svc_provider_class_lib(p, d, qname), java_type(_, qname, _, _, _, _, _, _, _, _, _, _, _, t). +// ── WHICH REGISTRY the descriptor is ─────────────────────────────────────────────── +// Spring's two registration files have the provider-configuration shape and arrive in the +// same relations, told apart by path: +// META-INF/spring/.imports one class per line; every entry is a configuration +// class Boot registers (auto_configuration) +// META-INF/spring.factories one descriptor per key; a key cfg_config_registry_key +// lists names configuration classes (auto_configuration), +// any other key a class SpringFactoriesLoader +// instantiates and calls (spring_factories) +// anything else META-INF/services (service_loader) +svc_spring_file(d, "imports") :- svc_descriptor_path(d, rel), + match("(.*/)?META-INF/spring/[^/]+[.]imports", rel). +svc_spring_file(d, "factories") :- svc_descriptor_path(d, rel), + match("(.*/)?META-INF/spring[.]factories", rel). +svc_is_spring_file(d) :- svc_spring_file(d, _). +svc_descriptor_any(d, svc) :- svc_descriptor(d, svc, _). +svc_descriptor_any(d, svc) :- svc_descriptor_lib(d, svc, _). + +svc_registration(d, "service_loader") :- svc_descriptor_path(d, _), !svc_is_spring_file(d). +svc_registration(d, "auto_configuration") :- svc_spring_file(d, "imports"). +svc_registration(d, "auto_configuration") :- svc_spring_file(d, "factories"), + svc_descriptor_any(d, svc), cfg_config_registry_key(svc). +svc_registration(d, "spring_factories") :- svc_spring_file(d, "factories"), + svc_descriptor_any(d, svc), !cfg_config_registry_key(svc). + // ── The type is REGISTERED: the container instantiates it and calls back into it ───── // cfg_registered_type already drives all three entry-point shapes in entry-points.dl — // callbacks by name, "overrides a library supertype" (which is what implementing a service // interface IS, so no name list is needed), and the constructor. -cfg_registered_type(t, "service_loader") :- svc_provider_resolves(_, _, t). +cfg_registered_type(t, kind) :- svc_provider_resolves(_, d, t), svc_registration(d, kind). // ── The type is INSTANTIATED, with provenance ─────────────────────────────────────── // Reported, never used to NARROW dispatch: the instantiation set can only ever be a lower // bound (containers, factories and reflection instantiate types this cannot see), so // filtering virtual dispatch on it would delete must-have edges. A consumer that wants // precision may subtract; the graph itself stays sound. -type_instantiated(t, "service_loader") :- svc_provider_resolves(_, _, t). -type_instantiated(t, "service_loader_lib") :- svc_provider_resolves_lib(_, _, t). +type_instantiated(t, kind) :- svc_provider_resolves(_, d, t), svc_registration(d, kind). +type_instantiated(t, cat(kind, "_lib")) :- svc_provider_resolves_lib(_, d, t), + svc_registration(d, kind). diff --git a/graph/java/engine/expression-resolution/lambda.dl b/graph/java/engine/expression-resolution/lambda.dl index 0a98fcdf5..8ccff8cd0 100644 --- a/graph/java/engine/expression-resolution/lambda.dl +++ b/graph/java/engine/expression-resolution/lambda.dl @@ -266,6 +266,78 @@ lambda_param_binding(lam, name, argT) :- java_expression("LAMBDA_EXPRESSION", "ARGUMENT", _, _, _, _, call, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, lam), recv_tvar_type(call, classTv, argT). +// ── (3d) A PLATFORM functional interface that is NOT staged ────────────────── +// Every clause above needs the target interface's SAM, so with the JDK not staged a lambda whose +// target is `BiConsumer` typed neither parameter: `(a, b) -> a.paint()` and +// `(var a, var b) -> a.paint()` both left `a` a declared unknown. Which type argument a platform +// interface's SAM takes at each parameter is fixed by the JDK, so it is a table: platform_fi_param +// (interface, lambda parameter position, type argument position). Supplier, Runnable and Callable +// take no parameter and so have no row. An interface not listed stays untyped. #1401. +// +// Keyed on the target's WRITTEN reference and on ref_external_name, which is empty when the +// reference resolves (the JDK staged, or a client type of the same name), so a resolved target is +// left to the SAM clauses above and the two never both answer. A client-declared interface is +// typed there from its own single abstract method. +platform_fi_param("java.util.function", "Function", "0", "0"). +platform_fi_param("java.util.function", "BiFunction", "0", "0"). +platform_fi_param("java.util.function", "BiFunction", "1", "1"). +platform_fi_param("java.util.function", "Consumer", "0", "0"). +platform_fi_param("java.util.function", "BiConsumer", "0", "0"). +platform_fi_param("java.util.function", "BiConsumer", "1", "1"). +platform_fi_param("java.util.function", "Predicate", "0", "0"). +platform_fi_param("java.util.function", "BiPredicate", "0", "0"). +platform_fi_param("java.util.function", "BiPredicate", "1", "1"). +platform_fi_param("java.util.function", "UnaryOperator", "0", "0"). +platform_fi_param("java.util.function", "BinaryOperator", "0", "0"). +platform_fi_param("java.util.function", "BinaryOperator", "1", "0"). +platform_fi_param("java.util", "Comparator", "0", "0"). +platform_fi_param("java.util", "Comparator", "1", "0"). + +platform_fi_ref(ref, n) :- ref_external_name(ref, q), + platform_fi_param(pkg, n, _, _), + q = cat(pkg, ".", n). +platform_fi_ref(ref, n) :- ref_external_name(ref, n), + platform_fi_param(pkg, n, _, _), + ref_file(ref, n, fp), + import_wildcard("client", pkg, fp, _). + +// The written target reference, resolved or not: the declared type of the local, field, method +// return or assigned local the lambda initializes, or the declared type of the client method +// parameter it is passed to. Same five shapes as lambda_target_fi_ref above. +lambda_target_written_ref(lam, ref) :- + java_expression("LAMBDA_EXPRESSION", "ROOT", "LOCAL_VAR_INITIALIZER", "LOCAL_VARIABLE", _, local, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, lam), + java_type_reference(_, "LOCAL_VARIABLE", _, _, _, _, _, "0", _, _, _, _, _, _, _, local, "LOCAL_VARIABLE", ref). +lambda_target_written_ref(lam, ref) :- + java_expression("LAMBDA_EXPRESSION", "ROOT", "FIELD_INITIALIZER", "FIELD", _, field, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, lam), + java_type_reference(_, "FIELD_TYPE", _, _, _, _, _, "0", _, _, _, _, _, _, _, field, "FIELD", ref). +lambda_target_written_ref(lam, ref) :- + java_expression("LAMBDA_EXPRESSION", "ROOT", "RETURN_VALUE", "RETURN_STATEMENT", _, m, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, lam), + java_type_reference(_, "METHOD_RETURN", _, _, _, _, _, "0", _, _, _, _, _, _, _, m, "METHOD", ref). +lambda_target_written_ref(lam, ref) :- + java_expression("LAMBDA_EXPRESSION", "ASSIGNMENT_VALUE", _, _, _, _, asgn, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, lam), + java_expression(_, "ASSIGNMENT_TARGET", _, _, _, _, asgn, _, _, _, name, _, _, _, _, _, _, _, _, _, _, _, _, _, tgt), + expr_ultimate_method("client", tgt, m), + java_local_variable(name, _, _, _, _, _, _, _, _, _, _, _, _, m, _, _, _, _, local), + java_type_reference(_, "LOCAL_VARIABLE", _, _, _, _, _, "0", _, _, _, _, _, _, _, local, "LOCAL_VARIABLE", ref). +lambda_target_written_ref(lam, ref) :- + java_expression("LAMBDA_EXPRESSION", "ARGUMENT", _, _, _, _, call, argpos, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, lam), + applicable_candidate(call, callee), + param_decl("client", _, argpos, _, callee, ph), + java_type_reference(_, "METHOD_PARAM", _, _, _, _, _, "0", _, _, _, _, _, _, _, ph, "METHOD_PARAM", ref). + +// The parameter type is the type argument as written, or a wildcard's bound: the function type of +// `Consumer` takes a W (JLS 9.9), and so does that of `Function`. +fi_written_arg(ref, pos, argRef) :- written_type_arg(ref, pos, argRef). +fi_written_arg(ref, pos, argRef) :- wildcard_type_arg(ref, pos, _, argRef). + +lambda_param_binding(lam, name, t) :- + java_expression("IDENTIFIER_REFERENCE", "LAMBDA_PARAMETER", _, _, _, _, lam, k, _, _, name, _, _, _, "LAMBDA_PARAMETER", _, _, _, _, _, _, _, _, _, _), + lambda_target_written_ref(lam, ref), + platform_fi_ref(ref, n), + platform_fi_param(_, n, k, j), + fi_written_arg(ref, j, argRef), + type_ref_resolves(_, argRef, t). + // Type every LAMBDA_PARAMETER use (receiver/argument) to its bound type, scoped to the same // enclosing method as the lambda and matched by name. Feeds the normal typed-receiver dispatch, // so `s.open(x)` resolves on the bound type. (Name+method scope; nested same-name lambda params diff --git a/graph/java/engine/framework-behavior/destinations.dl b/graph/java/engine/framework-behavior/destinations.dl index eb7f4946f..3c21a9408 100644 --- a/graph/java/engine/framework-behavior/destinations.dl +++ b/graph/java/engine/framework-behavior/destinations.dl @@ -17,9 +17,11 @@ // restate every existing consumer in terms of it; // - it is included AFTER call-edge-generation because it CONSUMES the resolved graph. // -// Other framework-mediated hops belong here as they are built: an event published to -// an @EventListener, an @Async or @Scheduled handoff, a gRPC stub to its service -// implementation. Each is a hop no call site expresses. +// Other framework-mediated hops belong here as they are built: an @Async or @Scheduled +// handoff, a gRPC stub to its service implementation. Each is a hop no call site +// expresses. An application event published to an @EventListener is NOT one of them: +// it stays in one JVM and is keyed on a type rather than a string, so it is a call edge +// with tier event_dispatch (call-edge-generation/event_dispatch.dl). // // A producer and the consumer that serves it live in different deployables. Neither // calls the other — there is no call site to resolve — so every rule in diff --git a/graph/java/engine/projections/config-services.dl b/graph/java/engine/projections/config-services.dl index 3f1c5da37..e96298078 100644 --- a/graph/java/engine/projections/config-services.dl +++ b/graph/java/engine/projections/config-services.dl @@ -34,3 +34,10 @@ svc_provider_class(p, d, qname) :- java_service_provider(qname, _, _, _, _, _, " svc_descriptor_lib(d, svc, wf) :- lib_service_descriptor(svc, _, _, _, _, wf, _, _, _, _, _, _, _, d). svc_provider_lib(p, d, pkg, simple) :- lib_service_provider(_, _, simple, pkg, _, _, "true", _, _, _, _, _, _, _, d, _, _, _, p). svc_provider_class_lib(p, d, qname) :- lib_service_provider(qname, _, _, _, _, _, "true", _, _, _, _, _, _, _, d, _, _, _, p). + +// svc_descriptor_path(Descriptor, RelativePath): the file that configured it. Spring's two +// registries, META-INF/spring/.imports and META-INF/spring.factories (one descriptor +// per key), arrive in these same relations; the path is what tells them apart +// (config-resolution/services.dl). +svc_descriptor_path(d, rel) :- java_service_descriptor(_, _, _, _, _, _, _, _, _, rel, _, _, _, d). +svc_descriptor_path(d, rel) :- lib_service_descriptor(_, _, _, _, _, _, _, _, _, rel, _, _, _, d). diff --git a/graph/java/engine/resolution/reference-types.dl b/graph/java/engine/resolution/reference-types.dl index 702199541..a5e7e60bc 100644 --- a/graph/java/engine/resolution/reference-types.dl +++ b/graph/java/engine/resolution/reference-types.dl @@ -90,6 +90,126 @@ local_var_type_resolves(prov, local, ref, elem) :- foreach_iterable(local, iterE lib_type(_, "java.lang.Iterable", _, _, _, _, _, _, _, _, _, _, _, ity), type_prov(elem, prov). +// ── `for (var x : xs)` with the platform NOT staged ───────────────────────────────────────── +// Both clauses above climb to java.lang.Iterable's own type variable, which exists only when the +// JDK IR is staged. Without it `for (var x : ws)` over a `List ws` typed nothing, while +// `for (Widget x : ws)` resolved because the element type is written there. Three shapes are read +// from what the client wrote instead, each keyed on the iterable's WRITTEN declaration reference +// (resolved or not): an array, a platform collection with its one type argument, and a client type +// that implements Iterable. #1401. +// +// The written reference of the iterable expression: a parameter, a local, a field, or the declared +// return of a client method called in place. Unlike recv_decl_ref this does not ask the reference +// to resolve, since `List` does not resolve when the platform is not staged. +// java_method_parameter (13): 0 name 2 method 12 hash. java_field (14): 0 name 8 owner 13 hash. +iter_written_ref(iterExpr, ref) :- foreach_iterable(_, iterExpr), + java_expression(_, _, _, _, _, _, _, _, _, _, name, _, _, _, "PARAMETER", _, _, _, _, _, _, _, _, _, iterExpr), + expr_ultimate_method("client", iterExpr, m), + java_method_parameter(name, _, m, _, _, _, _, _, _, _, _, _, param), + java_type_reference(_, "METHOD_PARAM", _, _, _, _, _, "0", _, _, _, _, _, _, _, param, "METHOD_PARAM", ref). +iter_written_ref(iterExpr, ref) :- foreach_iterable(_, iterExpr), + java_expression(_, _, _, _, _, _, _, _, _, _, name, _, _, _, "LOCAL_VARIABLE", _, _, _, _, _, _, _, _, _, iterExpr), + expr_ultimate_method("client", iterExpr, m), + java_local_variable(name, _, _, _, _, _, _, _, _, _, _, _, _, m, _, _, _, _, local), + java_type_reference(_, "LOCAL_VARIABLE", _, _, _, _, _, "0", _, _, _, _, _, _, _, local, "LOCAL_VARIABLE", ref). +iter_written_ref(iterExpr, ref) :- foreach_iterable(_, iterExpr), + java_expression(_, _, _, _, _, _, _, _, _, _, name, _, _, _, "FIELD", _, _, _, _, _, _, _, _, _, iterExpr), + expr_ultimate_type("client", iterExpr, encl), + java_field(name, _, _, _, _, _, _, _, encl, _, _, _, _, field), + java_type_reference(_, "FIELD_TYPE", _, _, _, _, _, "0", _, _, _, _, _, _, _, field, "FIELD", ref). +iter_written_ref(iterExpr, ref) :- foreach_iterable(_, iterExpr), + applicable_candidate(iterExpr, callee), + java_type_reference(_, "METHOD_RETURN", _, _, _, _, _, "0", _, _, _, _, _, _, _, callee, "METHOD", ref). + +// (1) An ARRAY iterable: the element is the component type. One dimension only; the element of a +// `Widget[][]` is a `Widget[]`, on which no method of Widget can be called. +// java_type_reference (18): 0 kind 11 arrayDimensions +local_var_type_resolves(prov, local, ref, elem) :- foreach_iterable(local, iterExpr), + iter_written_ref(iterExpr, ref), + java_type_reference("ARRAY", _, _, _, _, _, _, _, _, _, _, "1", _, _, _, _, _, ref), + type_ref_resolves(prov, ref, elem). + +// (2) A PLATFORM COLLECTION with its one type argument written: `List`, `List`. Only the names in foreach_platform_iterable, each Iterable over its only type +// parameter, and only when the reference names that platform type: the qualified name from a +// single-type import or written out, or the simple name under an on-demand import of its package. +// ref_external_name is empty once the platform is staged, so the clauses above then answer alone. +// A raw `List` writes no argument and stays untyped. +foreach_platform_iterable("java.lang", "Iterable"). +foreach_platform_iterable("java.util", "Collection"). +foreach_platform_iterable("java.util", "List"). +foreach_platform_iterable("java.util", "Set"). +foreach_platform_iterable("java.util", "SortedSet"). +foreach_platform_iterable("java.util", "NavigableSet"). +foreach_platform_iterable("java.util", "SequencedCollection"). +foreach_platform_iterable("java.util", "SequencedSet"). +foreach_platform_iterable("java.util", "Queue"). +foreach_platform_iterable("java.util", "Deque"). +foreach_platform_iterable("java.util", "ArrayList"). +foreach_platform_iterable("java.util", "LinkedList"). +foreach_platform_iterable("java.util", "HashSet"). +foreach_platform_iterable("java.util", "LinkedHashSet"). +foreach_platform_iterable("java.util", "TreeSet"). +foreach_platform_iterable("java.util", "ArrayDeque"). +foreach_platform_iterable("java.util", "PriorityQueue"). + +platform_iterable_ref(ref) :- ref_external_name(ref, q), + foreach_platform_iterable(pkg, n), + q = cat(pkg, ".", n). +// java.lang is imported implicitly, so a bare `Iterable` is the platform's. +platform_iterable_ref(ref) :- ref_external_name(ref, "Iterable"). +platform_iterable_ref(ref) :- ref_external_name(ref, n), + foreach_platform_iterable(pkg, n), + ref_file(ref, n, fp), + import_wildcard("client", pkg, fp, _). + +// The type argument at `pos` of a written reference, as written or as a wildcard's bound. +// `? extends W` reads as a W, which is all a loop variable does with it; `? super W` reads as an +// Object, so for an element the variance is checked (for_elem_type_arg). +// java_type_reference (18): 0 kind 5 parentReferenceHash 6 position 7 depth 12 wildcardVariance +written_type_arg(ref, pos, argRef) :- java_type_reference(k, _, _, _, _, ref, pos, "1", _, _, _, _, _, _, _, _, _, argRef), + k != "WILDCARD", k != "TYPE_VARIABLE". +wildcard_type_arg(ref, pos, variance, argRef) :- java_type_reference("WILDCARD", _, _, _, _, ref, pos, "1", _, _, _, _, variance, _, _, _, _, wc), + java_type_reference(k, _, _, _, _, wc, _, "2", _, _, _, _, _, _, _, _, _, argRef), + k != "WILDCARD", k != "TYPE_VARIABLE". +for_elem_type_arg(ref, argRef) :- written_type_arg(ref, "0", argRef). +for_elem_type_arg(ref, argRef) :- wildcard_type_arg(ref, "0", "EXTENDS", argRef). + +local_var_type_resolves(prov, local, argRef, elem) :- foreach_iterable(local, iterExpr), + iter_written_ref(iterExpr, ref), + platform_iterable_ref(ref), + for_elem_type_arg(ref, argRef), + type_ref_resolves(prov, argRef, elem). + +// (3) A CLIENT type that implements Iterable: `class Bag implements Iterable`, iterated as +// `Bag`. Its implements clause names Iterable, unresolved for the same reason, and the +// argument there is a type variable of the declaring type, bound through the iterable's written +// reference by type_arg_binding (which climbs the client hierarchy), or a concrete type. +// java_type_reference (18): 2 typeRegistryLinkHash (the declaring type) 10 typeVariableName +iterable_decl_arg(a, argRef) :- java_type_reference(_, ctx, a, _, _, _, _, "0", _, _, _, _, _, _, _, _, _, iref), + iterable_decl_context(ctx), + platform_iterable_ref(iref), + java_type_reference(_, _, _, _, _, iref, "0", "1", _, _, _, _, _, _, _, _, _, argRef). +iterable_decl_context("IMPLEMENTS_INTERFACE"). +iterable_decl_context("SUPER_TYPE"). +self_or_ancestor(c, c) :- java_type(_, _, _, _, _, _, _, _, _, _, _, _, _, c). +self_or_ancestor(c, a) :- java_type(_, _, _, _, _, _, _, _, _, _, _, _, _, c), type_ancestor(c, a). + +local_var_type_resolves(prov, local, ref, elem) :- foreach_iterable(local, iterExpr), + iter_written_ref(iterExpr, ref), + type_ref_resolves("client", ref, c), + self_or_ancestor(c, a), + iterable_decl_arg(a, argRef), + java_type_reference("TYPE_VARIABLE", _, _, _, _, _, _, _, _, _, tv, _, _, _, _, _, _, argRef), + type_arg_binding(ref, a, tv, elem), + type_prov(elem, prov). +local_var_type_resolves(prov, local, argRef, elem) :- foreach_iterable(local, iterExpr), + iter_written_ref(iterExpr, ref), + type_ref_resolves("client", ref, c), + self_or_ancestor(c, a), + iterable_decl_arg(a, argRef), + type_ref_resolves(prov, argRef, elem). + // A RECORD DECONSTRUCTION component — `if (n instanceof Pair(Leaf a, Node ignored)) a.render()`. // The component binds a variable exactly as `instanceof Leaf l` does, but the two are shaped // differently in the IR: a bare pattern's type reference is owned by the LOCAL, so the declared-type diff --git a/graph/java/souffle/decls_all.dl b/graph/java/souffle/decls_all.dl index 9f89483b8..c27f23b31 100644 --- a/graph/java/souffle/decls_all.dl +++ b/graph/java/souffle/decls_all.dl @@ -371,6 +371,11 @@ .decl svc_provider_resolves_lib(c0:symbol,c1:symbol,c2:symbol) .decl svc_descriptor_lib(c0:symbol,c1:symbol,c2:symbol) .decl svc_provider_lib(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl svc_descriptor_path(c0:symbol,c1:symbol) +.decl svc_descriptor_any(c0:symbol,c1:symbol) +.decl svc_spring_file(c0:symbol,c1:symbol) +.decl svc_is_spring_file(c0:symbol) +.decl svc_registration(c0:symbol,c1:symbol) .decl type_instantiated(c0:symbol,c1:symbol) .decl type_param(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) .decl type_parent(c0:symbol,c1:symbol) @@ -571,6 +576,32 @@ .decl cfg_repo_name_matches(c0:symbol) .decl cfg_repo_super_name(c0:symbol,c1:symbol) .decl cfg_repository_base_suffix(c0:symbol) +.decl cfg_config_registry_key(c0:symbol) +.decl cfg_factories_callback_name(c0:symbol) +.decl cfg_enable_props_ann(c0:symbol) +.decl cfg_pkg_scan_ann(c0:symbol,c1:symbol) +.decl cfg_pkg_scan_arg(c0:symbol) +.decl cfg_mapper_ann(c0:symbol) +.decl cfg_foreign_ann(c0:symbol,c1:symbol) +.decl cfg_registry_config(c0:symbol,c1:symbol) +.decl cfg_ann_class_arg(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl cfg_props_bean_type(c0:symbol,c1:symbol) +.decl cfg_props_type_prefix(c0:symbol,c1:symbol) +.decl cfg_props_type_has_prefix(c0:symbol) +.decl cfg_foreign_ann_file(c0:symbol,c1:symbol) +.decl cfg_mapper_site(c0:symbol,c1:symbol) +.decl cfg_scan(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl cfg_scan_names_base(c0:symbol) +.decl cfg_scan_pattern(c0:symbol,c1:symbol) +.decl cfg_scan_type_pkg(c0:symbol,c1:symbol) +.decl cfg_scan_rx_step(c0:symbol,c1:number,c2:symbol) +.decl cfg_scan_rx(c0:symbol,c1:symbol) +.decl cfg_scan_candidate(c0:symbol,c1:symbol,c2:symbol) +.decl cfg_scan_match(c0:symbol,c1:symbol,c2:symbol) +.decl cfg_scan_filter_ann(c0:symbol,c1:symbol) +.decl cfg_scan_filter_marker(c0:symbol,c1:symbol) +.decl cfg_scan_filtered(c0:symbol) +.decl cfg_scan_hit(c0:symbol,c1:symbol,c2:symbol) .decl cfg_satisfying_type(c0:symbol,c1:symbol) .decl cfg_scheduled_ann(c0:symbol) .decl cfg_setter_demand(c0:symbol) @@ -882,3 +913,42 @@ .decl cfg_staged_lib_type(c0:symbol) .decl cfg_injectable_scope_type(c0:symbol) .decl gen_accessor_field(c0:symbol,c1:symbol) +// call-edge-generation/event_dispatch.dl + knobs.dl (20): Spring application events (#1391) +.decl cfg_event_publisher(c0:symbol,c1:symbol) +.decl cfg_event_listener_ann(c0:symbol) +.decl cfg_event_listener_arg(c0:symbol) +.decl cfg_event_listener_type(c0:symbol,c1:symbol) +.decl cfg_event_catch_all(c0:symbol) +.decl event_knob_type(c0:symbol) +.decl event_catch_all_listener(c0:symbol) +.decl event_named_type(c0:symbol,c1:symbol) +.decl event_publisher_recv_type(c0:symbol,c1:symbol) +.decl event_publish_site(c0:symbol) +.decl event_published(c0:symbol,c1:symbol,c2:symbol) +.decl event_param_type(c0:symbol,c1:symbol) +.decl event_listener_ann_method(c0:symbol,c1:symbol) +.decl event_listener_names_classes(c0:symbol) +.decl event_listens(c0:symbol,c1:symbol) +.decl event_listener_class(c0:symbol,c1:symbol) +.decl event_listener_method(c0:symbol) +.decl event_dispatch_edge(c0:symbol,c1:symbol,c2:symbol) +// #1401: `var` in an enhanced for, a try-with-resources resource and a lambda parameter +.decl iter_written_ref(c0:symbol,c1:symbol) // (iterable expr, its written declaration ref) +.decl foreach_platform_iterable(c0:symbol,c1:symbol) // (package, simple name) knob +.decl platform_iterable_ref(c0:symbol) // ref names an unstaged platform Iterable +.decl written_type_arg(c0:symbol,c1:symbol,c2:symbol) // (ref, position, type-argument ref) +.decl wildcard_type_arg(c0:symbol,c1:symbol,c2:symbol,c3:symbol) // (ref, position, variance, bound ref) +.decl for_elem_type_arg(c0:symbol,c1:symbol) // (ref, element type-argument ref) +.decl iterable_decl_arg(c0:symbol,c1:symbol) // (client type, its Iterable argument ref) +.decl iterable_decl_context(c0:symbol) +.decl self_or_ancestor(c0:symbol,c1:symbol) +.decl platform_fi_param(c0:symbol,c1:symbol,c2:symbol,c3:symbol) // (package, interface, lambda param pos, type-arg pos) knob +.decl platform_fi_ref(c0:symbol,c1:symbol) // (ref, platform interface simple name) +.decl lambda_target_written_ref(c0:symbol,c1:symbol) // (lambda, written target ref) +.decl fi_written_arg(c0:symbol,c1:symbol,c2:symbol) // (ref, position, type-argument ref) +.decl twr_resource(c0:symbol,c1:symbol) // (resource local, enclosing method) +.decl twr_resource_type(c0:symbol,c1:symbol) +.decl close_no_arg(c0:symbol) +.decl twr_close_decl(c0:symbol,c1:symbol) +.decl twr_close(c0:symbol,c1:symbol) +.decl twr_close_count(c0:symbol,c1:number) diff --git a/graph/javascript/engine/call-edge-generation/calls.dl b/graph/javascript/engine/call-edge-generation/calls.dl index ec0a7b5ea..55697c0d0 100644 --- a/graph/javascript/engine/call-edge-generation/calls.dl +++ b/graph/javascript/engine/call-edge-generation/calls.dl @@ -71,3 +71,20 @@ unresolved_covered(ce) :- call_site("client", ck, _, rp, _, _, ce, _, _), rp != unresolved_covered(ce) :- call_site("client", ck, _, _, _, _, ce, _, _), call_kind_is_dynamic(ck). unresolved_receiver(ce, cn, ck, "other_form") :- call_site("client", ck, cn, _, _, _, ce, _, _), !call_resolved(ce), !unresolved_covered(ce). + +// ── unresolved_value_callee(CallExpr, Binding) ────────────────────────────── +// An unresolved call whose callee is a VALUE, not a name: `cb()` on a parameter, +// `f()` on a loop variable or a local, `table[k]()`, `eval(s)`. Nothing about its +// name narrows what it calls, so any function that escapes as a value may be the +// callee, and a question that finds no chain from its caller must answer "unknown", +// never "independent" (#1649). Binding: parameter | loop_variable | local | +// computed | dynamic_code. An imported binding is not one: it names a module +// member, and a module-level binding is left to the by-name leads. +value_callee_open(ce, c) :- call_site("client", ck, _, _, _, _, ce, _, _), !call_kind_is_dynamic(ck), + !call_resolved(ce), !callee_value_known(ce), expr_child(_, ce, "CALLEE", _, c). +unresolved_value_callee(ce, "parameter") :- value_callee_open(ce, c), expr_param(_, _, c). +unresolved_value_callee(ce, "loop_variable") :- value_callee_open(ce, c), expr_binding(_, v, c), loop_head(v, _). +unresolved_value_callee(ce, "local") :- value_callee_open(ce, c), expr_binding(_, v, c), !loop_head(v, _), + var_owner_method("client", _, v), !var_import(_, _, v), !expr_param(_, _, c). +unresolved_value_callee(ce, "computed") :- call_site("client", "COMPUTED_CALL", _, _, _, _, ce, _, _), !call_resolved(ce). +unresolved_value_callee(ce, "dynamic_code") :- call_site("client", "DYNAMIC_CODE_CALL", _, _, _, _, ce, _, _), !call_resolved(ce). diff --git a/graph/javascript/engine/resolution/frameworks.dl b/graph/javascript/engine/resolution/frameworks.dl index 4612145ac..dc932573e 100644 --- a/graph/javascript/engine/resolution/frameworks.dl +++ b/graph/javascript/engine/resolution/frameworks.dl @@ -29,8 +29,8 @@ assign_like_site(ce, tgt, src) :- expr_kind(_, "CALL", _, ce), call_site(_, "MET assign_like_site(ce, tgt, src) :- expr_kind(_, "CALL", _, ce), callee_value(ce, "func", m), model_assign_like_function(m), call_arg(ce, 0, tgt), call_arg(ce, 1, src). model_assign_like_function(m) :- module_export_value(mod, "default", "func", m), module_package("lib", "merge-descriptors", mod). -member_write(k, i, n, k2, i2) :- assign_like_site(_, tgt, src), expr_value(tgt, k, i), k != "ambient", - expr_value(src, k0, i0), prop_value(k0, i0, n, k2, i2). +member_write(k, i, n, k2, i2) :- assign_like_site(_, tgt, src), expr_value(tgt, k, i), !shared_identity_kind(k), + expr_value(src, k0, i0), prop_value(k0, i0, n, k2, i2), (k != "shape" ; shape_stores_kind(k2)). // The call evaluates to its target. expr_value(ce, k, i) :- assign_like_site(ce, tgt, _), expr_value(tgt, k, i). @@ -49,7 +49,7 @@ object_create_site(ce, src) :- expr_kind(_, "CALL", _, ce), call_site(_, "METHOD expr_child(_, ce, "RECEIVER", _, recv), expr_value(recv, "ambient", "Object"), call_arg(ce, 0, src). expr_value(ce, "obj", ce) :- object_create_site(ce, _). -inherits_from(k, i, k0, i0) :- inherit_like_site(_, tgt, src), expr_value(tgt, k, i), k != "ambient", expr_value(src, k0, i0). +inherits_from(k, i, k0, i0) :- inherit_like_site(_, tgt, src), expr_value(tgt, k, i), !shared_identity_kind(k), expr_value(src, k0, i0). inherits_from("obj", ce, k0, i0) :- object_create_site(ce, src), expr_value(src, k0, i0). prop_value(k, i, n, k2, i2) :- inherits_from(k, i, k0, i0), prop_value(k0, i0, n, k2, i2). // `Object.setPrototypeOf(Child.prototype, Parent.prototype)` — the spelling of @@ -164,8 +164,8 @@ descriptor_value(d, k, i) :- literal_owns_method(d, g), method_decl(_, "get", _, define_property_site(ce, tgt, key, desc) :- expr_kind(_, "CALL", _, ce), call_site(_, "METHOD_CALL", "defineProperty", _, _, _, ce, _, _), expr_child(_, ce, "RECEIVER", _, recv), expr_value(recv, "ambient", "Object"), call_arg(ce, 0, tgt), call_arg(ce, 1, key), call_arg(ce, 2, desc). -member_write(k, i, n, k2, i2) :- define_property_site(_, tgt, key, desc), expr_value(tgt, k, i), k != "ambient", - expr_value(key, "str", n), descriptor_value(desc, k2, i2). +member_write(k, i, n, k2, i2) :- define_property_site(_, tgt, key, desc), expr_value(tgt, k, i), !shared_identity_kind(k), + expr_value(key, "str", n), descriptor_value(desc, k2, i2), (k != "shape" ; shape_stores_kind(k2)). // `Object.defineProperties(o, { n: desc, … })` and `Object.create(proto, { n: desc })`. descriptors_literal_entry(lit, n, desc) :- expr_child(_, lit, "PROPERTY_KEY", idx, key), expr_name(_, n, key), expr_child(_, lit, "PROPERTY_VALUE", idx, desc). @@ -173,8 +173,9 @@ descriptors_literal_entry(lit, n, desc) :- expr_child(_, lit, "COMPUTED_KEY", id expr_child(_, lit, "PROPERTY_VALUE", idx, desc). member_write(k, i, n, k2, i2) :- expr_kind(_, "CALL", _, ce), call_site(_, "METHOD_CALL", "defineProperties", _, _, _, ce, _, _), expr_child(_, ce, "RECEIVER", _, recv), expr_value(recv, "ambient", "Object"), - call_arg(ce, 0, tgt), expr_value(tgt, k, i), k != "ambient", - call_arg(ce, 1, lit), descriptors_literal_entry(lit, n, desc), descriptor_value(desc, k2, i2). + call_arg(ce, 0, tgt), expr_value(tgt, k, i), !shared_identity_kind(k), + call_arg(ce, 1, lit), descriptors_literal_entry(lit, n, desc), descriptor_value(desc, k2, i2), + (k != "shape" ; shape_stores_kind(k2)). member_write("obj", ce, n, k2, i2) :- object_create_site(ce, _), call_arg(ce, 1, lit), descriptors_literal_entry(lit, n, desc), descriptor_value(desc, k2, i2). // The call evaluates to its target (`Object.defineProperty(o, …)` returns o). diff --git a/graph/javascript/engine/resolution/value-flow.dl b/graph/javascript/engine/resolution/value-flow.dl index 5f8da8a6b..1ce316c2a 100644 --- a/graph/javascript/engine/resolution/value-flow.dl +++ b/graph/javascript/engine/resolution/value-flow.dl @@ -232,9 +232,14 @@ path_source(k, i) :- var_binding_path(_, _, v), var_init(_, _, e, v), expr_value path_source(k, i) :- expr_param(_, p, e), expr_binding_path(_, _, e), param_value(p, k, i). path_source("module", mod) :- import_decl(_, _, _, "DESTRUCTURED", n, _, _, _, imp), contains(".", n), import_module(imp, mod). path_source(k, i) :- path_step(_, _, _, k, i). -path_value(k, i, p, k2, i2) :- path_rest_base(p, b), path_parent(b, par), match("[0-9]+", substr(b, strlen(par) + 1, strlen(b) - strlen(par) - 1)), - path_value(k, i, par, k2, i2). -path_value(k, i, p, k2, i2) :- path_rest_base(p, b), b != "", !match("([^.]*[.])*[0-9]+", b), path_value(k, i, b, k2, i2). +path_value(k, i, p, k2, i2) :- path_rest_index_parent(p, par), path_value(k, i, par, k2, i2). +path_value(k, i, p, k2, i2) :- path_rest_named_base(p, b), path_value(k, i, b, k2, i2). +// The two tests above are on the path TEXT only, so they are decided once here, below +// the flow, instead of by a regex on every pass of the recursion (#732: the second +// clause was 29 s of a 200 s solve on a real bundler's source when it matched inline). +path_rest_index_parent(p, par) :- path_rest_base(p, b), path_parent(b, par), + match("[0-9]+", substr(b, strlen(par) + 1, strlen(b) - strlen(par) - 1)). +path_rest_named_base(p, b) :- path_rest_base(p, b), b != "", !match("([^.]*[.])*[0-9]+", b). path_parent(b, substr(b, 0, n)) :- path_rest_base(_, b), contains(".", b), n = range(1, strlen(b) - 1), substr(b, n, 1) = ".", !contains(".", substr(b, n + 1, strlen(b) - n - 1)). binding_path(par) :- path_parent(_, par). @@ -364,8 +369,58 @@ installed_function(val, m) :- expr_kind(_, "ASSIGNMENT", _, val), expr_child(_, // parser roots both under RETURN). The owner is the function returned FROM — the // parser attributes a nested function's body to the nested function, so no // filtering by nesting is needed. Plus `@returns {T}`. -return_value(m, k, i) :- expr_root(_, "RETURN", e), expr_owner(_, m, _, e), !expr_kind(_, "THIS", _, e), expr_value(e, k, i). +return_value(m, k, i) :- expr_root(_, "RETURN", e), expr_owner(_, m, _, e), !expr_kind(_, "THIS", _, e), + !wrapped_result_return(e), expr_value(e, k, i). return_value(m, "inst", t) :- method_return_type(_, _, tr, m), type_ref_type(tr, t). + +// ── a WRAPPER's closure returns what its argument returns, per call site (#695) ── +// `memoize(fn)`, `once(f)`, `lazy(make)`: the wrapper returns a closure, and the +// closure returns the result of calling the wrapper's parameter, directly +// (`() => make()`) or through a variable (`result = fn(); return result`). Read +// context-insensitively, the parameter holds every function any caller passed, so +// `getAlpha()` returned the union of every memoized getter's result (a superset), or, +// once the wrapper was over the fan cap, nothing at all. The site knows better: at +// `const getAlpha = memoize(() => require('./alpha'))`, a call of what that site +// returned returns what ITS argument returns. So the site is a value of its own, +// `("wrap", site)`, carried beside the closure's `("func", c)` wherever the closure +// goes, and a call through it takes its value from that site's argument alone. The +// closure's carrying `return` feeds no caller-merged value: every call of the +// closure reaches it through a site. Nothing here reads param_value, so a wrapper +// over the fan cap is answered as exactly as one with two callers. +// +// Syntactic, so it stratifies below the flow: the closure is the function a `return` +// of the wrapper introduces (or names through a const), and the carry is the call of +// the parameter through `=`, `||`, `??`, `&&`, `?:`, `(a, b)` and a local binding. +wrapper_closure(h, c) :- expr_root(_, "RETURN", e), expr_owner(_, h, _, e), expr_introduces(_, c, e). +wrapper_closure(h, c) :- expr_root(_, "RETURN", e), expr_owner(_, h, _, e), expr_binding(_, v, e), + var_init(_, _, init, v), expr_introduces(_, c, init). +param_call_kind("FUNCTION_CALL"). +param_call_kind("FUNCTION_CALL_CALL"). +param_call_kind("FUNCTION_CALL_APPLY"). +carries_param_call(pc, p) :- expr_kind(_, "CALL", _, pc), call_site(_, ck, _, _, _, _, pc, _, _), param_call_kind(ck), + expr_child(_, pc, "CALLEE", _, c), expr_param(_, p, c). +carries_param_call(e, p) :- expr_kind(_, "CONDITIONAL", _, e), expr_child(_, e, r, _, c), r != "CONDITION", carries_param_call(c, p). +carries_param_call(e, p) :- expr_kind(_, "BINARY", _, e), expr_operator(_, op, e), binary_op_passes_value(op), + expr_child(_, e, _, _, c), carries_param_call(c, p). +carries_param_call(e, p) :- expr_kind(_, "ASSIGNMENT", _, e), expr_child(_, e, "ASSIGNMENT_VALUE", _, c), carries_param_call(c, p). +carries_param_call(e, p) :- expr_kind(_, "SEQUENCE", _, e), sequence_last(e, c), carries_param_call(c, p). +carries_param_call(e, p) :- expr_binding(_, v, e), var_carries_param_call(v, p). +var_carries_param_call(v, p) :- var_init(_, _, init, v), carries_param_call(init, p). +var_carries_param_call(v, p) :- expr_kind(_, "ASSIGNMENT", _, a), expr_operator(_, op, a), assignment_op_writes_value(op), + expr_child(_, a, "ASSIGNMENT_TARGET", _, tgt), expr_binding(_, v, tgt), + expr_child(_, a, "ASSIGNMENT_VALUE", _, val), carries_param_call(val, p). +// wrapper_forwards(Wrapper, Closure, ArgPosition): the closure's result is the call of +// the wrapper's parameter at that position. +wrapper_forwards(h, c, pos) :- wrapper_closure(h, c), param_decl(_, _, ppos, h, p), !param_is_rest(_, p), + pos = to_number(ppos), expr_root(_, "RETURN", e), expr_owner(_, c, _, e), carries_param_call(e, p). +wrapped_result_return(e) :- wrapper_closure(h, c), param_decl(_, _, _, h, p), + expr_root(_, "RETURN", e), expr_owner(_, c, _, e), carries_param_call(e, p). +// The site's own value, and a call through it. +expr_value(s, "wrap", s) :- expr_kind(_, "CALL", _, s), expr_resolves_to_method(s, h), wrapper_forwards(h, _, _). +expr_value(ce, k, i) :- expr_kind(_, "CALL", _, ce), callee_value(ce, "wrap", s), + !call_site(_, "FUNCTION_CALL_BIND", _, _, _, _, ce, _, _), + expr_resolves_to_method(s, h), wrapper_forwards(h, _, pos), call_arg(s, pos, arg), + expr_value(arg, "func", g), return_value(g, k, i). // `return this` is POLYMORPHIC: a fluent `add()` declared on Node and called on an // AbstractBlock returns the AbstractBlock. So a `this`-return is not a value of the // method — it is the RECEIVER of each call, substituted at the site (below). A @@ -499,14 +554,46 @@ expr_value(e, "free", cat(mod, ":", n)) :- expr_kind(_, "IDENTIFIER", _, e), // every `@param {Object} opts` in the program, so a write through any of them would // be a read through all of them — a global merge point that made one held-out // solve 35x slower. The platform's properties are not tracked. +// +// The same holds for `str` (#732): a string value is identified by its TEXT, so +// `"request"` is one value wherever the literal is written, and a write through a +// binding that may hold it landed on every other binding that may hold the same text. +// At runtime a property write onto a primitive is dropped (or throws in strict code), +// so nothing real is lost. On a real bundler's own source these were 37K member_write +// rows, one of the points where its widest value sets were manufactured. +// +// A `shape` is a JSDoc `@typedef {Object}` REFERENCE, also one identity per typedef: +// `@param {StatsModule} m; m.moduleName = x` wrote x into every StatsModule in the +// program. On that bundler these were 36K member_write rows onto 66 typedefs, and +// they are where its widest value sets were made: 513 parameters of 620 unrelated +// functions all held the same 552 values. But where the object behind a typedef is +// not tracked, the shape is the only carrier of a HANDLER stored on it: +// `context.cachedGetErrors = c => …`, later `cachedGetErrors(compilation)`, was 9 +// real edges on that bundler. So a write through a shape keeps every value a call +// can later be made on or through (shape_stores_kind): a function or a class; an +// instance, object literal or module namespace whose members are called +// (`ctx.logger = new Logger()`, then `ctx.logger.log()`, where nothing but the +// typedef carries ctx); and an array or collection of handlers that is iterated +// (`ctx.list = [a, b]; for (const f of ctx.list) f()`). It drops the string, +// symbol and other scalar stores, which were the width. Both tests are on kinds, +// so the rules stay monotone. +shared_identity_kind("ambient"). +shared_identity_kind("str"). +shape_stores_kind("func"). +shape_stores_kind("ctor"). +shape_stores_kind("inst"). +shape_stores_kind("obj"). +shape_stores_kind("module"). +shape_stores_kind("arr"). +shape_stores_kind("coll"). member_write(k, i, n, k2, i2) :- expr_kind(_, "ASSIGNMENT", _, a), expr_operator(_, op, a), assignment_op_writes_value(op), expr_child(_, a, "ASSIGNMENT_TARGET", _, tgt), expr_kind(_, "PROPERTY_ACCESS", _, tgt), expr_name(_, n, tgt), - expr_child(_, tgt, "ACCESS_TARGET", _, r), expr_value(r, k, i), k != "ambient", - expr_child(_, a, "ASSIGNMENT_VALUE", _, val), expr_value(val, k2, i2). + expr_child(_, tgt, "ACCESS_TARGET", _, r), expr_value(r, k, i), !shared_identity_kind(k), + expr_child(_, a, "ASSIGNMENT_VALUE", _, val), expr_value(val, k2, i2), (k != "shape" ; shape_stores_kind(k2)). member_write(k, i, n, k2, i2) :- computed_key_write(a, tgt, key, val), !keyed_copy_write(a, _, _), !generic_setter_write(a), key_name(key, n), - expr_child(_, tgt, "ACCESS_TARGET", _, r), expr_value(r, k, i), k != "ambient", k != "arr", k != "coll", - expr_value(val, k2, i2). + expr_child(_, tgt, "ACCESS_TARGET", _, r), expr_value(r, k, i), !shared_identity_kind(k), k != "arr", k != "coll", + expr_value(val, k2, i2), (k != "shape" ; shape_stores_kind(k2)). computed_key_write(a, tgt, key, val) :- expr_kind(_, "ASSIGNMENT", _, a), expr_operator(_, op, a), assignment_op_writes_value(op), expr_child(_, a, "ASSIGNMENT_TARGET", _, tgt), expr_kind(_, "ELEMENT_ACCESS", _, tgt), expr_child(_, tgt, "COMPUTED_KEY", _, key), expr_child(_, a, "ASSIGNMENT_VALUE", _, val). @@ -539,10 +626,15 @@ same_key_binding(k1, k2) :- expr_binding(_, v, k1), expr_binding(_, v, k2). generic_setter_write(a) :- computed_key_write(a, tgt, key, _), expr_child(_, tgt, "ACCESS_TARGET", _, r), expr_param(_, _, r), expr_param(_, _, key), !keyed_copy_write(a, _, _). member_write_refused(a, "generic_setter") :- generic_setter_write(a). +// A write whose target may hold a string (above): declared, so the +// dropped store is countable. `ambient` targets were never tracked and are not listed. +member_write_refused(a, "shared_identity") :- expr_kind(_, "ASSIGNMENT", _, a), expr_operator(_, op, a), assignment_op_writes_value(op), + expr_child(_, a, "ASSIGNMENT_TARGET", _, tgt), expr_child(_, tgt, "ACCESS_TARGET", _, r), + expr_value(r, k, _), shared_identity_kind(k), k != "ambient". member_write(k, i, n, k2, i2) :- keyed_copy_write(a, key, src), computed_key_write(a, tgt, key, _), key_name(key, n), - expr_child(_, tgt, "ACCESS_TARGET", _, r), expr_value(r, k, i), k != "ambient", k != "arr", k != "coll", - expr_value(src, k0, i0), prop_value(k0, i0, n, k2, i2). + expr_child(_, tgt, "ACCESS_TARGET", _, r), expr_value(r, k, i), !shared_identity_kind(k), k != "arr", k != "coll", + expr_value(src, k0, i0), prop_value(k0, i0, n, k2, i2), (k != "shape" ; shape_stores_kind(k2)). // A copy whose keys are ENUMERATED FROM THE SOURCE (#707): `for (const k in src) // dst[k] = src[k]`, `for (const k of Object.keys(src))`, `Object.keys(src).forEach(k // => { dst[k] = src[k]; })`, the same through `Object.getOwnPropertyNames`, an alias @@ -558,8 +650,9 @@ enumerated_copy(a, src) :- keyed_copy_write(a, key, src), key_enumerates(key2, o same_subject(e1, e2) :- same_key_binding(e1, e2). same_subject(e1, e2) :- expr_kind(_, "THIS", _, e1), expr_kind(_, "THIS", _, e2), expr_owner(_, m, _, e1), expr_owner(_, m, _, e2). member_write(k, i, n, k2, i2) :- enumerated_copy(a, src), computed_key_write(a, tgt, _, _), - expr_child(_, tgt, "ACCESS_TARGET", _, r), expr_value(r, k, i), k != "ambient", k != "arr", k != "coll", - expr_value(src, k0, i0), prop_value(k0, i0, n, k2, i2), n != "constructor", n != "prototype". + expr_child(_, tgt, "ACCESS_TARGET", _, r), expr_value(r, k, i), !shared_identity_kind(k), k != "arr", k != "coll", + expr_value(src, k0, i0), prop_value(k0, i0, n, k2, i2), n != "constructor", n != "prototype", + (k != "shape" ; shape_stores_kind(k2)). // key_enumerates(KeyExpr, Source): a reference to a binding that enumerates Source's // own property names. key_enumerates(key, src) :- loop_head(v, src), for_in_iterable(src), expr_binding(_, v, key). @@ -590,9 +683,15 @@ object_keys_call(ce, o) :- expr_kind(_, "CALL", _, ce), expr_resolves_to_method( // keys_helper(Method): `function keys(o) { const a = []; for (const k in o) a.push(k); // return a; }`, or `function keys(o) { return Object.keys(o); }`: returns an array of // its first parameter's property names. -keys_helper(m) :- param_decl(_, _, "0", m, p), loop_head(v, e), for_in_iterable(e), expr_param(_, p, e), - expr_binding(_, v, kref), array_method_call(pce, "push", a), call_arg(pce, _, kref), return_value(m, "arr", a). -keys_helper(m) :- param_decl(_, _, "0", m, p), object_keys_call(ce, e), expr_param(_, p, e), return_value(m, "arr", ce). +// ATOM ORDER MATTERS (#732): Souffle joins in the order written, and these rules sit in +// the value-flow recursion, so they are re-run every iteration. Written with +// `param_decl` first they began with the product of every first parameter and every +// loop head (no shared variable), and on a real bundler's source that product was the +// most expensive clause of the solve: 76 s of 200, deriving nothing. Each atom below +// is bound by the one before it. +keys_helper(m) :- loop_head(v, e), for_in_iterable(e), expr_param(_, p, e), param_decl(_, _, "0", m, p), + expr_binding(_, v, kref), call_arg(pce, _, kref), array_method_call(pce, "push", a), return_value(m, "arr", a). +keys_helper(m) :- object_keys_call(ce, e), expr_param(_, p, e), param_decl(_, _, "0", m, p), return_value(m, "arr", ce). // `Object.assign` and its library cousins: resolution/frameworks.dl. // ── literal_owns_method(LiteralExpr, MethodHash) — PD-JS-1 workaround ─────── diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index db5004f54..32ddecf4c 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -70,6 +70,8 @@ .decl call_resolved(c0:symbol) .decl unresolved_receiver(c0:symbol, c1:symbol, c2:symbol, c3:symbol) .decl unresolved_covered(c0:symbol) +.decl value_callee_open(c0:symbol, c1:symbol) +.decl unresolved_value_callee(c0:symbol, c1:symbol) // ── containment/ownership.dl ── .decl call_caller_method(c0:symbol, c1:symbol) @@ -301,6 +303,12 @@ .decl this_value(c0:symbol, c1:symbol, c2:symbol) .decl installed_function(c0:symbol, c1:symbol) .decl return_value(c0:symbol, c1:symbol, c2:symbol) +.decl wrapper_closure(c0:symbol, c1:symbol) +.decl param_call_kind(c0:symbol) +.decl carries_param_call(c0:symbol, c1:symbol) +.decl var_carries_param_call(c0:symbol, c1:symbol) +.decl wrapper_forwards(c0:symbol, c1:symbol, c2:number) +.decl wrapped_result_return(c0:symbol) .decl prop_value(c0:symbol, c1:symbol, c2:symbol, c3:symbol, c4:symbol) .decl member_write(c0:symbol, c1:symbol, c2:symbol, c3:symbol, c4:symbol) .decl prototype_object(c0:symbol, c1:symbol) @@ -314,6 +322,10 @@ .decl keys_array_expr(c0:symbol, c1:symbol) .decl object_keys_call(c0:symbol, c1:symbol) .decl keys_helper(c0:symbol) +.decl shared_identity_kind(c0:symbol) +.decl shape_stores_kind(c0:symbol) +.decl path_rest_index_parent(c0:symbol, c1:symbol) +.decl path_rest_named_base(c0:symbol, c1:symbol) .decl generic_setter_write(c0:symbol) .decl member_write_refused(c0:symbol, c1:symbol) .decl literal_contains_method(c0:symbol, c1:symbol) diff --git a/graph/javascript/souffle/export_manifest.tsv b/graph/javascript/souffle/export_manifest.tsv index b90de230f..1e8b5aac9 100644 --- a/graph/javascript/souffle/export_manifest.tsv +++ b/graph/javascript/souffle/export_manifest.tsv @@ -5,6 +5,7 @@ client_calls_lib client-to-lib-calls.csv client_calls_lib_detail client-to-lib-navigation.csv unresolved_receiver unresolved-receiver.csv method_has_unknown_call method-has-unknown-call.csv +unresolved_value_callee unresolved-value-callee.csv entry_point entry-point.csv entry_reachable entry-reachable.csv method_call_edge method-call-edge.csv diff --git a/graph/pipeline/run-souffle.sh b/graph/pipeline/run-souffle.sh index 5cf84aacf..79c797220 100755 --- a/graph/pipeline/run-souffle.sh +++ b/graph/pipeline/run-souffle.sh @@ -424,7 +424,12 @@ case "$LIBKEY" in *) echo "library cache key is not a digest (got '$LIBKEY') — refusing to reuse staged facts" >&2 exit 1;; esac -LIBDIR="$CACHE_ROOT/libfacts-$LIBKEY" +# AXIOM_LIBFACTS_CACHE moves ONLY the staged library facts (the compiled engine stays in +# CACHE_ROOT). The key is module names plus CSV size and mtime in whole seconds, which is +# enough for one run at a time; a test harness running cases side by side gives each its own +# directory, because two library IRs parsed in the same second into same-named directories +# can share a key (graph/test/tools/case-pool.sh). +LIBDIR="${AXIOM_LIBFACTS_CACHE:-$CACHE_ROOT}/libfacts-$LIBKEY" # How many IR modules the roots actually hold. Cheap (lib_modules is a marker test and a # one-level glob, never a find) and worth knowing before the message below: "this is the # 2GB read" was printed verbatim for a run whose --library directory was EMPTY, which is @@ -531,6 +536,16 @@ echo "▶ engine id = $ENGINE_ID (rules + souffle $SOUFFLE_VERSION)" # per-run intermediate. Default IN-REPO so a checkout is self-contained (.souffle-cache/ is # gitignored); point AXIOM_SOUFFLE_CACHE at a shared dir to amortise it. CACHE_DIR="$CACHE_ROOT" +# -march: `native` by default, tuned for the machine that compiles and runs it. A binary +# that is restored onto OTHER machines — a CI cache shared across hosted runners, whose CPUs +# differ — must not be: AXIOM_ENGINE_MARCH=portable compiles for the compiler's baseline +# target instead, as the published engines are (build-engines.yml). Any other value is +# passed through as -march=. ENGINE_ID does not cover this, so whoever shares a +# cache across machines keys it on the setting (ci.yml does). +case "${AXIOM_ENGINE_MARCH:-native}" in + portable) MARCH_FLAG=();; + *) MARCH_FLAG=("-march=${AXIOM_ENGINE_MARCH:-native}");; +esac EXE=""; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) EXE=".exe";; esac BIN="$CACHE_DIR/souffle-engine-$LANG_ARG-$ENGINE_ID$EXE" @@ -606,7 +621,7 @@ elif command -v souffle >/dev/null 2>&1; then [ -s "$INT/souffle-program.cpp" ] || { echo "❌ souffle wrote no C++ for $PROG" >&2; exit 1; } CXX_PLATFORM="" case "$(uname -s)" in CYGWIN*) CXX_PLATFORM="-Wa,-mbig-obj";; esac - if ! c++ -std=c++17 -O3 -march=native -w $CXX_PLATFORM -I "$INNER" "$INT/souffle-program.cpp" -o "$BIN.tmp.$$"; then + if ! c++ -std=c++17 -O3 ${MARCH_FLAG[@]+"${MARCH_FLAG[@]}"} -w $CXX_PLATFORM -I "$INNER" "$INT/souffle-program.cpp" -o "$BIN.tmp.$$"; then rm -f "$BIN.tmp.$$"; echo "❌ compiling the engine failed" >&2; exit 1 fi # VERIFY, THEN PUBLISH. The cache entry is trusted by name alone from now on, so nothing may @@ -693,13 +708,25 @@ while [ "$iter" -lt 50 ]; do INNER="${INNER:-$(find_souffle_include)}" souffle -I "$SRC" -g "$INT/profile-program.cpp" -p "$AXIOM_SOUFFLE_PROFILE" "$PROG" \ 2> "$INT/.souffle-prof-gen.log" || { cat "$INT/.souffle-prof-gen.log" >&2; exit 1; } - c++ -std=c++17 -O3 -march=native -w -I "$INNER" \ + c++ -std=c++17 -O3 ${MARCH_FLAG[@]+"${MARCH_FLAG[@]}"} -w -I "$INNER" \ "$INT/profile-program.cpp" -o "$PBIN" || exit 1 fi echo "▶ solving with profiling -> $AXIOM_SOUFFLE_PROFILE" "$PBIN" -F "$FACTS" -D "$RAW" -p "$AXIOM_SOUFFLE_PROFILE" else - "$BIN" -F "$FACTS" -D "$RAW" + # A cached binary is compiled with -march=native (unless AXIOM_ENGINE_MARCH says + # otherwise). Restored onto a CPU without one of + # the instructions it uses (a shared cache, a CI cache keyed too coarsely), it dies + # with SIGILL (exit 132) before solving anything. Never leave it there to kill every + # later run the same way: drop the cache entry, so the next run recompiles, and say so. + rc=0; "$BIN" -F "$FACTS" -D "$RAW" || rc=$? + if [ "$rc" -ne 0 ]; then + if [ "$rc" -eq 132 ] && [ -z "$PACKAGED" ]; then + rm -f "$BIN" + echo "❌ the cached engine $BIN died with an illegal instruction: it was compiled for a different CPU. Removed it; the next run recompiles." >&2 + fi + exit "$rc" + fi fi # No frontier declared for this language: the solve above is the whole answer. Break # BEFORE the count, because the count is what misreported it. See issue #475. diff --git a/graph/python/engine/config-resolution/knobs.dl b/graph/python/engine/config-resolution/knobs.dl index 74c252ab5..c2f6c78f0 100644 --- a/graph/python/engine/config-resolution/knobs.dl +++ b/graph/python/engine/config-resolution/knobs.dl @@ -124,6 +124,35 @@ py_task_dispatch_method("apply_async"). // the fixture to produce the argument. The parameter name is the only join. py_fixture_deco("fixture"). +// ── py_fixture_name_kw(Keyword): the decorator argument that names the fixture ─ +// `@pytest.fixture(name="client") def client_fixture()` registers `client`, and a test +// requesting `client_fixture` gets nothing. The documented way to keep a fixture from +// shadowing a module-level name. +py_fixture_name_kw("name"). + +// ── py_fixture_plugins_var(Name): the module global that lists plugin modules ─ +// `pytest_plugins = ["pkg.fixtures"]` (a string, a list or a tuple) makes the runner +// import those modules as plugins, and a plugin's fixtures are visible to every test in +// the session, not only to the directory of the module that listed them. +py_fixture_plugins_var("pytest_plugins"). + +// ── py_parametrize_deco(Tail) and its keywords: a value supplied DIRECTLY ──── +// `@pytest.mark.parametrize("user", [...])` passes the value in, and the fixture called +// `user` is NOT run for that test. With `indirect=True`, or with the name in an +// `indirect=[...]` list, the value goes to the fixture instead and the fixture runs. +py_parametrize_deco("parametrize"). +py_parametrize_argnames_kw("argnames"). +py_parametrize_indirect_kw("indirect"). +py_parametrize_indirect_all("True"). + +// ── py_argnames_sep / py_argnames_lead: how "a, b" separates argument names ── +// The runner splits the string on "," and strips each piece. Souffle cannot split, so a +// name is tested as a whole token: led by a separator (with or without the usual single +// space) and followed by one, inside the string wrapped in separators. +py_argnames_sep(","). +py_argnames_lead(","). +py_argnames_lead(", "). + // ── py_test_name_prefix(Prefix) — how a runner recognises a test ───────────── // Injection happens only into a COLLECTED test. Without this, every function whose // parameter happens to share a fixture's name would be wired to it. @@ -142,6 +171,29 @@ py_route_entry_fn("path"). py_route_entry_fn("re_path"). py_route_entry_fn("url"). +// ── py_route_include_fn(Name) — mounts another table under a prefix ────────── +// `path("orders/", include(order_patterns))`. The argument is a table like +// `urlpatterns`, however it is named. +py_route_include_fn("include"). + +// ── py_route_regex_syntax(Regex) — a route pattern that is a regular expression ── +// `re_path(r"^orders/(?P[0-9]+)/$", view)`. Such a pattern names no literal path, so +// no served path is composed through it. Matched against the whole pattern text. +py_route_regex_syntax(".*[$^()?|\\\\].*"). + +// ── py_class_view_entry_fn / py_view_handler_name ──────────────────────────── +// `path("orders/", OrderView.as_view())`. The function as_view() returns calls the +// class's method named for the request's HTTP verb, lower-cased. +py_class_view_entry_fn("as_view"). +py_view_handler_name("get"). +py_view_handler_name("post"). +py_view_handler_name("put"). +py_view_handler_name("patch"). +py_view_handler_name("delete"). +py_view_handler_name("head"). +py_view_handler_name("options"). +py_view_handler_name("trace"). + // ── py_signal_receiver_deco(Tail) — attaches a handler to a signal ─────────── // `@receiver(order_placed)`. The publisher writes `order_placed.send(sender=...)`; // the two ends are joined by the SIGNAL OBJECT and never by a call. diff --git a/graph/python/engine/framework-behavior/dispatch.dl b/graph/python/engine/framework-behavior/dispatch.dl index a3f943c01..7b3e7bfa3 100644 --- a/graph/python/engine/framework-behavior/dispatch.dl +++ b/graph/python/engine/framework-behavior/dispatch.dl @@ -69,6 +69,7 @@ .decl py_fw_deco_tail(v:symbol) py_fw_deco_tail(v) :- py_task_register_deco(v). py_fw_deco_tail(v) :- py_fixture_deco(v). +py_fw_deco_tail(v) :- py_parametrize_deco(v). py_fw_deco_tail(v) :- py_signal_receiver_deco(v). py_fw_deco_tail(v) :- py_orm_hook_deco(v). @@ -146,9 +147,20 @@ framework_edge(from, to, "task_dispatch", meth, "registered") :- // The runner matches a test's PARAMETER NAME against the declared fixtures and calls // the fixture to build the argument. The name is the entire join, so the confidence is // `by_name`: it is correct exactly where the runner's scoping agrees with lexical scope. +// +// THE NAME IS THE ONE THE DECORATOR REGISTERS. `@fixture(name="client")` on +// `def client_fixture()` registers `client`, and only a parameter called `client` gets +// it. Keying on the def name wires the wrong parameter and misses the right one. .decl py_fixture_decl(m:symbol, name:symbol) -py_fixture_decl(m, name) :- decorator_target("client", _, m, h), m != "", - py_deco_named(h, v), py_fixture_deco(v), +.decl py_fixture_deco_on(m:symbol, h:symbol) +py_fixture_deco_on(m, h) :- decorator_target("client", _, m, h), m != "", + py_deco_named(h, v), py_fixture_deco(v). +.decl py_fixture_renamed(h:symbol, name:symbol) +py_fixture_renamed(h, name) :- py_fixture_deco_on(_, h), + annotation_arg("client", k, name, "STRING_LITERAL", _, "true", h, _), + py_fixture_name_kw(k), name != "". +py_fixture_decl(m, name) :- py_fixture_deco_on(m, h), py_fixture_renamed(h, name). +py_fixture_decl(m, name) :- py_fixture_deco_on(m, h), !py_fixture_renamed(h, _), method_decl("client", name, _, _, _, m). // The runner calls a requested fixture; nothing in the client does. @@ -191,32 +203,116 @@ py_fixture_requester(m) :- py_fixture_decl(m, _). // strict > excludes it and the commonest place a project keeps shared fixtures // contributes nothing at all. At the root d is "" and the prefix test admits every // requester, which is exactly right. +// +// A FIXTURE BELONGS TO EVERY MODULE IT IS AN ATTRIBUTE OF, not only to the file that +// declares it. The runner registers the fixtures it finds among a module's attributes, +// so `from .fixtures import *` in a conftest, or `from .helpers import helper_fx` in a +// test module, makes the fixture that module's own. Keyed on the declaring file alone, +// a fixture module a conftest star-imports reached no test at all (#1537). +// module_member_method already applies the language's export rule to a star import +// (`__all__`, else no leading underscore), so a name the star does not bind is not +// registered here either. +.decl py_fixture_in_file(fix:symbol, file:symbol) +py_fixture_in_file(fix, p) :- py_fixture_decl(fix, _), method_file("client", p, fix). +py_fixture_in_file(fix, p) :- py_fixture_decl(fix, _), + module_member_method("client", mod, _, fix), module_file("client", p, mod). + .decl py_fixture_conftest(fix:symbol, dir:symbol) -py_fixture_conftest(fix, d) :- py_fixture_decl(fix, _), - method_file("client", p, fix), py_fixture_scope_file(c), +py_fixture_conftest(fix, d) :- py_fixture_in_file(fix, p), py_fixture_scope_file(c), strlen(p) >= strlen(c), substr(p, strlen(p) - strlen(c), strlen(c)) = c, d = substr(p, 0, strlen(p) - strlen(c)). +// py_fixture_plugin(Fixture) -- a fixture of a module some module names in its +// `pytest_plugins` global. A plugin is registered for the whole session, so its fixtures +// are visible to every test, below any conftest or same-module fixture of the name. +// The value is a string, or a list or tuple of strings, each a dotted module name, +// joined to the module by that exact qualified name. +.decl py_fixture_plugin_name(q:symbol) +py_fixture_plugin_name(q) :- py_fixture_plugins_var(n), py_module_level_binding(_, n, b), + expr_binding("client", b, "STORE", tgt), assign_pair("client", tgt, val), + expr_node("client", "LITERAL", _, q, val), q != "". +py_fixture_plugin_name(q) :- py_fixture_plugins_var(n), py_module_level_binding(_, n, b), + expr_binding("client", b, "STORE", tgt), assign_pair("client", tgt, val), + expr_parent("client", val, "ELEMENT", _, el), + expr_node("client", "LITERAL", _, q, el), q != "". +.decl py_fixture_plugin(fix:symbol) +py_fixture_plugin(fix) :- py_fixture_plugin_name(q), module_decl("client", _, q, _, mod), + module_file("client", p, mod), py_fixture_in_file(fix, p). + // A requested parameter: no default, not a receiver, on a test or another fixture. -.decl py_fixture_request(req:symbol, name:symbol) -py_fixture_request(req, name) :- py_fixture_requester(req), +// And NOT an argument the test's own parametrize decorator supplies DIRECTLY: the runner +// passes that value in and never runs the fixture of the name (#1529). An `indirect` +// one is handed to the fixture, which does run, so it stays a request. +.decl py_fixture_param(req:symbol, name:symbol) +py_fixture_param(req, name) :- py_fixture_requester(req), param_decl("client", name, _, _, req, ph), name != "", param_default("client", "false", _, ph), param_kind("client", _, _, "false", ph). -// (a) the test's own module wins outright. +.decl py_parametrize_on(req:symbol, h:symbol) +py_parametrize_on(req, h) :- decorator_target("client", _, req, h), req != "", + py_deco_named(h, v), py_parametrize_deco(v). +// the argnames, as the string "a, b" (first positional, or the keyword) ... +.decl py_parametrize_argnames(h:symbol, s:symbol) +py_parametrize_argnames(h, s) :- py_parametrize_on(_, h), + annotation_arg("client", _, s, "STRING_LITERAL", "0", "false", h, _). +py_parametrize_argnames(h, s) :- py_parametrize_on(_, h), + annotation_arg("client", k, s, "STRING_LITERAL", _, "true", h, _), + py_parametrize_argnames_kw(k). +.decl py_parametrize_argname(req:symbol, h:symbol, name:symbol) +py_parametrize_argname(req, h, name) :- py_fixture_param(req, name), + py_parametrize_on(req, h), py_parametrize_argnames(h, s), + py_argnames_sep(sep), py_argnames_lead(lead), + contains(cat(lead, cat(name, sep)), cat(sep, cat(s, sep))). +// ... or as a list or tuple of strings. +py_parametrize_argname(req, h, name) :- py_fixture_param(req, name), + py_parametrize_on(req, h), decorator_expr("client", de, h), call_arg(de, "0", a), + expr_parent("client", a, "ELEMENT", _, el), + expr_node("client", "LITERAL", _, name, el). +.decl py_parametrize_indirect(h:symbol, name:symbol) +py_parametrize_indirect(h, name) :- py_parametrize_argname(_, h, name), + annotation_arg("client", k, v, _, _, "true", h, _), + py_parametrize_indirect_kw(k), py_parametrize_indirect_all(v). +py_parametrize_indirect(h, name) :- py_parametrize_argname(_, h, name), + decorator_expr("client", de, h), call_kwarg(de, k, a), py_parametrize_indirect_kw(k), + expr_parent("client", a, "ELEMENT", _, el), + expr_node("client", "LITERAL", _, name, el). +.decl py_parametrize_direct(req:symbol, name:symbol) +py_parametrize_direct(req, name) :- py_parametrize_argname(req, h, name), + !py_parametrize_indirect(h, name). + +.decl py_fixture_request(req:symbol, name:symbol) +py_fixture_request(req, name) :- py_fixture_param(req, name), + !py_parametrize_direct(req, name). + +// (0) a fixture a CLASS declares serves the tests of that class and of its subclasses, +// and nothing else; for them it wins over the module's fixture of the name. Read as a +// plain same-module fixture it was also handed to a sibling class's tests, and the +// module fixture it overrides was handed to its own (#1512). +.decl py_fixture_class(fix:symbol, t:symbol) +py_fixture_class(fix, t) :- py_fixture_decl(fix, _), method_owner("client", t, fix), t != "". +.decl py_fixture_in_class(req:symbol, name:symbol, fix:symbol) +py_fixture_in_class(req, name, fix) :- py_fixture_request(req, name), + py_fixture_decl(fix, name), fix != req, py_fixture_class(fix, t), + method_owner("client", u, req), type_self_or_ancestor("client", u, t). +.decl py_fixture_class_shadowed(req:symbol, name:symbol) +py_fixture_class_shadowed(req, name) :- py_fixture_in_class(req, name, _). + +// (a) the test's own module wins next: declared at its top level, or imported into it. .decl py_fixture_same_module(req:symbol, name:symbol, fix:symbol) py_fixture_same_module(req, name, fix) :- py_fixture_request(req, name), - py_fixture_decl(fix, name), fix != req, - method_file("client", p, req), method_file("client", p, fix). + py_fixture_decl(fix, name), fix != req, !py_fixture_class(fix, _), + !py_fixture_class_shadowed(req, name), + method_file("client", p, req), py_fixture_in_file(fix, p). .decl py_fixture_shadowed(req:symbol, name:symbol) py_fixture_shadowed(req, name) :- py_fixture_same_module(req, name, _). +py_fixture_shadowed(req, name) :- py_fixture_class_shadowed(req, name). // (b) otherwise a conftest at or above the requester, NEAREST first. Depth is the // length of the governed directory, so the longest prefix is the nearest ancestor. .decl py_fixture_conftest_cand(req:symbol, name:symbol, fix:symbol, depth:number) py_fixture_conftest_cand(req, name, fix, dl) :- py_fixture_request(req, name), - py_fixture_decl(fix, name), fix != req, + py_fixture_decl(fix, name), fix != req, !py_fixture_class(fix, _), py_fixture_conftest(fix, d), method_file("client", p, req), strlen(p) > strlen(d), substr(p, 0, strlen(d)) = d, dl = strlen(d). .decl py_fixture_nearest(req:symbol, name:symbol, depth:number) @@ -224,9 +320,14 @@ py_fixture_nearest(req, name, m) :- py_fixture_conftest_cand(req, name, _, _), m = max dl : { py_fixture_conftest_cand(req, name, _, dl) }. .decl py_fixture_injection(from:symbol, to:symbol, name:symbol) +py_fixture_injection(req, fix, name) :- py_fixture_in_class(req, name, fix). py_fixture_injection(req, fix, name) :- py_fixture_same_module(req, name, fix). py_fixture_injection(req, fix, name) :- py_fixture_conftest_cand(req, name, fix, dl), !py_fixture_shadowed(req, name), py_fixture_nearest(req, name, dl). +// (c) failing both, a plugin's fixture, which every test sees. +py_fixture_injection(req, fix, name) :- py_fixture_request(req, name), + py_fixture_decl(fix, name), fix != req, py_fixture_plugin(fix), !py_fixture_class(fix, _), + !py_fixture_shadowed(req, name), !py_fixture_nearest(req, name, _). .decl py_fixture_requested(m:symbol) py_fixture_requested(m) :- py_fixture_injection(_, m, _). @@ -255,10 +356,12 @@ py_route_entry_call(site, e) :- // The VALUE side of `urlpatterns = [...]`. The table name is a STORE target and the // entries are its sibling, not its descendant, so the two halves are joined through // assign_pair rather than by walking up from the name. -.decl py_route_table_value(val:symbol) -py_route_table_value(val) :- assign_pair("client", tgt, val), +.decl py_route_root_value(val:symbol) +py_route_root_value(val) :- assign_pair("client", tgt, val), expr_binding("client", b, "STORE", tgt), binding_decl("client", n, _, _, _, b), py_route_table(n). +.decl py_route_table_value(val:symbol) +py_route_table_value(val) :- py_route_root_value(val). // The entry expression IS that value, or sits transitively under it. // @@ -273,18 +376,117 @@ expr_in_route_table(e) :- py_route_table_value(e). expr_in_route_table(e) :- py_route_table_value(val), expr_parent("client", val, _, _, e). expr_in_route_table(e) :- expr_in_route_table(p), expr_parent("client", p, _, _, e). +// A LIST HANDED TO include() IS A TABLE TOO (#1511). `urlpatterns = [path("x/", +// include(extra_patterns))]` mounts every entry of `extra_patterns`, and the list is +// named through a binding rather than written inline, so the descent above never +// reaches it. The name is joined to the assignment that binds it; a binding join is +// same-module, which is where such a helper list lives. `include((extra_patterns, +// "ns"))` names it one level down, as an element of the tuple. +.decl py_route_include_name(inc:symbol, e:symbol) +py_route_include_name(inc, a) :- + expr_in_route_table(inc), call_decl("client", _, fn, _, inc, _), py_route_include_fn(fn), + call_arg(inc, _, a). +py_route_include_name(inc, x) :- + py_route_include_name(inc, a), expr_parent("client", a, "ELEMENT", _, x). +// py_route_mount(inc, val): the include() call `inc` mounts the table value `val`, either +// written inline (`include([path(...)])`) or named through a binding. +.decl py_route_mount(inc:symbol, val:symbol) +py_route_mount(inc, a) :- + py_route_include_name(inc, a), expr_parent("client", a, "ELEMENT", _, x), expr_in_route_table(x), + call_decl("client", _, fn, _, x, _), py_route_entry_fn(fn). +py_route_mount(inc, val) :- + py_route_include_name(inc, a), + expr_binding("client", b, ctx, a), ctx != "STORE", binding_lookup("client", b, b2), + expr_binding("client", b2, "STORE", tgt), assign_pair("client", tgt, val). +py_route_table_value(val) :- py_route_mount(_, val). + +// THE PATH A ROUTE SERVES (#1482, #1511). A table's entries are served under the prefix of +// the entry that mounts the table: `path("api/v1/", include(api_patterns))` serves +// `path("status/", views.api_status)` at `/api/v1/status/`, and a table mounted twice serves +// its entries at both. A table named like `urlpatterns` is taken as the root, served at +// `/`; one mounted by a module path string (`include("app.urls")`) is not followed, so +// its paths are those of the root and can be short of the real prefix. A pattern that is +// a regular expression is no literal path and stops the chain; a table that mounts +// itself stops at the length bound instead of growing without end. +.decl py_route_pattern_text(e:symbol, pat:symbol) +py_route_pattern_text(e, pat) :- + py_route_entry_call(_, e), call_arg(e, "0", a), expr_node("client", "LITERAL", _, pat, a). +.decl py_route_pattern_is_regex(e:symbol) +py_route_pattern_is_regex(e) :- + py_route_pattern_text(e, pat), py_route_regex_syntax(re), match(re, pat). +.decl py_route_pattern(e:symbol, pat:symbol) +py_route_pattern(e, pat) :- py_route_pattern_text(e, pat), !py_route_pattern_is_regex(e). +.decl py_route_entry_in(val:symbol, e:symbol) +py_route_entry_in(val, e) :- + py_route_table_value(val), expr_parent("client", val, "ELEMENT", _, e), py_route_entry_call(_, e). +.decl py_route_prefix(val:symbol, p:symbol) +py_route_prefix(val, "/") :- py_route_root_value(val). +py_route_prefix(val2, cat(p, pat)) :- + py_route_prefix(val, p), strlen(p) < 200, + py_route_entry_in(val, i), py_route_pattern(i, pat), + call_arg(i, _, inc), py_route_mount(inc, val2). +.decl py_route_served(site:symbol, path:symbol) +py_route_served(site, cat(p, pat)) :- + py_route_prefix(val, p), py_route_entry_in(val, e), py_route_pattern(e, pat), + py_route_entry_call(site, e). + +// The view an entry names. A route registers the declaration written under any +// decorator, not what the decorator returns (#1510): `@login_required def settings` +// is served at its route, and the name `settings` denoting the decorator's wrapper is +// a fact about calls through that name, not about which def the route dispatches to. +// So an argument that names a replaced def yields the def, and only then does the +// value semantics of expr_denotes_method answer. +.decl py_route_entry_arg(site:symbol, a:symbol) +py_route_entry_arg(site, a) :- py_route_entry_call(site, e), call_arg(e, _, a). + +.decl py_route_arg_names_replaced(a:symbol, m:symbol) +py_route_arg_names_replaced(a, m) :- + py_route_entry_arg(_, a), expr_attr_names_method("client", a, m), method_target_replaced("client", m). +py_route_arg_names_replaced(a, m) :- + py_route_entry_arg(_, a), + expr_binding("client", b, ctx, a), ctx != "STORE", binding_lookup("client", b, b2), + binding_binds_replaced_method("client", b2, m). +py_route_arg_names_replaced(a, m) :- + py_route_entry_arg(_, a), + expr_binding("client", b, ctx, a), ctx != "STORE", binding_lookup("client", b, b2), + import_binds_method("client", b2, m), method_target_replaced("client", m). + +.decl py_route_view(site:symbol, m:symbol) +py_route_view(site, m) :- py_route_entry_arg(site, a), py_route_arg_names_replaced(a, m). +py_route_view(site, m) :- + py_route_entry_arg(site, a), !py_route_arg_names_replaced(a, _), + expr_denotes_method("client", a, m), m != "". +// A CLASS-BASED VIEW (#1513). `views.OrderView.as_view()` is a call into the framework, +// which returns a function that dispatches on the request method to the handler of +// that name. The class is the receiver of the call; the handlers are its methods named +// for an HTTP verb, looked up through the MRO so a handler inherited from a project +// base class counts. Only client methods: a handler the framework's own base defines +// is not a change anyone here makes. +py_route_view(site, m) :- + py_route_entry_arg(site, a), call_decl("client", _, fn, _, a, _), py_class_view_entry_fn(fn), + call_receiver_object(a, obj), + ( expr_names_type("client", obj, t) ; expr_attr_names_type("client", obj, t) ), + py_view_handler_name(n), mro_lookup("client", t, n, m), + method_decl("client", _, _, _, _, m). + // The view is whichever argument of the entry DENOTES a def. A route entry takes the // pattern as a string and the view as a reference, so the string arguments simply do // not denote a method and drop out without a position rule. -.decl py_url_dispatch(from:symbol, to:symbol) -py_url_dispatch(from, to) :- - py_route_entry_call(site, e), call_arg(e, _, a), - expr_denotes_method("client", a, to), to != "", +.decl py_url_dispatch_site(site:symbol, from:symbol, to:symbol) +py_url_dispatch_site(site, from, to) :- + py_route_view(site, to), call_context("client", _, from, _, _, site), from != "". +.decl py_url_dispatch(from:symbol, to:symbol) +py_url_dispatch(from, to) :- py_url_dispatch_site(_, from, to). entry_point(m, "url") :- py_url_dispatch(_, m). +// The edge carries the path the route serves where the table says it, so "what does this +// URL run" and "where is this view routed" read the answer off the edge. A route whose +// path is not written as a literal keeps the generic label. +framework_edge(from, to, "url_dispatch", path, "registered") :- + py_url_dispatch_site(site, from, to), py_route_served(site, path). framework_edge(from, to, "url_dispatch", "route_table", "registered") :- - py_url_dispatch(from, to). + py_url_dispatch_site(site, from, to), !py_route_served(site, _). // ───────────────────────────────────────────────────────────────────────────── // 4. SIGNAL DISPATCH (publisher -> receiver) diff --git a/graph/python/souffle/decls_all.dl b/graph/python/souffle/decls_all.dl index 4a52c2810..78fc34039 100644 --- a/graph/python/souffle/decls_all.dl +++ b/graph/python/souffle/decls_all.dl @@ -46,8 +46,20 @@ .decl py_fixture_deco(v:symbol) .decl py_fixture_scope_file(v:symbol) .decl py_test_name_prefix(v:symbol) +.decl py_fixture_name_kw(v:symbol) +.decl py_fixture_plugins_var(v:symbol) +.decl py_parametrize_deco(v:symbol) +.decl py_parametrize_argnames_kw(v:symbol) +.decl py_parametrize_indirect_kw(v:symbol) +.decl py_parametrize_indirect_all(v:symbol) +.decl py_argnames_sep(v:symbol) +.decl py_argnames_lead(v:symbol) .decl py_route_table(v:symbol) .decl py_route_entry_fn(v:symbol) +.decl py_route_include_fn(v:symbol) +.decl py_route_regex_syntax(v:symbol) +.decl py_class_view_entry_fn(v:symbol) +.decl py_view_handler_name(v:symbol) .decl py_signal_receiver_deco(v:symbol) .decl py_signal_connect_method(v:symbol) .decl py_signal_send_method(v:symbol) diff --git a/graph/test/csharp/README.md b/graph/test/csharp/README.md index 760aaa0f3..78c6e880d 100644 --- a/graph/test/csharp/README.md +++ b/graph/test/csharp/README.md @@ -5,7 +5,7 @@ than the numbers. | | what it is | what it proves | cost | |---|---|---|---| -| `run-tests.sh` | 16 cases, scored against Roslyn | a construct resolves, and its control does not over-resolve | seconds | +| `run-tests.sh` | 18 cases, scored against Roslyn | a construct resolves, and its control does not over-resolve | seconds | | `corpus/run-corpus.sh` | 10 real projects, dev and holdout | the rules generalise beyond what they were written against | ~30 min | | `runtime-oracle/` | the subject's own test suite, traced | an edge was actually taken, and which taken edge was missed | ~5 min per subject | @@ -34,6 +34,7 @@ run-tests.sh [--only NN-slug] [--verbose N] | `15-external-generic-return` | in-source methods whose declared return type is an unstaged generic (`List`, `Dictionary`): a `var` holding the result and the call used directly as a receiver, for an invocation, a property read and an indexer, are each labelled `external:List.*` by the ROOT type name, never `external:List.*` (#1279). Controls: the element (`items[0].Clear()`, a `foreach`, an `await`) still resolves to `Item.Clear`, and an in-source extension on the returned type stays `known_edge` | | `16-target-typed-new-external` | a target-typed `new(...)` whose target type is unstaged, in each context `target-typed-new.dl` reads (a field, a property, an explicit local, a return statement, an expression body, an assignment) and with a keyword-alias target (`string s = new('a', 3)`): each is `external:.`, as the explicit `new List()` already is (#1290). Controls: an in-source target type still resolves to its constructor | | `17-lambda-parameters` | a lambda parameter with no written type, typed by the delegate the lambda is converted to: a generic delegate declared in source closed at the field (`ParseFn`), a non-generic one, `Func` and `Action`, through a field, property and local initializer, `=`, `??=` and the branches of `?:`; and a value typed by a constrained type parameter, as a parameter and as the argument closing a delegate (`ParseFn` where `T : ISchema`) (#1283). Controls: two type arguments of different types on a declared delegate and on `Func`, whose parameters call a member of the same name on each, so crossed positions fan; an explicitly typed lambda and method. The calls THROUGH a delegate member (`inst.I.Run = inst.I.Parse`) are in `tools/assigned-delegate-member-test.sh` | +| `18-mediator-send` | a mediator's `Send(request)` reaching the handler for the request's type, and `Publish(notification)` reaching every handler for its type and its bases, which no call site names (#1383): the request constructed at the call, through a `var`, through a local declared `object`, a request with no response (`IRequestHandler`), one class handling two requests, a derived request with a handled base, a generic request closed over a type (`GetById`) and one served by an open generic handler, a request or notification declared as its base (the handler of each type in the family), and two classes in two files each declaring its own nested `Ping` and `PingHandler`, where the resolved type and not the simple name decides. The Roslyn score cannot see the hop (it is not the site's own target), so `tools/mediator-dispatch-test.sh` asserts the `event_dispatch` edges exactly, with the stand-in contracts in the source and again with them removed, the package shape. Controls: a handler nothing sends, a sibling of a sent request, the same generic request closed over another type, a notification handler of a sibling and of an unrelated notification, a notification Sent rather than Published, a notification declared as the marker interface, a handler of another `GetOrder` written partly qualified (`Legacy.GetOrder`, which the engine does not resolve) and reached only by the send that writes it that way (that send is in `dispatch-only/`, outside the Roslyn score, because the engine leaves its construction unresolved), and the same request handed to a receiver that is not a mediator | ### The acceptance bar's own self-test diff --git a/graph/test/csharp/cases/18-mediator-send/dispatch-only/LegacySender.cs b/graph/test/csharp/cases/18-mediator-send/dispatch-only/LegacySender.cs new file mode 100644 index 000000000..462b28f2c --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/dispatch-only/LegacySender.cs @@ -0,0 +1,16 @@ +using System.Threading.Tasks; +using MediatR; + +namespace Shop +{ + // Read by tools/mediator-dispatch-test.sh only, not by the Roslyn score: the engine + // does not resolve the partly qualified `new Legacy.GetOrder(id)`, and the score + // would count that construction as a site resolved to nothing. The send still names + // Shop.Legacy.GetOrder, so it reaches LegacyGetOrderHandler and not GetOrderHandler. + public class LegacyOrders + { + private readonly IMediator _mediator; + public LegacyOrders(IMediator mediator) { _mediator = mediator; } + public Task Get(int id) => _mediator.Send(new Legacy.GetOrder(id)); + } +} diff --git a/graph/test/csharp/cases/18-mediator-send/dispatch-only/Pipeline.cs b/graph/test/csharp/cases/18-mediator-send/dispatch-only/Pipeline.cs new file mode 100644 index 000000000..a71ca2d99 --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/dispatch-only/Pipeline.cs @@ -0,0 +1,23 @@ +using System.Threading; +using System.Threading.Tasks; +using MediatR; + +namespace Shop +{ + // Read by tools/mediator-dispatch-test.sh only, not by the Roslyn score: the engine + // leaves `next()`, an invocation of a generic delegate parameter, unresolved, and the + // score would count it against the case. That gap is not the mediator's. + + // An OPEN pipeline behavior is registered for every request: every Send runs it. + public class TimingBehavior : IPipelineBehavior where TReq : notnull + { + public Task Handle(TReq request, RequestHandlerDelegate next, CancellationToken ct) => next(); + } + + // A CLOSED behavior runs only around its own request type: the sends of GetOrder, and + // (CONTROL) no other Send and no Publish. + public class AuditGetOrder : IPipelineBehavior + { + public Task Handle(GetOrder request, RequestHandlerDelegate next, CancellationToken ct) => next(); + } +} diff --git a/graph/test/csharp/cases/18-mediator-send/expected.dispatch b/graph/test/csharp/cases/18-mediator-send/expected.dispatch new file mode 100644 index 000000000..9929cb3e8 --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/expected.dispatch @@ -0,0 +1,94 @@ +in-source Shop.BlueSuite.Run() Shop.BlueSuite.PingHandler.Handle(Ping,CancellationToken) +in-source Shop.BlueSuite.Run() Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.Catalog.Order(int) Shop.GetOrderById.Handle(GetById,CancellationToken) +in-source Shop.Catalog.Order(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.Catalog.Widgets(int) Shop.ListAllHandler.Handle(ListAll,CancellationToken) +in-source Shop.Catalog.Widgets(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.GreenSuite.Run() Shop.GreenSuite.PingHandler.Handle(Ping,CancellationToken) +in-source Shop.GreenSuite.Run() Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.LegacyOrders.Get(int) Shop.LegacyGetOrderHandler.Handle(Legacy.GetOrder,CancellationToken) +in-source Shop.LegacyOrders.Get(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrderCommands.Any(ShapeQuery) Shop.CircleQueryHandler.Handle(CircleQuery,CancellationToken) +in-source Shop.OrderCommands.Any(ShapeQuery) Shop.ShapeQueryHandler.Handle(ShapeQuery,CancellationToken) +in-source Shop.OrderCommands.Any(ShapeQuery) Shop.SquareQueryHandler.Handle(SquareQuery,CancellationToken) +in-source Shop.OrderCommands.Any(ShapeQuery) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrderCommands.Circle(double) Shop.CircleQueryHandler.Handle(CircleQuery,CancellationToken) +in-source Shop.OrderCommands.Circle(double) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrderCommands.Close(int) Shop.CloseOrderHandler.Handle(CloseOrder,CancellationToken) +in-source Shop.OrderCommands.Close(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrderCommands.Count(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrderCommands.Count(int) Shop.WidgetHandlers.Handle(CountWidgets,CancellationToken) +in-source Shop.OrderCommands.Shape(double) Shop.ShapeQueryHandler.Handle(ShapeQuery,CancellationToken) +in-source Shop.OrderCommands.Shape(double) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrderCommands.Weigh(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrderCommands.Weigh(int) Shop.WidgetHandlers.Handle(WeighWidgets,CancellationToken) +in-source Shop.OrderEvents.Announce(OrderEvent) Shop.AuditOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +in-source Shop.OrderEvents.Announce(OrderEvent) Shop.EmailOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +in-source Shop.OrderEvents.Announce(OrderEvent) Shop.LogOrderEvent.Handle(OrderEvent,CancellationToken) +in-source Shop.OrderEvents.Announce(OrderEvent) Shop.ShipOnOrderShipped.Handle(OrderShipped,CancellationToken) +in-source Shop.OrderEvents.Misrouted(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrderEvents.Place(int) Shop.AuditOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +in-source Shop.OrderEvents.Place(int) Shop.EmailOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +in-source Shop.OrderEvents.Place(int) Shop.LogOrderEvent.Handle(OrderEvent,CancellationToken) +in-source Shop.OrderEvents.PlaceAgain(int) Shop.AuditOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +in-source Shop.OrderEvents.PlaceAgain(int) Shop.EmailOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +in-source Shop.OrderEvents.PlaceAgain(int) Shop.LogOrderEvent.Handle(OrderEvent,CancellationToken) +in-source Shop.OrdersApi.GetOrder(OrderServices,int) Shop.AuditGetOrder.Handle(GetOrder,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrdersApi.GetOrder(OrderServices,int) Shop.GetOrderHandler.Handle(GetOrder,CancellationToken) +in-source Shop.OrdersApi.GetOrder(OrderServices,int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrdersController.Get(int) Shop.AuditGetOrder.Handle(GetOrder,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrdersController.Get(int) Shop.GetOrderHandler.Handle(GetOrder,CancellationToken) +in-source Shop.OrdersController.Get(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrdersController.GetLocal(int) Shop.AuditGetOrder.Handle(GetOrder,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrdersController.GetLocal(int) Shop.GetOrderHandler.Handle(GetOrder,CancellationToken) +in-source Shop.OrdersController.GetLocal(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrdersController.GetUntyped(int) Shop.AuditGetOrder.Handle(GetOrder,RequestHandlerDelegate,CancellationToken) +in-source Shop.OrdersController.GetUntyped(int) Shop.GetOrderHandler.Handle(GetOrder,CancellationToken) +in-source Shop.OrdersController.GetUntyped(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.BlueSuite.Run() Shop.BlueSuite.PingHandler.Handle(Ping,CancellationToken) +unstaged Shop.BlueSuite.Run() Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.Catalog.Order(int) Shop.GetOrderById.Handle(GetById,CancellationToken) +unstaged Shop.Catalog.Order(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.Catalog.Widgets(int) Shop.ListAllHandler.Handle(ListAll,CancellationToken) +unstaged Shop.Catalog.Widgets(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.GreenSuite.Run() Shop.GreenSuite.PingHandler.Handle(Ping,CancellationToken) +unstaged Shop.GreenSuite.Run() Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.LegacyOrders.Get(int) Shop.LegacyGetOrderHandler.Handle(Legacy.GetOrder,CancellationToken) +unstaged Shop.LegacyOrders.Get(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrderCommands.Any(ShapeQuery) Shop.CircleQueryHandler.Handle(CircleQuery,CancellationToken) +unstaged Shop.OrderCommands.Any(ShapeQuery) Shop.ShapeQueryHandler.Handle(ShapeQuery,CancellationToken) +unstaged Shop.OrderCommands.Any(ShapeQuery) Shop.SquareQueryHandler.Handle(SquareQuery,CancellationToken) +unstaged Shop.OrderCommands.Any(ShapeQuery) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrderCommands.Circle(double) Shop.CircleQueryHandler.Handle(CircleQuery,CancellationToken) +unstaged Shop.OrderCommands.Circle(double) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrderCommands.Close(int) Shop.CloseOrderHandler.Handle(CloseOrder,CancellationToken) +unstaged Shop.OrderCommands.Close(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrderCommands.Count(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrderCommands.Count(int) Shop.WidgetHandlers.Handle(CountWidgets,CancellationToken) +unstaged Shop.OrderCommands.Shape(double) Shop.ShapeQueryHandler.Handle(ShapeQuery,CancellationToken) +unstaged Shop.OrderCommands.Shape(double) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrderCommands.Weigh(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrderCommands.Weigh(int) Shop.WidgetHandlers.Handle(WeighWidgets,CancellationToken) +unstaged Shop.OrderEvents.Announce(OrderEvent) Shop.AuditOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +unstaged Shop.OrderEvents.Announce(OrderEvent) Shop.EmailOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +unstaged Shop.OrderEvents.Announce(OrderEvent) Shop.LogOrderEvent.Handle(OrderEvent,CancellationToken) +unstaged Shop.OrderEvents.Announce(OrderEvent) Shop.ShipOnOrderShipped.Handle(OrderShipped,CancellationToken) +unstaged Shop.OrderEvents.Misrouted(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrderEvents.Place(int) Shop.AuditOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +unstaged Shop.OrderEvents.Place(int) Shop.EmailOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +unstaged Shop.OrderEvents.Place(int) Shop.LogOrderEvent.Handle(OrderEvent,CancellationToken) +unstaged Shop.OrderEvents.PlaceAgain(int) Shop.AuditOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +unstaged Shop.OrderEvents.PlaceAgain(int) Shop.EmailOnOrderPlaced.Handle(OrderPlaced,CancellationToken) +unstaged Shop.OrderEvents.PlaceAgain(int) Shop.LogOrderEvent.Handle(OrderEvent,CancellationToken) +unstaged Shop.OrdersApi.GetOrder(OrderServices,int) Shop.AuditGetOrder.Handle(GetOrder,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrdersApi.GetOrder(OrderServices,int) Shop.GetOrderHandler.Handle(GetOrder,CancellationToken) +unstaged Shop.OrdersApi.GetOrder(OrderServices,int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrdersController.Get(int) Shop.AuditGetOrder.Handle(GetOrder,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrdersController.Get(int) Shop.GetOrderHandler.Handle(GetOrder,CancellationToken) +unstaged Shop.OrdersController.Get(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrdersController.GetLocal(int) Shop.AuditGetOrder.Handle(GetOrder,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrdersController.GetLocal(int) Shop.GetOrderHandler.Handle(GetOrder,CancellationToken) +unstaged Shop.OrdersController.GetLocal(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrdersController.GetUntyped(int) Shop.AuditGetOrder.Handle(GetOrder,RequestHandlerDelegate,CancellationToken) +unstaged Shop.OrdersController.GetUntyped(int) Shop.GetOrderHandler.Handle(GetOrder,CancellationToken) +unstaged Shop.OrdersController.GetUntyped(int) Shop.TimingBehavior.Handle(TReq,RequestHandlerDelegate,CancellationToken) diff --git a/graph/test/csharp/cases/18-mediator-send/src/18-mediator-send.csproj b/graph/test/csharp/cases/18-mediator-send/src/18-mediator-send.csproj new file mode 100644 index 000000000..a7a09e8ad --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/src/18-mediator-send.csproj @@ -0,0 +1,6 @@ + + + net8.0 + enable + + diff --git a/graph/test/csharp/cases/18-mediator-send/src/BlueSuite.cs b/graph/test/csharp/cases/18-mediator-send/src/BlueSuite.cs new file mode 100644 index 000000000..df8a03e8b --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/src/BlueSuite.cs @@ -0,0 +1,22 @@ +using System.Threading; +using System.Threading.Tasks; +using MediatR; + +namespace Shop +{ + // Two classes, each in its own file, each declare their own Ping and PingHandler: the + // same simple names in two scopes, the shape of a mediator's own test suite. Each send + // reaches the handler of ITS Ping, not the other class's. + public class BlueSuite + { + public record Ping(string Text) : IRequest; + public class PingHandler : IRequestHandler + { + public Task Handle(Ping request, CancellationToken ct) => Task.FromResult("blue " + request.Text); + } + + private readonly IMediator _mediator; + public BlueSuite(IMediator mediator) { _mediator = mediator; } + public Task Run() => _mediator.Send(new Ping("x")); + } +} diff --git a/graph/test/csharp/cases/18-mediator-send/src/Generic.cs b/graph/test/csharp/cases/18-mediator-send/src/Generic.cs new file mode 100644 index 000000000..d0653b72d --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/src/Generic.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediatR; + +namespace Shop +{ + public record Widget(int Id); + + // a GENERIC request: the handler is chosen by the request type WITH its arguments + public record GetById(int Id) : IRequest; + public class GetOrderById : IRequestHandler, OrderView> + { + public Task Handle(GetById request, CancellationToken ct) => + Task.FromResult(new OrderView(request.Id, "by id")); + } + // CONTROL: the same generic request closed over another type, and nothing sends it + public class GetWidgetById : IRequestHandler, Widget> + { + public Task Handle(GetById request, CancellationToken ct) => + Task.FromResult(new Widget(request.Id)); + } + + // an OPEN generic handler: one class serves the request closed over any type + public record ListAll(int Page) : IRequest>; + public class ListAllHandler : IRequestHandler, List> + { + public Task> Handle(ListAll request, CancellationToken ct) => Task.FromResult(new List()); + } + + public class Catalog + { + private readonly ISender _sender; + public Catalog(ISender sender) { _sender = sender; } + + public Task Order(int id) => _sender.Send(new GetById(id)); + public Task> Widgets(int page) => _sender.Send(new ListAll(page)); + } +} diff --git a/graph/test/csharp/cases/18-mediator-send/src/GreenSuite.cs b/graph/test/csharp/cases/18-mediator-send/src/GreenSuite.cs new file mode 100644 index 000000000..5a3e5cb3a --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/src/GreenSuite.cs @@ -0,0 +1,23 @@ +using System.Threading; +using System.Threading.Tasks; +using MediatR; + +namespace Shop +{ + public class GreenSuite + { + public record Ping(string Text) : IRequest; + public class PingHandler : IRequestHandler + { + public Task Handle(Ping request, CancellationToken ct) => Task.FromResult("green " + request.Text); + } + + private readonly IMediator _mediator; + public GreenSuite(IMediator mediator) { _mediator = mediator; } + public Task Run() + { + var ping = new Ping("y"); + return _mediator.Send(ping); + } + } +} diff --git a/graph/test/csharp/cases/18-mediator-send/src/Legacy.cs b/graph/test/csharp/cases/18-mediator-send/src/Legacy.cs new file mode 100644 index 000000000..ceb284174 --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/src/Legacy.cs @@ -0,0 +1,7 @@ +using MediatR; + +namespace Shop.Legacy +{ + // another request with the simple name GetOrder, in its own namespace and file + public record GetOrder(int Id) : IRequest; +} diff --git a/graph/test/csharp/cases/18-mediator-send/src/Mediator.cs b/graph/test/csharp/cases/18-mediator-send/src/Mediator.cs new file mode 100644 index 000000000..9d6174188 --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/src/Mediator.cs @@ -0,0 +1,52 @@ +// Stand-ins for the mediator package's contracts, with its names and shapes, so the +// Roslyn oracle can compile the case. tools/mediator-dispatch-test.sh also runs the case +// with this file removed, which is the shape a real project has: the package unstaged. +using System.Threading; +using System.Threading.Tasks; + +namespace MediatR +{ + public interface IBaseRequest { } + public interface IRequest : IBaseRequest { } + public interface IRequest : IBaseRequest { } + + public interface IRequestHandler where TRequest : IRequest + { + Task Handle(TRequest request, CancellationToken cancellationToken); + } + + public interface IRequestHandler where TRequest : IRequest + { + Task Handle(TRequest request, CancellationToken cancellationToken); + } + + public interface ISender + { + Task Send(IRequest request, CancellationToken cancellationToken = default); + Task Send(TRequest request, CancellationToken cancellationToken = default) where TRequest : IRequest; + Task Send(object request, CancellationToken cancellationToken = default); + } + + public delegate Task RequestHandlerDelegate(); + + public interface IPipelineBehavior where TRequest : notnull + { + Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken); + } + + public interface INotification { } + + public interface INotificationHandler where TNotification : INotification + { + Task Handle(TNotification notification, CancellationToken cancellationToken); + } + + public interface IPublisher + { + Task Publish(object notification, CancellationToken cancellationToken = default); + Task Publish(TNotification notification, CancellationToken cancellationToken = default) + where TNotification : INotification; + } + + public interface IMediator : ISender, IPublisher { } +} diff --git a/graph/test/csharp/cases/18-mediator-send/src/Notifications.cs b/graph/test/csharp/cases/18-mediator-send/src/Notifications.cs new file mode 100644 index 000000000..8270b2f0b --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/src/Notifications.cs @@ -0,0 +1,66 @@ +using System.Threading; +using System.Threading.Tasks; +using MediatR; + +namespace Shop +{ + // Publish runs EVERY handler for the notification's type, and INotificationHandler is + // contravariant: a handler of a base notification runs for each derived one too. + public record OrderEvent(int Id) : INotification; + public record OrderPlaced(int Id) : OrderEvent(Id); + public record OrderShipped(int Id) : OrderEvent(Id); + + public class EmailOnOrderPlaced : INotificationHandler + { + public Task Handle(OrderPlaced notification, CancellationToken ct) => Task.CompletedTask; + } + public class AuditOnOrderPlaced : INotificationHandler + { + public Task Handle(OrderPlaced notification, CancellationToken ct) => Task.CompletedTask; + } + // a handler of the BASE notification: every OrderEvent published reaches it + public class LogOrderEvent : INotificationHandler + { + public Task Handle(OrderEvent notification, CancellationToken ct) => Task.CompletedTask; + } + // CONTROL: a sibling of the published notification, handled, and nothing publishes it + public class ShipOnOrderShipped : INotificationHandler + { + public Task Handle(OrderShipped notification, CancellationToken ct) => Task.CompletedTask; + } + + // CONTROL: an unrelated notification, handled, and nothing publishes it + public record WidgetMoved(int Id) : INotification; + public class LogWidgetMoved : INotificationHandler + { + public Task Handle(WidgetMoved notification, CancellationToken ct) => Task.CompletedTask; + } + + public class OrderEvents + { + private readonly IPublisher _publisher; + private readonly IMediator _mediator; + public OrderEvents(IPublisher publisher, IMediator mediator) { _publisher = publisher; _mediator = mediator; } + + // the fan: both OrderPlaced handlers and the OrderEvent handler + public Task Place(int id) => _publisher.Publish(new OrderPlaced(id)); + + // through the mediator, and through a local + public Task PlaceAgain(int id) + { + var placed = new OrderPlaced(id); + return _mediator.Publish(placed); + } + + // declared as the base: every handler in the family, and not LogWidgetMoved + public Task Announce(OrderEvent notification) => _publisher.Publish(notification); + + // CONTROL: declared as the marker interface, which every notification implements; + // nothing is linked rather than every handler of the project + public Task Relay(INotification notification) => _publisher.Publish(notification); + + // CONTROL: a notification Sent is not Published; no notification handler runs (an + // open pipeline step is still linked: the engine does not check that it is a request) + public Task Misrouted(int id) => _mediator.Send(new OrderPlaced(id)); + } +} diff --git a/graph/test/csharp/cases/18-mediator-send/src/Requests.cs b/graph/test/csharp/cases/18-mediator-send/src/Requests.cs new file mode 100644 index 000000000..1151d0072 --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/src/Requests.cs @@ -0,0 +1,64 @@ +using System.Threading; +using System.Threading.Tasks; +using MediatR; + +namespace Shop +{ + public record OrderView(int Id, string State); + + public record GetOrder(int Id) : IRequest; + public class GetOrderHandler : IRequestHandler + { + public Task Handle(GetOrder request, CancellationToken ct) => + Task.FromResult(new OrderView(request.Id, "open")); + } + + // a request that answers nothing: IRequestHandler + public record CloseOrder(int Id) : IRequest; + public class CloseOrderHandler : IRequestHandler + { + public Task Handle(CloseOrder request, CancellationToken ct) => Task.CompletedTask; + } + + // CONTROL: the same response type as GetOrder, and nothing sends it + public record ArchiveOrder(int Id) : IRequest; + public class ArchiveOrderHandler : IRequestHandler + { + public Task Handle(ArchiveOrder request, CancellationToken ct) => + Task.FromResult(new OrderView(request.Id, "archived")); + } + + // CONTROL: a handler for ANOTHER GetOrder, named by a partly qualified argument + public class LegacyGetOrderHandler : IRequestHandler + { + public Task Handle(Legacy.GetOrder request, CancellationToken ct) => + Task.FromResult(new OrderView(request.Id, "legacy")); + } + + // one class handling two requests: each send reaches only the Handle for its type + public record CountWidgets(int Bin) : IRequest; + public record WeighWidgets(int Bin) : IRequest; + public class WidgetHandlers : IRequestHandler, IRequestHandler + { + public Task Handle(CountWidgets request, CancellationToken ct) => Task.FromResult(3); + public Task Handle(WeighWidgets request, CancellationToken ct) => Task.FromResult(1.5); + } + + // a request family: the container looks the handler up by the RUNTIME type + public record ShapeQuery(double Size) : IRequest; + public record CircleQuery(double R) : ShapeQuery(R); + public record SquareQuery(double S) : ShapeQuery(S); + public class ShapeQueryHandler : IRequestHandler + { + public Task Handle(ShapeQuery request, CancellationToken ct) => Task.FromResult("shape"); + } + public class CircleQueryHandler : IRequestHandler + { + public Task Handle(CircleQuery request, CancellationToken ct) => Task.FromResult("circle"); + } + // CONTROL: handled, and nothing sends it + public class SquareQueryHandler : IRequestHandler + { + public Task Handle(SquareQuery request, CancellationToken ct) => Task.FromResult("square"); + } +} diff --git a/graph/test/csharp/cases/18-mediator-send/src/Senders.cs b/graph/test/csharp/cases/18-mediator-send/src/Senders.cs new file mode 100644 index 000000000..c7f96fc14 --- /dev/null +++ b/graph/test/csharp/cases/18-mediator-send/src/Senders.cs @@ -0,0 +1,81 @@ +using System.Threading.Tasks; +using MediatR; + +namespace Shop +{ + // CONTROL: a receiver that is not a mediator, with a Send of the same shape + public interface IMailer { Task Send(object message); } + + public class OrdersController + { + private readonly IMediator _mediator; + private readonly IMailer _mailer; + public OrdersController(IMediator mediator, IMailer mailer) { _mediator = mediator; _mailer = mailer; } + + // Send(IRequest) with the request constructed at the call + public Task Get(int id) => _mediator.Send(new GetOrder(id)); + + // through a `var` local + public async Task GetLocal(int id) + { + var query = new GetOrder(id); + return await _mediator.Send(query); + } + + // Send(object): the local is declared `object`, its value is a GetOrder + public async Task GetUntyped(int id) + { + object request = new GetOrder(id); + return await _mediator.Send(request); + } + + // CONTROL: the same request handed to a mailer reaches no handler + public Task Notify(int id) => _mailer.Send(new GetOrder(id)); + } + + // a minimal-API endpoint takes its services as one object: the mediator is a property + public class OrderServices + { + public OrderServices(IMediator mediator) { Mediator = mediator; } + public IMediator Mediator { get; } + } + + public static class OrdersApi + { + public static Task GetOrder(OrderServices services, int id) => + services.Mediator.Send(new GetOrder(id)); + } + + // a mocking library's argument matcher, as a test writes it + public static class Arg { public static T Any() => default!; } + + // CONTROL: a test stubbing the mediator dispatches nothing + public class OrdersControllerTest + { + private readonly IMediator _mediator; + public OrdersControllerTest(IMediator mediator) { _mediator = mediator; } + public Task StubGet() => _mediator.Send(Arg.Any()); + } + + public class OrderCommands + { + private readonly ISender _sender; + public OrderCommands(ISender sender) { _sender = sender; } + + // Send(TRequest) where TRequest : IRequest, through ISender + public Task Close(int id) => _sender.Send(new CloseOrder(id)); + + // a class that handles two requests + public Task Count(int bin) => _sender.Send(new CountWidgets(bin)); + public Task Weigh(int bin) => _sender.Send(new WeighWidgets(bin)); + + // constructed at the call, so exactly that type: the base's handler, and a derived + // request's own handler and not its base's + public Task Shape(double size) => _sender.Send(new ShapeQuery(size)); + public Task Circle(double r) => _sender.Send(new CircleQuery(r)); + + // declared as the base, so at run time it may be a ShapeQuery, a CircleQuery or a + // SquareQuery: each of the three handlers, and no handler outside the family + public Task Any(ShapeQuery query) => _sender.Send(query); + } +} diff --git a/graph/test/csharp/engine-invariants.py b/graph/test/csharp/engine-invariants.py index 123db45e5..e9f95a96e 100755 --- a/graph/test/csharp/engine-invariants.py +++ b/graph/test/csharp/engine-invariants.py @@ -33,6 +33,7 @@ "known_edge", "multi_inferred", "fan_capped", "boundary_lib", "boundary_generated", "ambiguous_unknown", "ambiguous_dynamic", "known_implicit_ctor", "known_builtin_operator", "runtime_observed", + "event_dispatch", } # A predefined alias that denotes NO TYPE. Every other name in the engine's alias diff --git a/graph/test/csharp/run-tests.sh b/graph/test/csharp/run-tests.sh index ed6448a7e..fd6354543 100755 --- a/graph/test/csharp/run-tests.sh +++ b/graph/test/csharp/run-tests.sh @@ -102,8 +102,14 @@ if ! python3 "$HERE/corpus/aggregate-selftest.py"; then fi echo +# shellcheck source=../tools/case-pool.sh +. "$REPO/graph/test/tools/case-pool.sh" PASS=0; FAIL=0; FAILED="" -for dir in "$HERE"/cases/*/; do +# One case: the body of what was the case loop, unchanged, inside a one-case `for` so its +# `continue`s still mean "next case". pool_run (graph/test/tools/case-pool.sh) runs several +# at once and prints them in case order. +case_body() { +for dir in "$@"; do name="$(basename "$dir")" [ -d "$dir/src" ] || continue if [ -n "$ONLY" ]; then case ",$ONLY," in *,"$name",*) ;; *) continue ;; esac; fi @@ -170,6 +176,10 @@ EOF2 FAIL=$((FAIL+1)); FAILED="$FAILED $name" fi done +} +echo "running with up to $(pool_jobs) job(s) at once (AXIOM_SUITE_JOBS; 1 = one at a time)" +POOL_INTS="PASS FAIL" POOL_STRINGS="FAILED" +pool_run case_body "$HERE"/cases/*/ echo echo "cases: $PASS passed, $FAIL failed" diff --git a/graph/test/csharp/tools/mediator-dispatch-test.sh b/graph/test/csharp/tools/mediator-dispatch-test.sh new file mode 100755 index 000000000..98a086a77 --- /dev/null +++ b/graph/test/csharp/tools/mediator-dispatch-test.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# IN-PROCESS MEDIATOR DISPATCH (#1383): Send(request) -> the handler for its type, and +# Publish(notification) -> every handler for its type and its bases. +# +# cases/18-mediator-send is scored against Roslyn by run-tests.sh like every case, and +# that score cannot see this edge: it is not the site's own target, and the oracle binds +# Send to the mediator interface. So the edges are rendered by qualified name and diffed +# against cases/18-mediator-send/expected.dispatch, in two shapes: +# in-source the case as written, with the stand-in contracts in Mediator.cs +# unstaged the same case with Mediator.cs removed, which is what a real project has: +# the package is not in the source tree +# Both shapes also get cases/18-mediator-send/dispatch-only/, a send written partly +# qualified (`new Legacy.GetOrder(id)`) that the engine does not resolve as a type, which +# the Roslyn score would count against the case as a site resolved to nothing. +# Both shapes must give the same edges. The controls (a handler nothing sends, a generic +# request closed over another type, a Send on a receiver that is not a mediator, a +# notification Sent rather than Published or declared as the marker interface) are pinned +# by being absent from the golden. +# +# mediator-dispatch-test.sh [--bless] +# ───────────────────────────────────────────────────────────────────────────── +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(d="$HERE"; while [ "$d" != / ] && { [ ! -f "$d/package.json" ] || [ ! -d "$d/graph" ]; }; do d="$(dirname "$d")"; done; echo "$d")" +[ -f "$ROOT/parser/dist/index.js" ] || { echo "mediator-dispatch-test: SKIP (parser not built)"; exit 0; } +command -v souffle >/dev/null || { echo "mediator-dispatch-test: SKIP (no souffle)"; exit 0; } +CASE="$ROOT/graph/test/csharp/cases/18-mediator-send" +W="$(cd "$(mktemp -d)" && pwd -P)"; trap 'rm -rf "$W"' EXIT +mkdir -p "$W/in-source" "$W/unstaged" +cp "$CASE/src/"*.cs "$CASE/dispatch-only/"*.cs "$W/in-source/" +cp "$W/in-source/"*.cs "$W/unstaged/"; rm "$W/unstaged/Mediator.cs" +: > "$W/actual.dispatch" +for shape in in-source unstaged; do + src="$W/$shape" + node "$ROOT/parser/dist/index.js" "$src" "mediator-$shape" false "$W/out-$shape/ir" --per-language > "$W/$shape.parse.log" 2>&1 \ + || { echo " ✗ $shape: parser failed"; tail -3 "$W/$shape.parse.log"; exit 1; } + bash "$ROOT/graph/csharp/souffle/devrun.sh" "$W/out-$shape/ir" "$W/out-$shape/engine" > "$W/$shape.engine.log" 2>&1 \ + || { echo " ✗ $shape: engine failed"; grep -m3 '^Error' "$W/$shape.engine.log"; exit 1; } + python3 - "$W/out-$shape/ir/csharp" "$W/out-$shape/engine/out" "$shape" >> "$W/actual.dispatch" <<'PY' +import csv, os, sys +ir, out, shape = sys.argv[1:4] +# the signature too: one class may handle two requests, with one Handle for each +lbl = {r['csMethodUniqueHash']: r['qualifiedName'] + '(' + r['signature'] + ')' for r in csv.DictReader(open(os.path.join(ir, 'all-csharp-methods.csv'), newline='', encoding='utf-8'), delimiter='\t')} +rows = [r for r in csv.reader(open(os.path.join(out, 'call-chain-edges.csv'), newline='', encoding='utf-8'), delimiter='\t') if r] +print('\n'.join(sorted({f"{shape}\t{lbl.get(r[1], r[1])}\t{lbl.get(r[3], r[3])}" for r in rows if r[5] == 'event_dispatch'}))) +PY +done +if [ "${1:-}" = "--bless" ]; then cp "$W/actual.dispatch" "$CASE/expected.dispatch"; echo "mediator-dispatch-test: blessed ($(grep -c . "$CASE/expected.dispatch") rows)"; exit 0; fi +[ -f "$CASE/expected.dispatch" ] || { echo " ✗ no expected.dispatch (run with --bless)"; exit 1; } +a=$(grep -c '^in-source' "$CASE/expected.dispatch"); b=$(grep -c '^unstaged' "$CASE/expected.dispatch") +[ "$a" -ge 1 ] && [ "$b" -ge 1 ] || { echo " ✗ the golden has no edge in one of the shapes ($a in-source, $b unstaged)"; exit 1; } +[ "$(grep '^in-source' "$CASE/expected.dispatch" | cut -f2-)" = "$(grep '^unstaged' "$CASE/expected.dispatch" | cut -f2-)" ] \ + || { echo " ✗ the golden's two shapes disagree"; exit 1; } +if diff -q "$CASE/expected.dispatch" "$W/actual.dispatch" >/dev/null; then + echo "mediator-dispatch-test: ok ($a edges in each shape)" +else + echo " ✗ mediator dispatch edges changed"; diff -u "$CASE/expected.dispatch" "$W/actual.dispatch" | sed 's/^/ /' | head -40; exit 1 +fi diff --git a/graph/test/java/cases/61-var-local-type/src/probe/Bag.java b/graph/test/java/cases/61-var-local-type/src/probe/Bag.java new file mode 100644 index 000000000..dfcac1ec5 --- /dev/null +++ b/graph/test/java/cases/61-var-local-type/src/probe/Bag.java @@ -0,0 +1,11 @@ +package probe; + +import java.util.Iterator; + +/** A client generic that is iterable over its own type argument. */ +public class Bag implements Iterable { + @Override + public Iterator iterator() { + return null; + } +} diff --git a/graph/test/java/cases/61-var-local-type/src/probe/Gadget.java b/graph/test/java/cases/61-var-local-type/src/probe/Gadget.java new file mode 100644 index 000000000..6dc748e9c --- /dev/null +++ b/graph/test/java/cases/61-var-local-type/src/probe/Gadget.java @@ -0,0 +1,6 @@ +package probe; + +public class Gadget { + public void paint() { + } +} diff --git a/graph/test/java/cases/61-var-local-type/src/probe/VarLocals.java b/graph/test/java/cases/61-var-local-type/src/probe/VarLocals.java index bf5c79e01..195b9a1d3 100644 --- a/graph/test/java/cases/61-var-local-type/src/probe/VarLocals.java +++ b/graph/test/java/cases/61-var-local-type/src/probe/VarLocals.java @@ -4,6 +4,10 @@ import java.util.Collections; import java.util.List; import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.ToIntFunction; /** * `var` is a reserved type name (JLS 14.4), so a local declared with it never has a type called @@ -74,13 +78,71 @@ void fromEnhancedForDeclared(List ws) { } } - /** var resource in try-with-resources. */ + /** var over an array: the component type. */ + void fromArray(Widget[] ws) { + for (var x : ws) { + x.paint(); + } + } + + /** var over a client generic that implements Iterable: T as written on the declaration. */ + void fromClientIterable(Bag bag) { + for (var x : bag) { + x.paint(); + } + } + + /** var over a list whose type argument is a wildcard: its bound. */ + void fromWildcardList(List ws) { + for (var x : ws) { + x.paint(); + } + } + + /** + * control: a raw List has no element type. x is an Object, and Widget's hashCode must not be + * picked for it. + */ + @SuppressWarnings("rawtypes") + int fromRawList(List raw) { + int n = 0; + for (var x : raw) { + n += x.hashCode(); + } + return n; + } + + /** var resource in try-with-resources: the implicit close() at the end is a call. */ void fromResource() { try (var w = Widget.create()) { w.paint(); } } + /** control: the declared resource closes the same way. */ + void fromResourceDeclared() { + try (Widget w = Widget.create()) { + w.paint(); + } + } + + /** two resources, closed in reverse order: one close() each. */ + void twoResources() { + try (var a = new Widget(); var b = Widget.create()) { + a.paint(); + } + } + + /** + * control: a var resource whose initializer is an unstaged platform call. Its type is unknown, + * so there is no close() edge at all, and in particular not Widget's, the only client close(). + */ + long fromResourceUnknown() { + try (var s = java.util.stream.Stream.of(1)) { + return s.count(); + } + } + /** var from an unstaged platform call: the type is unknown, so the call stays unresolved. */ int fromUnknown() { var xs = Collections.emptyList(); @@ -107,4 +169,47 @@ Format source() { BiConsumer lambdaParams() { return (var a, var b) -> a.paint(); } + + /** the same with implicit parameters. */ + BiConsumer lambdaImplicit() { + return (a, b) -> b.paint(); + } + + /** a lambda assigned to a declared local. */ + void lambdaLocal(Widget w) { + Consumer c = x -> x.paint(); + c.accept(w); + } + + /** a lambda passed to a client method whose parameter is `Consumer`. */ + void lambdaArgument() { + each(x -> x.paint()); + } + + void each(Consumer c) { + } + + /** control: the second type argument feeds the second parameter, not the first. */ + BiFunction lambdaSecondArgument() { + return (a, b) -> { + b.paint(); + return a; + }; + } + + /** control: Function's second type argument is its result, never its parameter. */ + Function lambdaResultArgument() { + return g -> { + g.paint(); + return Widget.create(); + }; + } + + /** + * control: a functional interface the table does not list. Its parameter stays untyped, so + * the call on it is a declared unknown rather than a guess. + */ + ToIntFunction lambdaUnknownInterface() { + return x -> x.toString().length(); + } } diff --git a/graph/test/java/cases/61-var-local-type/src/probe/Widget.java b/graph/test/java/cases/61-var-local-type/src/probe/Widget.java index 11ded4520..3f908eb01 100644 --- a/graph/test/java/cases/61-var-local-type/src/probe/Widget.java +++ b/graph/test/java/cases/61-var-local-type/src/probe/Widget.java @@ -11,4 +11,9 @@ public void paint() { @Override public void close() { } + + @Override + public int hashCode() { + return 1; + } } diff --git a/graph/test/java/cases/65-boot-bean-registration/src/META-INF/services/probe.boot.Plugin b/graph/test/java/cases/65-boot-bean-registration/src/META-INF/services/probe.boot.Plugin new file mode 100644 index 000000000..cde12edf4 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/META-INF/services/probe.boot.Plugin @@ -0,0 +1 @@ +probe.boot.WidgetPlugin diff --git a/graph/test/java/cases/65-boot-bean-registration/src/META-INF/spring.factories b/graph/test/java/cases/65-boot-bean-registration/src/META-INF/spring.factories new file mode 100644 index 000000000..865f25a25 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/META-INF/spring.factories @@ -0,0 +1,4 @@ +# probe.boot.CommentedInitializer is named in a comment only +org.springframework.context.ApplicationContextInitializer=probe.boot.WidgetInitializer +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ + probe.boot.LegacyAutoConfiguration diff --git a/graph/test/java/cases/65-boot-bean-registration/src/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/graph/test/java/cases/65-boot-bean-registration/src/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..70f0bdb84 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +# probe.boot.CommentedAutoConfiguration +probe.boot.WidgetAutoConfiguration diff --git a/graph/test/java/cases/65-boot-bean-registration/src/application.properties b/graph/test/java/cases/65-boot-bean-registration/src/application.properties new file mode 100644 index 000000000..a26a94137 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/application.properties @@ -0,0 +1 @@ +widget.fallback-name=probe.boot.UnrelatedHandler diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/CommentedAutoConfiguration.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/CommentedAutoConfiguration.java new file mode 100644 index 000000000..f17ca19fb --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/CommentedAutoConfiguration.java @@ -0,0 +1,8 @@ +package probe.boot; + +/** CONTROL: named only in a comment line of the imports file. */ +public class CommentedAutoConfiguration { + public CommentedAutoConfiguration() { + WidgetWire.loose(); + } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/CommentedInitializer.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/CommentedInitializer.java new file mode 100644 index 000000000..427d910f1 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/CommentedInitializer.java @@ -0,0 +1,8 @@ +package probe.boot; + +/** CONTROL: named only in a comment line of spring.factories. */ +public class CommentedInitializer { + public void initialize() { + WidgetWire.loose(); + } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/LegacyAutoConfiguration.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/LegacyAutoConfiguration.java new file mode 100644 index 000000000..77fc21d44 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/LegacyAutoConfiguration.java @@ -0,0 +1,13 @@ +package probe.boot; + +import probe.service.Mailer; + +/** SUBJECT: a spring.factories EnableAutoConfiguration value on a continued line; its constructor is injected. */ +public class LegacyAutoConfiguration { + private final Mailer mailer; + + public LegacyAutoConfiguration(Mailer mailer) { + this.mailer = mailer; + WidgetWire.legacy(); + } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/Plugin.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/Plugin.java new file mode 100644 index 000000000..c9452c3bc --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/Plugin.java @@ -0,0 +1,5 @@ +package probe.boot; + +public interface Plugin { + void start(); +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/UnrelatedHandler.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/UnrelatedHandler.java new file mode 100644 index 000000000..dc0c27891 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/UnrelatedHandler.java @@ -0,0 +1,8 @@ +package probe.boot; + +/** CONTROL: named as a value in an unrelated properties file. */ +public class UnrelatedHandler { + public UnrelatedHandler() { + WidgetWire.loose(); + } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetAutoConfiguration.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetAutoConfiguration.java new file mode 100644 index 000000000..d6d95610c --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetAutoConfiguration.java @@ -0,0 +1,11 @@ +package probe.boot; + +import org.springframework.boot.autoconfigure.AutoConfiguration; + +/** SUBJECT: named in AutoConfiguration.imports, so a bean with its constructor reached. */ +@AutoConfiguration +public class WidgetAutoConfiguration { + public WidgetAutoConfiguration() { + WidgetWire.open(); + } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetInitializer.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetInitializer.java new file mode 100644 index 000000000..b8109119f --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetInitializer.java @@ -0,0 +1,12 @@ +package probe.boot; + +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; + +/** SUBJECT: a spring.factories initializer: registered and called, not a bean. */ +public class WidgetInitializer implements ApplicationContextInitializer { + @Override + public void initialize(ConfigurableApplicationContext ctx) { + WidgetWire.init(); + } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetPlugin.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetPlugin.java new file mode 100644 index 000000000..0f7294461 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetPlugin.java @@ -0,0 +1,11 @@ +package probe.boot; + +/** CONTROL: a META-INF/services provider, registered before this change. */ +public class WidgetPlugin implements Plugin { + public WidgetPlugin() { + WidgetWire.plug(); + } + + @Override + public void start() { } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetWire.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetWire.java new file mode 100644 index 000000000..caaef1782 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/boot/WidgetWire.java @@ -0,0 +1,9 @@ +package probe.boot; + +public class WidgetWire { + public static void open() { } + public static void legacy() { } + public static void init() { } + public static void plug() { } + public static void loose() { } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/config/CacheProperties.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/CacheProperties.java new file mode 100644 index 000000000..f153d1641 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/CacheProperties.java @@ -0,0 +1,15 @@ +package probe.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** CONTROL: a bean by stereotype before this change. */ +@Component +@ConfigurationProperties(prefix = "app.cache") +public class CacheProperties { + private int size; + + public int getSize() { return size; } + + public void setSize(int size) { this.size = size; } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/config/MailProperties.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/MailProperties.java new file mode 100644 index 000000000..e1f84822b --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/MailProperties.java @@ -0,0 +1,13 @@ +package probe.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** SUBJECT: registered by @EnableConfigurationProperties on PropsConfig. */ +@ConfigurationProperties(prefix = "app.mail") +public class MailProperties { + private String host; + + public String getHost() { return host; } + + public void setHost(String host) { this.host = host; } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/config/MapperConfig.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/MapperConfig.java new file mode 100644 index 000000000..50cace3e7 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/MapperConfig.java @@ -0,0 +1,8 @@ +package probe.config; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan("probe.mapper") +public class MapperConfig { } diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/config/PropsConfig.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/PropsConfig.java new file mode 100644 index 000000000..2b4f1bd20 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/PropsConfig.java @@ -0,0 +1,10 @@ +package probe.config; + +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties({MailProperties.class}) +@ConfigurationPropertiesScan("probe.scanned") +public class PropsConfig { } diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/config/QueueProperties.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/QueueProperties.java new file mode 100644 index 000000000..970570e3a --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/QueueProperties.java @@ -0,0 +1,13 @@ +package probe.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** CONTROL: registered by nothing, and outside the properties scan. Stays unsatisfied. */ +@ConfigurationProperties("app.queue") +public class QueueProperties { + private int depth; + + public int getDepth() { return depth; } + + public void setDepth(int depth) { this.depth = depth; } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/config/ShopMapperConfig.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/ShopMapperConfig.java new file mode 100644 index 000000000..dc6728747 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/config/ShopMapperConfig.java @@ -0,0 +1,8 @@ +package probe.config; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@MapperScan(basePackages = {"probe.**.dao"}) +public class ShopMapperConfig { } diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/convert/ItemConverter.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/convert/ItemConverter.java new file mode 100644 index 000000000..f9b4d4315 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/convert/ItemConverter.java @@ -0,0 +1,9 @@ +package probe.convert; + +import org.mapstruct.Mapper; + +/** CONTROL: MapStruct's @Mapper, not MyBatis's. Not a proxy bean. */ +@Mapper +public interface ItemConverter { + String convert(Long id); +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/dao/StockMapper.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/dao/StockMapper.java new file mode 100644 index 000000000..7ba6d3753 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/dao/StockMapper.java @@ -0,0 +1,9 @@ +package probe.dao; + +import org.apache.ibatis.annotations.Mapper; + +/** SUBJECT: @Mapper; also under the Ant pattern probe.**.dao. */ +@Mapper +public interface StockMapper { + int count(Long id); +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/daoextra/ExtraDao.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/daoextra/ExtraDao.java new file mode 100644 index 000000000..41d1d4966 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/daoextra/ExtraDao.java @@ -0,0 +1,6 @@ +package probe.daoextra; + +/** CONTROL: "probe.**.dao" does not cover the package probe.daoextra. */ +public interface ExtraDao { + int extra(Long id); +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/mapper/ItemMapper.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/mapper/ItemMapper.java new file mode 100644 index 000000000..2ddbef79d --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/mapper/ItemMapper.java @@ -0,0 +1,6 @@ +package probe.mapper; + +/** SUBJECT: no annotation, registered by @MapperScan("probe.mapper"). */ +public interface ItemMapper { + int touch(Long id); +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/other/TagMapper.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/other/TagMapper.java new file mode 100644 index 000000000..5623ddd5e --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/other/TagMapper.java @@ -0,0 +1,6 @@ +package probe.other; + +/** CONTROL: outside every @MapperScan package and not annotated. Stays unsatisfied. */ +public interface TagMapper { + int tag(Long id); +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/scanned/ScanProperties.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/scanned/ScanProperties.java new file mode 100644 index 000000000..e4e131293 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/scanned/ScanProperties.java @@ -0,0 +1,13 @@ +package probe.scanned; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** SUBJECT: picked up by @ConfigurationPropertiesScan("probe.scanned"). */ +@ConfigurationProperties("app.scan") +public class ScanProperties { + private int limit; + + public int getLimit() { return limit; } + + public void setLimit(int limit) { this.limit = limit; } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/service/ItemService.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/service/ItemService.java new file mode 100644 index 000000000..21ba81baa --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/service/ItemService.java @@ -0,0 +1,35 @@ +package probe.service; + +import org.springframework.stereotype.Service; +import probe.convert.ItemConverter; +import probe.dao.StockMapper; +import probe.daoextra.ExtraDao; +import probe.mapper.ItemMapper; +import probe.other.TagMapper; +import probe.shop.dao.OrderDao; + +@Service +public class ItemService { + private final ItemMapper items; + private final StockMapper stock; + private final OrderDao orders; + private final ExtraDao extra; + private final TagMapper tags; + private final ItemConverter converter; + private final PriceSource prices; + + public ItemService(ItemMapper items, StockMapper stock, OrderDao orders, ExtraDao extra, + TagMapper tags, ItemConverter converter, PriceSource prices) { + this.items = items; + this.stock = stock; + this.orders = orders; + this.extra = extra; + this.tags = tags; + this.converter = converter; + this.prices = prices; + } + + public int touch(Long id) { + return items.touch(id) + stock.count(id) + orders.open(id) + extra.extra(id) + tags.tag(id); + } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/service/Mailer.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/service/Mailer.java new file mode 100644 index 000000000..afb2b4f7c --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/service/Mailer.java @@ -0,0 +1,26 @@ +package probe.service; + +import org.springframework.stereotype.Service; +import probe.config.CacheProperties; +import probe.config.MailProperties; +import probe.config.QueueProperties; +import probe.scanned.ScanProperties; + +@Service +public class Mailer { + private final MailProperties mail; + private final CacheProperties cache; + private final QueueProperties queue; + private final ScanProperties scan; + + public Mailer(MailProperties mail, CacheProperties cache, QueueProperties queue, ScanProperties scan) { + this.mail = mail; + this.cache = cache; + this.queue = queue; + this.scan = scan; + } + + public String host() { return mail.getHost(); } + + public int size() { return cache.getSize() + queue.getDepth() + scan.getLimit(); } +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/service/PriceSource.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/service/PriceSource.java new file mode 100644 index 000000000..073ea0ab7 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/service/PriceSource.java @@ -0,0 +1,6 @@ +package probe.service; + +/** CONTROL: a plain interface with no implementation. Stays unsatisfied. */ +public interface PriceSource { + long price(Long id); +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/shop/dao/OrderDao.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/shop/dao/OrderDao.java new file mode 100644 index 000000000..28e289802 --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/shop/dao/OrderDao.java @@ -0,0 +1,6 @@ +package probe.shop.dao; + +/** SUBJECT: matched by @MapperScan(basePackages = "probe.**.dao") two segments down. */ +public interface OrderDao { + int open(Long id); +} diff --git a/graph/test/java/cases/65-boot-bean-registration/src/probe/web/ItemController.java b/graph/test/java/cases/65-boot-bean-registration/src/probe/web/ItemController.java new file mode 100644 index 000000000..0445c7e8f --- /dev/null +++ b/graph/test/java/cases/65-boot-bean-registration/src/probe/web/ItemController.java @@ -0,0 +1,14 @@ +package probe.web; + +import org.springframework.web.bind.annotation.RestController; +import probe.service.ItemService; + +/** CONTROL: a stereotype bean injected by type, known_bean before this change. */ +@RestController +public class ItemController { + private final ItemService service; + + public ItemController(ItemService service) { + this.service = service; + } +} diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/AllEvents.java b/graph/test/java/cases/65-spring-application-events/src/probe/AllEvents.java new file mode 100644 index 000000000..f02e9bcd8 --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/AllEvents.java @@ -0,0 +1,14 @@ +package probe; + +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +// a listener for the framework base type: every published event reaches it, an +// ApplicationEvent subtype (OrderShipped) as itself and a plain object (OrderPlaced) +// wrapped in a PayloadApplicationEvent +@Component +public class AllEvents implements ApplicationListener { + @Override + public void onApplicationEvent(ApplicationEvent event) { } +} diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/Audit.java b/graph/test/java/cases/65-spring-application-events/src/probe/Audit.java new file mode 100644 index 000000000..da8938d4b --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/Audit.java @@ -0,0 +1,34 @@ +package probe; + +import org.springframework.context.event.EventListener; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.stereotype.Component; + +@Component +public class Audit { + @EventListener + public void on(OrderPlaced e) { record(e.id); } + + @EventListener + public void onExpress(ExpressOrderPlaced e) { record(e.id); } + + @TransactionalEventListener + public void afterCommit(OrderPlaced e) { record("t"); } + + // the event type named on the annotation, no parameter + @EventListener(classes = OrderCancelled.class) + public void onCancelled() { record("c"); } + + // several types named on the annotation, in the array form + @EventListener({OrderPlaced.class, OrderCancelled.class}) + public void onEither() { record("either"); } + + // control: listens for a type nothing publishes + @EventListener + public void onWidget(WidgetBuilt w) { record("w"); } + + // control: the right parameter type, but not a listener + public void direct(OrderPlaced e) { record(e.id); } + + void record(String s) { } +} diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/EverythingLog.java b/graph/test/java/cases/65-spring-application-events/src/probe/EverythingLog.java new file mode 100644 index 000000000..ef05cecd8 --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/EverythingLog.java @@ -0,0 +1,11 @@ +package probe; + +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +// a listener for Object receives every published event, whatever its type +@Component +public class EverythingLog { + @EventListener + public void any(Object event) { } +} diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/ExpressOrderPlaced.java b/graph/test/java/cases/65-spring-application-events/src/probe/ExpressOrderPlaced.java new file mode 100644 index 000000000..e8829c60a --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/ExpressOrderPlaced.java @@ -0,0 +1,6 @@ +package probe; + +// a subtype: a listener for OrderPlaced receives it, a listener for this type does not receive a plain OrderPlaced +public class ExpressOrderPlaced extends OrderPlaced { + public ExpressOrderPlaced(String id) { super(id); } +} diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/LocalBus.java b/graph/test/java/cases/65-spring-application-events/src/probe/LocalBus.java new file mode 100644 index 000000000..eafdbb69e --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/LocalBus.java @@ -0,0 +1,9 @@ +package probe; + +import org.springframework.stereotype.Component; + +// control: a method called publishEvent on a project type; a call to it is an ordinary call +@Component +public class LocalBus { + public void publishEvent(Object event) { } +} diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/OrderCancelled.java b/graph/test/java/cases/65-spring-application-events/src/probe/OrderCancelled.java new file mode 100644 index 000000000..468e17681 --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/OrderCancelled.java @@ -0,0 +1,3 @@ +package probe; + +public class OrderCancelled { } diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/OrderPlaced.java b/graph/test/java/cases/65-spring-application-events/src/probe/OrderPlaced.java new file mode 100644 index 000000000..fefee7106 --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/OrderPlaced.java @@ -0,0 +1,6 @@ +package probe; + +public class OrderPlaced { + public final String id; + public OrderPlaced(String id) { this.id = id; } +} diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/OrderService.java b/graph/test/java/cases/65-spring-application-events/src/probe/OrderService.java new file mode 100644 index 000000000..e04d09cf1 --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/OrderService.java @@ -0,0 +1,42 @@ +package probe; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import java.util.List; + +@Service +public class OrderService { + private final ApplicationEventPublisher publisher; + private final ApplicationContext ctx; + private final Audit audit; + private final LocalBus bus; + + public OrderService(ApplicationEventPublisher publisher, ApplicationContext ctx, Audit audit, LocalBus bus) { + this.publisher = publisher; this.ctx = ctx; this.audit = audit; this.bus = bus; + } + + // a field receiver and a `new` argument + public void place() { publisher.publishEvent(new OrderPlaced("1")); } + + // a subtype of OrderPlaced: reaches the OrderPlaced listeners AND the ExpressOrderPlaced one + public void placeExpress() { publisher.publishEvent(new ExpressOrderPlaced("2")); } + + // the context as publisher, and a local carrying the event + public void ship() { + OrderShipped shipped = new OrderShipped(this); + ctx.publishEvent(shipped); + } + + // a parameter as receiver and as event + public void relay(ApplicationEventPublisher p, OrderCancelled e) { p.publishEvent(e); } + + // published from inside a lambda: attributed to the method the lambda is written in + public void placeAll(List ids) { ids.forEach(id -> publisher.publishEvent(new OrderPlaced(id))); } + + // control: a direct call, which resolved before this change + public void placeDirect() { audit.direct(new OrderPlaced("3")); } + + // control: a project class with its own publishEvent is not a Spring publisher + public void placeLocal() { bus.publishEvent(new OrderPlaced("4")); } +} diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/OrderShipped.java b/graph/test/java/cases/65-spring-application-events/src/probe/OrderShipped.java new file mode 100644 index 000000000..3a2df8ee7 --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/OrderShipped.java @@ -0,0 +1,7 @@ +package probe; + +import org.springframework.context.ApplicationEvent; + +public class OrderShipped extends ApplicationEvent { + public OrderShipped(Object source) { super(source); } +} diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/Refunds.java b/graph/test/java/cases/65-spring-application-events/src/probe/Refunds.java new file mode 100644 index 000000000..59680a993 --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/Refunds.java @@ -0,0 +1,16 @@ +package probe; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationEventPublisherAware; +import org.springframework.stereotype.Component; + +// the publisher handed over through ApplicationEventPublisherAware and kept in a field +@Component +public class Refunds implements ApplicationEventPublisherAware { + private ApplicationEventPublisher events; + + @Override + public void setApplicationEventPublisher(ApplicationEventPublisher events) { this.events = events; } + + public void refund() { events.publishEvent(new OrderCancelled()); } +} diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/ShipListener.java b/graph/test/java/cases/65-spring-application-events/src/probe/ShipListener.java new file mode 100644 index 000000000..c87bd1206 --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/ShipListener.java @@ -0,0 +1,12 @@ +package probe; + +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +@Component +public class ShipListener implements ApplicationListener { + @Override + public void onApplicationEvent(OrderShipped event) { notifyWarehouse(); } + + void notifyWarehouse() { } +} diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/WidgetBuilt.java b/graph/test/java/cases/65-spring-application-events/src/probe/WidgetBuilt.java new file mode 100644 index 000000000..9b629bee8 --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/WidgetBuilt.java @@ -0,0 +1,8 @@ +package probe; + +import org.springframework.context.ApplicationEvent; + +// an unrelated event type: nothing in this case publishes it +public class WidgetBuilt extends ApplicationEvent { + public WidgetBuilt(Object source) { super(source); } +} diff --git a/graph/test/java/cases/65-spring-application-events/src/probe/WidgetListener.java b/graph/test/java/cases/65-spring-application-events/src/probe/WidgetListener.java new file mode 100644 index 000000000..a37b021ff --- /dev/null +++ b/graph/test/java/cases/65-spring-application-events/src/probe/WidgetListener.java @@ -0,0 +1,11 @@ +package probe; + +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +// control: an ApplicationListener for a type nothing publishes +@Component +public class WidgetListener implements ApplicationListener { + @Override + public void onApplicationEvent(WidgetBuilt event) { } +} diff --git a/graph/test/java/expected/61-var-local-type.edges b/graph/test/java/expected/61-var-local-type.edges index dcbfc0084..dd0eb4c06 100644 --- a/graph/test/java/expected/61-var-local-type.edges +++ b/graph/test/java/expected/61-var-local-type.edges @@ -1,16 +1,22 @@ -ambiguous_unknown method probe.VarLocals#fromEnhancedFor(List) -> - ambiguous_unknown method probe.VarLocals#fromLiteral() -> - +ambiguous_unknown method probe.VarLocals#fromRawList(List) -> - +ambiguous_unknown method probe.VarLocals#fromResourceUnknown() -> - ambiguous_unknown method probe.VarLocals#fromUnknown() -> - -ambiguous_unknown method probe.VarLocals#lambdaParams() -> - +ambiguous_unknown method probe.VarLocals#lambdaUnknownInterface() -> - ambiguous_unknown method probe.VarLocals#literalInline() -> - boundary_lib method probe.VarLocals#declaredExternal(Object) -> external:java.text.Format.format boundary_lib method probe.VarLocals#fromLiteralDeclared() -> external:String.length +boundary_lib method probe.VarLocals#lambdaLocal(Widget) -> external:java.util.function.Consumer.accept boundary_lib method probe.VarLocals#varExternal(Object) -> external:java.text.Format.format +known_edge lambda_body probe.VarLocals#lambdaLocal(Widget) -> probe.Widget#paint() known_edge method probe.VarLocals#declaredExternal(Object) -> probe.VarLocals#source() +known_edge method probe.VarLocals#fromArray(Widget[]) -> probe.Widget#paint() known_edge method probe.VarLocals#fromCall(Registry) -> probe.Handler#handle() known_edge method probe.VarLocals#fromCall(Registry) -> probe.Registry#lookup(String) known_edge method probe.VarLocals#fromCallDeclared(Registry) -> probe.Handler#handle() known_edge method probe.VarLocals#fromCallDeclared(Registry) -> probe.Registry#lookup(String) +known_edge method probe.VarLocals#fromClientIterable(Bag) -> probe.Widget#paint() +known_edge method probe.VarLocals#fromEnhancedFor(List) -> probe.Widget#paint() known_edge method probe.VarLocals#fromEnhancedForDeclared(List) -> probe.Widget#paint() known_edge method probe.VarLocals#fromFactory() -> probe.Widget#create() known_edge method probe.VarLocals#fromFactory() -> probe.Widget#paint() @@ -19,7 +25,24 @@ known_edge method probe.VarLocals#fromNewReassigned() -> probe.Widget#create() known_edge method probe.VarLocals#fromNewReassigned() -> probe.Widget#paint() known_edge method probe.VarLocals#fromResource() -> probe.Widget#create() known_edge method probe.VarLocals#fromResource() -> probe.Widget#paint() +known_edge method probe.VarLocals#fromResourceDeclared() -> probe.Widget#create() +known_edge method probe.VarLocals#fromResourceDeclared() -> probe.Widget#paint() +known_edge method probe.VarLocals#fromWildcardList(List) -> probe.Widget#paint() +known_edge method probe.VarLocals#lambdaArgument() -> probe.VarLocals#each(Consumer) +known_edge method probe.VarLocals#lambdaArgument() -> probe.Widget#paint() +known_edge method probe.VarLocals#lambdaImplicit() -> probe.Widget#paint() +known_edge method probe.VarLocals#lambdaLocal(Widget) -> probe.Widget#paint() +known_edge method probe.VarLocals#lambdaParams() -> probe.Widget#paint() +known_edge method probe.VarLocals#lambdaResultArgument() -> probe.Gadget#paint() +known_edge method probe.VarLocals#lambdaResultArgument() -> probe.Widget#create() +known_edge method probe.VarLocals#lambdaSecondArgument() -> probe.Gadget#paint() +known_edge method probe.VarLocals#twoResources() -> probe.Widget#create() +known_edge method probe.VarLocals#twoResources() -> probe.Widget#paint() known_edge method probe.VarLocals#varExternal(Object) -> probe.VarLocals#source() known_edge new probe.VarLocals#fromNew() -> probe.Widget#() known_edge new probe.VarLocals#fromNewReassigned() -> probe.Widget#() +known_edge new probe.VarLocals#twoResources() -> probe.Widget#() known_edge new probe.Widget#create() -> probe.Widget#() +known_edge resource_close probe.VarLocals#fromResource() -> probe.Widget#close() +known_edge resource_close probe.VarLocals#fromResourceDeclared() -> probe.Widget#close() +known_edge resource_close probe.VarLocals#twoResources() -> probe.Widget#close() diff --git a/graph/test/java/expected/61-var-local-type.field-oracle b/graph/test/java/expected/61-var-local-type.field-oracle new file mode 100644 index 000000000..ee4c4947d --- /dev/null +++ b/graph/test/java/expected/61-var-local-type.field-oracle @@ -0,0 +1,7 @@ +61-var-local-type + precision 0.0000 (0 correct, 0 wrong) + recall 0.0000 (0 of 0 in the bytecode) + sites 4 resolved 0 (0.0%) + tiers ambiguous_unknown=4 + access read=4 + not scored: 0 initializer-owned, 0 in a class the build did not compile, 0 oracle rows in a static initializer, 0 reads of an inlined constant diff --git a/graph/test/java/expected/61-var-local-type.fields b/graph/test/java/expected/61-var-local-type.fields new file mode 100644 index 000000000..198374c0d --- /dev/null +++ b/graph/test/java/expected/61-var-local-type.fields @@ -0,0 +1,2 @@ +ambiguous_unknown read probe.VarLocals#fromResourceUnknown() -> - +ambiguous_unknown read probe.VarLocals#lambdaSecondArgument() -> - diff --git a/graph/test/java/expected/61-var-local-type.known-missing b/graph/test/java/expected/61-var-local-type.known-missing deleted file mode 100644 index 996b5ec5e..000000000 --- a/graph/test/java/expected/61-var-local-type.known-missing +++ /dev/null @@ -1,13 +0,0 @@ -# Accepted gap, measured by the bytecode oracle in CI: a `var` local whose type comes from something -# other than its initializer expression is left a declared unknown. Before #1401 each of these was a -# wrong `external:var.` edge; now the receiver is honestly untyped. Three shapes: -# - the element variable of an enhanced for loop (`for (var w : widgets)`): its type is the -# iterable's element type; -# - a try-with-resources variable (`try (var w = open())` where open() is not typed client-side); -# - `var` lambda parameters (`(var w) -> w.paint()`): the type comes from the target functional -# interface. -# Follow-up: type `var` in these three shapes. It cannot rot: when one starts resolving, this line -# reports NOW-FIXED and the case fails until it is removed. -probe.VarLocals#fromEnhancedFor -> probe.Widget#paint() -probe.VarLocals#fromResource -> probe.Widget#close() -probe.VarLocals#lambdaParams -> probe.Widget#paint() diff --git a/graph/test/java/expected/61-var-local-type.oracle b/graph/test/java/expected/61-var-local-type.oracle index b29608363..1ea4ecbcc 100644 --- a/graph/test/java/expected/61-var-local-type.oracle +++ b/graph/test/java/expected/61-var-local-type.oracle @@ -1,4 +1 @@ -oracle=20 engine=17 agree=17 missing=3 (known 3, NEW 0) extra=0 - known-missing probe.VarLocals#fromEnhancedFor -> probe.Widget#paint() - known-missing probe.VarLocals#fromResource -> probe.Widget#close() - known-missing probe.VarLocals#lambdaParams -> probe.Widget#paint() +oracle=37 engine=37 agree=37 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/java/expected/61-var-local-type.type-use b/graph/test/java/expected/61-var-local-type.type-use index e1b611ace..92e8e6d01 100644 --- a/graph/test/java/expected/61-var-local-type.type-use +++ b/graph/test/java/expected/61-var-local-type.type-use @@ -1,25 +1,52 @@ +ambiguous_unknown ANNOTATION_TYPE 0 probe.Bag [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.VarLocals [ANNOTATION] -> - ambiguous_unknown ANNOTATION_TYPE 0 probe.Widget [ANNOTATION] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 probe.Bag [TYPE] -> - ambiguous_unknown IMPLEMENTS_INTERFACE 0 probe.Widget [TYPE] -> - ambiguous_unknown LOCAL_VARIABLE 0 probe.VarLocals#declaredExternal(Object) [LOCAL_VARIABLE] -> - ambiguous_unknown LOCAL_VARIABLE 0 probe.VarLocals#fromLiteralDeclared() [LOCAL_VARIABLE] -> - +ambiguous_unknown LOCAL_VARIABLE 0 probe.VarLocals#lambdaLocal(Widget) [LOCAL_VARIABLE] -> - ambiguous_unknown METHOD_PARAM 0 probe.Registry#lookup(String) [METHOD_PARAM] -> - ambiguous_unknown METHOD_PARAM 0 probe.VarLocals#declaredExternal(Object) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.VarLocals#each(Consumer) [METHOD_PARAM] -> - ambiguous_unknown METHOD_PARAM 0 probe.VarLocals#fromEnhancedFor(List) [METHOD_PARAM] -> - ambiguous_unknown METHOD_PARAM 0 probe.VarLocals#fromEnhancedForDeclared(List) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.VarLocals#fromRawList(List) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.VarLocals#fromWildcardList(List) [METHOD_PARAM] -> - ambiguous_unknown METHOD_PARAM 0 probe.VarLocals#varExternal(Object) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_RETURN 0 probe.Bag#iterator() [METHOD] -> - ambiguous_unknown METHOD_RETURN 0 probe.VarLocals#declaredExternal(Object) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 probe.VarLocals#lambdaImplicit() [METHOD] -> - ambiguous_unknown METHOD_RETURN 0 probe.VarLocals#lambdaParams() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 probe.VarLocals#lambdaResultArgument() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 probe.VarLocals#lambdaSecondArgument() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 probe.VarLocals#lambdaUnknownInterface() [METHOD] -> - ambiguous_unknown METHOD_RETURN 0 probe.VarLocals#source() [METHOD] -> - ambiguous_unknown METHOD_RETURN 0 probe.VarLocals#varExternal(Object) [METHOD] -> - known_edge LOCAL_VARIABLE 0 probe.VarLocals#fromCallDeclared(Registry) [LOCAL_VARIABLE] -> probe.Handler known_edge LOCAL_VARIABLE 0 probe.VarLocals#fromEnhancedForDeclared(List) [LOCAL_VARIABLE] -> probe.Widget +known_edge LOCAL_VARIABLE 0 probe.VarLocals#fromResourceDeclared() [LOCAL_VARIABLE] -> probe.Widget +known_edge LOCAL_VARIABLE 1 probe.VarLocals#lambdaLocal(Widget) [LOCAL_VARIABLE] -> probe.Widget +known_edge METHOD_PARAM 0 probe.VarLocals#fromArray(Widget[]) [METHOD_PARAM] -> probe.Widget known_edge METHOD_PARAM 0 probe.VarLocals#fromCall(Registry) [METHOD_PARAM] -> probe.Registry known_edge METHOD_PARAM 0 probe.VarLocals#fromCallDeclared(Registry) [METHOD_PARAM] -> probe.Registry +known_edge METHOD_PARAM 0 probe.VarLocals#fromClientIterable(Bag) [METHOD_PARAM] -> probe.Bag +known_edge METHOD_PARAM 0 probe.VarLocals#lambdaLocal(Widget) [METHOD_PARAM] -> probe.Widget +known_edge METHOD_PARAM 1 probe.VarLocals#fromClientIterable(Bag) [METHOD_PARAM] -> probe.Widget known_edge METHOD_PARAM 1 probe.VarLocals#fromEnhancedFor(List) [METHOD_PARAM] -> probe.Widget known_edge METHOD_PARAM 1 probe.VarLocals#fromEnhancedForDeclared(List) [METHOD_PARAM] -> probe.Widget +known_edge METHOD_PARAM 2 probe.VarLocals#each(Consumer) [METHOD_PARAM] -> probe.Widget +known_edge METHOD_PARAM 2 probe.VarLocals#fromWildcardList(List) [METHOD_PARAM] -> probe.Widget known_edge METHOD_RETURN 0 probe.Registry#lookup(String) [METHOD] -> probe.Handler known_edge METHOD_RETURN 0 probe.Widget#create() [METHOD] -> probe.Widget +known_edge METHOD_RETURN 1 probe.VarLocals#lambdaImplicit() [METHOD] -> probe.Widget known_edge METHOD_RETURN 1 probe.VarLocals#lambdaParams() [METHOD] -> probe.Widget +known_edge METHOD_RETURN 1 probe.VarLocals#lambdaResultArgument() [METHOD] -> probe.Gadget +known_edge METHOD_RETURN 1 probe.VarLocals#lambdaResultArgument() [METHOD] -> probe.Widget +known_edge METHOD_RETURN 1 probe.VarLocals#lambdaSecondArgument() [METHOD] -> probe.Gadget +known_edge METHOD_RETURN 1 probe.VarLocals#lambdaSecondArgument() [METHOD] -> probe.Widget +known_edge METHOD_RETURN 1 probe.VarLocals#lambdaUnknownInterface() [METHOD] -> probe.Widget known_edge OBJECT_CREATION_TYPE 0 probe.VarLocals#fromNew() [EXPRESSION] -> probe.Widget known_edge OBJECT_CREATION_TYPE 0 probe.VarLocals#fromNewReassigned() [EXPRESSION] -> probe.Widget +known_edge OBJECT_CREATION_TYPE 0 probe.VarLocals#twoResources() [EXPRESSION] -> probe.Widget known_edge OBJECT_CREATION_TYPE 0 probe.Widget#create() [EXPRESSION] -> probe.Widget diff --git a/graph/test/java/expected/61-var-local-type.type-use-oracle b/graph/test/java/expected/61-var-local-type.type-use-oracle new file mode 100644 index 000000000..1e8ab296f --- /dev/null +++ b/graph/test/java/expected/61-var-local-type.type-use-oracle @@ -0,0 +1,9 @@ +61-var-local-type + precision 1.0000 (11 correct, 0 wrong) + recall 0.8462 (11 of 13 in the bytecode) + references 56 resolved 30 (53.6%) + tiers ambiguous_unknown=26 known_edge=30 + not scored: 0 in a context the bytecode does not carry, 0 in a class the build did not compile, 0 local in a class compiled without -g + contexts ANNOTATION_TYPE=4 IMPLEMENTS_INTERFACE=2 LOCAL_VARIABLE=7 METHOD_PARAM=18 METHOD_RETURN=21 OBJECT_CREATION_TYPE=4 + MISSING probe.VarLocals CAST_EXPRESSION probe.Widget + MISSING probe.VarLocals METHOD_PARAM probe.Gadget diff --git a/graph/test/java/expected/65-boot-bean-registration.config b/graph/test/java/expected/65-boot-bean-registration.config new file mode 100644 index 000000000..adf982dbf --- /dev/null +++ b/graph/test/java/expected/65-boot-bean-registration.config @@ -0,0 +1,90 @@ +── bean_def (14) ── + auto_configuration probe.boot.LegacyAutoConfiguration <- probe.boot.LegacyAutoConfiguration + auto_configuration probe.boot.WidgetAutoConfiguration <- probe.boot.WidgetAutoConfiguration + enabled_properties app.mail-probe.config.MailProperties <- probe.config.MailProperties + enabled_properties app.scan-probe.scanned.ScanProperties <- probe.scanned.ScanProperties + mapper_proxy itemMapper <- probe.mapper.ItemMapper + mapper_proxy orderDao <- probe.shop.dao.OrderDao + mapper_proxy stockMapper <- probe.dao.StockMapper + stereotype cacheProperties <- probe.config.CacheProperties + stereotype itemController <- probe.web.ItemController + stereotype itemService <- probe.service.ItemService + stereotype mailer <- probe.service.Mailer + stereotype mapperConfig <- probe.config.MapperConfig + stereotype propsConfig <- probe.config.PropsConfig + stereotype shopMapperConfig <- probe.config.ShopMapperConfig +── bean_origin (14) ── + client app.mail-probe.config.MailProperties <- probe.config.MailProperties + client app.scan-probe.scanned.ScanProperties <- probe.scanned.ScanProperties + client cacheProperties <- probe.config.CacheProperties + client itemController <- probe.web.ItemController + client itemMapper <- probe.mapper.ItemMapper + client itemService <- probe.service.ItemService + client mailer <- probe.service.Mailer + client mapperConfig <- probe.config.MapperConfig + client orderDao <- probe.shop.dao.OrderDao + client probe.boot.LegacyAutoConfiguration <- probe.boot.LegacyAutoConfiguration + client probe.boot.WidgetAutoConfiguration <- probe.boot.WidgetAutoConfiguration + client propsConfig <- probe.config.PropsConfig + client shopMapperConfig <- probe.config.ShopMapperConfig + client stockMapper <- probe.dao.StockMapper +── inject_point (13) ── + ctor_param probe.boot.LegacyAutoConfiguration#(Mailer):mailer : probe.service.Mailer + ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):converter : probe.convert.ItemConverter + ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):extra : probe.daoextra.ExtraDao + ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):items : probe.mapper.ItemMapper + ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):orders : probe.shop.dao.OrderDao + ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):prices : probe.service.PriceSource + ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):stock : probe.dao.StockMapper + ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):tags : probe.other.TagMapper + ctor_param probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties):cache : probe.config.CacheProperties + ctor_param probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties):mail : probe.config.MailProperties + ctor_param probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties):queue : probe.config.QueueProperties + ctor_param probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties):scan : probe.scanned.ScanProperties + ctor_param probe.web.ItemController#(ItemService):service : probe.service.ItemService +── di_edge (13) ── + known_bean ctor_param probe.boot.LegacyAutoConfiguration#(Mailer):mailer : probe.service.Mailer <- mailer (probe.service.Mailer) + known_bean ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):items : probe.mapper.ItemMapper <- itemMapper (probe.mapper.ItemMapper) + known_bean ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):orders : probe.shop.dao.OrderDao <- orderDao (probe.shop.dao.OrderDao) + known_bean ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):stock : probe.dao.StockMapper <- stockMapper (probe.dao.StockMapper) + known_bean ctor_param probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties):cache : probe.config.CacheProperties <- cacheProperties (probe.config.CacheProperties) + known_bean ctor_param probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties):mail : probe.config.MailProperties <- app.mail-probe.config.MailProperties (probe.config.MailProperties) + known_bean ctor_param probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties):scan : probe.scanned.ScanProperties <- app.scan-probe.scanned.ScanProperties (probe.scanned.ScanProperties) + known_bean ctor_param probe.web.ItemController#(ItemService):service : probe.service.ItemService <- itemService (probe.service.ItemService) + unsatisfied ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):converter : probe.convert.ItemConverter <- - (-) + unsatisfied ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):extra : probe.daoextra.ExtraDao <- - (-) + unsatisfied ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):prices : probe.service.PriceSource <- - (-) + unsatisfied ctor_param probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):tags : probe.other.TagMapper <- - (-) + unsatisfied ctor_param probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties):queue : probe.config.QueueProperties <- - (-) +── config_class_ref (2) ── + annotation @EnableConfigurationProperties "MailProperties" -> probe.config.MailProperties [client] + properties widget.fallback-name "probe.boot.UnrelatedHandler" -> probe.boot.UnrelatedHandler [client] +── config_key_ref (0) ── +── config_binding (1) ── + widget.fallback-name class_binding type probe.boot.UnrelatedHandler +── config_affects_method (1) ── + widget.fallback-name class_binding probe.boot.UnrelatedHandler#() +── config_entry_point (8) ── + auto_configuration probe.boot.LegacyAutoConfiguration#(Mailer) + auto_configuration probe.boot.WidgetAutoConfiguration#() + bean_ctor probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) + bean_ctor probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties) + bean_ctor probe.web.ItemController#(ItemService) + service_loader probe.boot.WidgetPlugin#() + service_loader probe.boot.WidgetPlugin#start() + spring_factories probe.boot.WidgetInitializer#initialize(ConfigurableApplicationContext) +── bean_condition (0) ── +── config_unresolved [DECLARED UNKNOWNS] (5) ── + unsatisfied_injection_point di probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):converter "probe.convert.ItemConverter" + unsatisfied_injection_point di probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):extra "probe.daoextra.ExtraDao" + unsatisfied_injection_point di probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):prices "probe.service.PriceSource" + unsatisfied_injection_point di probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource):tags "probe.other.TagMapper" + unsatisfied_injection_point di probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties):queue "probe.config.QueueProperties" +── remote_edge (0) ── +── remote_unserved [SENT, NO CONSUMER HERE] (0) ── +── remote_unsent [SERVED, NO PRODUCER HERE] (0) ── +── remote_undetermined [DECLARED UNKNOWNS] (0) ── +── persistence_query (0) ── +── persistence_entity (0) ── +── persistence_field (0) ── +── persistence_unresolved [DECLARED UNKNOWNS] (0) ── diff --git a/graph/test/java/expected/65-boot-bean-registration.edges b/graph/test/java/expected/65-boot-bean-registration.edges new file mode 100644 index 000000000..7a2855d07 --- /dev/null +++ b/graph/test/java/expected/65-boot-bean-registration.edges @@ -0,0 +1,16 @@ +known_edge method probe.boot.CommentedAutoConfiguration#() -> probe.boot.WidgetWire#loose() +known_edge method probe.boot.CommentedInitializer#initialize() -> probe.boot.WidgetWire#loose() +known_edge method probe.boot.LegacyAutoConfiguration#(Mailer) -> probe.boot.WidgetWire#legacy() +known_edge method probe.boot.UnrelatedHandler#() -> probe.boot.WidgetWire#loose() +known_edge method probe.boot.WidgetAutoConfiguration#() -> probe.boot.WidgetWire#open() +known_edge method probe.boot.WidgetInitializer#initialize(ConfigurableApplicationContext) -> probe.boot.WidgetWire#init() +known_edge method probe.boot.WidgetPlugin#() -> probe.boot.WidgetWire#plug() +known_edge method probe.service.ItemService#touch(Long) -> probe.dao.StockMapper#count(Long) +known_edge method probe.service.ItemService#touch(Long) -> probe.daoextra.ExtraDao#extra(Long) +known_edge method probe.service.ItemService#touch(Long) -> probe.mapper.ItemMapper#touch(Long) +known_edge method probe.service.ItemService#touch(Long) -> probe.other.TagMapper#tag(Long) +known_edge method probe.service.ItemService#touch(Long) -> probe.shop.dao.OrderDao#open(Long) +known_edge method probe.service.Mailer#host() -> probe.config.MailProperties#getHost() +known_edge method probe.service.Mailer#size() -> probe.config.CacheProperties#getSize() +known_edge method probe.service.Mailer#size() -> probe.config.QueueProperties#getDepth() +known_edge method probe.service.Mailer#size() -> probe.scanned.ScanProperties#getLimit() diff --git a/graph/test/java/expected/65-boot-bean-registration.envelope b/graph/test/java/expected/65-boot-bean-registration.envelope new file mode 100644 index 000000000..0c4a28e69 --- /dev/null +++ b/graph/test/java/expected/65-boot-bean-registration.envelope @@ -0,0 +1 @@ +nominal probe.boot.Plugin.start -> probe.boot.WidgetPlugin.start diff --git a/graph/test/java/expected/65-boot-bean-registration.fields b/graph/test/java/expected/65-boot-bean-registration.fields new file mode 100644 index 000000000..1b88854f7 --- /dev/null +++ b/graph/test/java/expected/65-boot-bean-registration.fields @@ -0,0 +1,30 @@ +known_edge read probe.config.CacheProperties#getSize() -> probe.config.CacheProperties#size +known_edge read probe.config.MailProperties#getHost() -> probe.config.MailProperties#host +known_edge read probe.config.QueueProperties#getDepth() -> probe.config.QueueProperties#depth +known_edge read probe.scanned.ScanProperties#getLimit() -> probe.scanned.ScanProperties#limit +known_edge read probe.service.ItemService#touch(Long) -> probe.service.ItemService#extra +known_edge read probe.service.ItemService#touch(Long) -> probe.service.ItemService#items +known_edge read probe.service.ItemService#touch(Long) -> probe.service.ItemService#orders +known_edge read probe.service.ItemService#touch(Long) -> probe.service.ItemService#stock +known_edge read probe.service.ItemService#touch(Long) -> probe.service.ItemService#tags +known_edge read probe.service.Mailer#host() -> probe.service.Mailer#mail +known_edge read probe.service.Mailer#size() -> probe.service.Mailer#cache +known_edge read probe.service.Mailer#size() -> probe.service.Mailer#queue +known_edge read probe.service.Mailer#size() -> probe.service.Mailer#scan +known_edge write probe.boot.LegacyAutoConfiguration#(Mailer) -> probe.boot.LegacyAutoConfiguration#mailer +known_edge write probe.config.CacheProperties#setSize(int) -> probe.config.CacheProperties#size +known_edge write probe.config.MailProperties#setHost(String) -> probe.config.MailProperties#host +known_edge write probe.config.QueueProperties#setDepth(int) -> probe.config.QueueProperties#depth +known_edge write probe.scanned.ScanProperties#setLimit(int) -> probe.scanned.ScanProperties#limit +known_edge write probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) -> probe.service.ItemService#converter +known_edge write probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) -> probe.service.ItemService#extra +known_edge write probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) -> probe.service.ItemService#items +known_edge write probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) -> probe.service.ItemService#orders +known_edge write probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) -> probe.service.ItemService#prices +known_edge write probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) -> probe.service.ItemService#stock +known_edge write probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) -> probe.service.ItemService#tags +known_edge write probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties) -> probe.service.Mailer#cache +known_edge write probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties) -> probe.service.Mailer#mail +known_edge write probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties) -> probe.service.Mailer#queue +known_edge write probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties) -> probe.service.Mailer#scan +known_edge write probe.web.ItemController#(ItemService) -> probe.web.ItemController#service diff --git a/graph/test/java/expected/65-boot-bean-registration.type-use b/graph/test/java/expected/65-boot-bean-registration.type-use new file mode 100644 index 000000000..f6f6159e1 --- /dev/null +++ b/graph/test/java/expected/65-boot-bean-registration.type-use @@ -0,0 +1,59 @@ +ambiguous_unknown ANNOTATION_TYPE 0 probe.boot.WidgetAutoConfiguration [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.boot.WidgetInitializer [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.boot.WidgetPlugin [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.config.CacheProperties [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.config.MailProperties [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.config.MapperConfig [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.config.PropsConfig [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.config.QueueProperties [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.config.ShopMapperConfig [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.convert.ItemConverter [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.dao.StockMapper [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.scanned.ScanProperties [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.service.ItemService [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.service.Mailer [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.web.ItemController [ANNOTATION] -> - +ambiguous_unknown FIELD_TYPE 0 probe.config.MailProperties [FIELD] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 probe.boot.WidgetInitializer [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 1 probe.boot.WidgetInitializer [TYPE] -> - +ambiguous_unknown METHOD_PARAM 0 probe.boot.WidgetInitializer#initialize(ConfigurableApplicationContext) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.config.MailProperties#setHost(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.convert.ItemConverter#convert(Long) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.dao.StockMapper#count(Long) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.daoextra.ExtraDao#extra(Long) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.mapper.ItemMapper#touch(Long) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.other.TagMapper#tag(Long) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.service.ItemService#touch(Long) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.service.PriceSource#price(Long) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.shop.dao.OrderDao#open(Long) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_RETURN 0 probe.config.MailProperties#getHost() [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 probe.convert.ItemConverter#convert(Long) [METHOD] -> - +ambiguous_unknown METHOD_RETURN 0 probe.service.Mailer#host() [METHOD] -> - +known_edge ANNOTATION_PARAM 0 probe.config.PropsConfig [ANNOTATION_ARGUMENT] -> probe.config.MailProperties +known_edge FIELD_TYPE 0 probe.boot.LegacyAutoConfiguration [FIELD] -> probe.service.Mailer +known_edge FIELD_TYPE 0 probe.service.ItemService [FIELD] -> probe.convert.ItemConverter +known_edge FIELD_TYPE 0 probe.service.ItemService [FIELD] -> probe.dao.StockMapper +known_edge FIELD_TYPE 0 probe.service.ItemService [FIELD] -> probe.daoextra.ExtraDao +known_edge FIELD_TYPE 0 probe.service.ItemService [FIELD] -> probe.mapper.ItemMapper +known_edge FIELD_TYPE 0 probe.service.ItemService [FIELD] -> probe.other.TagMapper +known_edge FIELD_TYPE 0 probe.service.ItemService [FIELD] -> probe.service.PriceSource +known_edge FIELD_TYPE 0 probe.service.ItemService [FIELD] -> probe.shop.dao.OrderDao +known_edge FIELD_TYPE 0 probe.service.Mailer [FIELD] -> probe.config.CacheProperties +known_edge FIELD_TYPE 0 probe.service.Mailer [FIELD] -> probe.config.MailProperties +known_edge FIELD_TYPE 0 probe.service.Mailer [FIELD] -> probe.config.QueueProperties +known_edge FIELD_TYPE 0 probe.service.Mailer [FIELD] -> probe.scanned.ScanProperties +known_edge FIELD_TYPE 0 probe.web.ItemController [FIELD] -> probe.service.ItemService +known_edge IMPLEMENTS_INTERFACE 0 probe.boot.WidgetPlugin [TYPE] -> probe.boot.Plugin +known_edge METHOD_PARAM 0 probe.boot.LegacyAutoConfiguration#(Mailer) [METHOD_PARAM] -> probe.service.Mailer +known_edge METHOD_PARAM 0 probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) [METHOD_PARAM] -> probe.convert.ItemConverter +known_edge METHOD_PARAM 0 probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) [METHOD_PARAM] -> probe.dao.StockMapper +known_edge METHOD_PARAM 0 probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) [METHOD_PARAM] -> probe.daoextra.ExtraDao +known_edge METHOD_PARAM 0 probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) [METHOD_PARAM] -> probe.mapper.ItemMapper +known_edge METHOD_PARAM 0 probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) [METHOD_PARAM] -> probe.other.TagMapper +known_edge METHOD_PARAM 0 probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) [METHOD_PARAM] -> probe.service.PriceSource +known_edge METHOD_PARAM 0 probe.service.ItemService#(ItemMapper,StockMapper,OrderDao,ExtraDao,TagMapper,ItemConverter,PriceSource) [METHOD_PARAM] -> probe.shop.dao.OrderDao +known_edge METHOD_PARAM 0 probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties) [METHOD_PARAM] -> probe.config.CacheProperties +known_edge METHOD_PARAM 0 probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties) [METHOD_PARAM] -> probe.config.MailProperties +known_edge METHOD_PARAM 0 probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties) [METHOD_PARAM] -> probe.config.QueueProperties +known_edge METHOD_PARAM 0 probe.service.Mailer#(MailProperties,CacheProperties,QueueProperties,ScanProperties) [METHOD_PARAM] -> probe.scanned.ScanProperties +known_edge METHOD_PARAM 0 probe.web.ItemController#(ItemService) [METHOD_PARAM] -> probe.service.ItemService diff --git a/graph/test/java/expected/65-spring-application-events.config b/graph/test/java/expected/65-spring-application-events.config new file mode 100644 index 000000000..9229e8d13 --- /dev/null +++ b/graph/test/java/expected/65-spring-application-events.config @@ -0,0 +1,52 @@ +── bean_def (8) ── + stereotype allEvents <- probe.AllEvents + stereotype audit <- probe.Audit + stereotype everythingLog <- probe.EverythingLog + stereotype localBus <- probe.LocalBus + stereotype orderService <- probe.OrderService + stereotype refunds <- probe.Refunds + stereotype shipListener <- probe.ShipListener + stereotype widgetListener <- probe.WidgetListener +── bean_origin (8) ── + client allEvents <- probe.AllEvents + client audit <- probe.Audit + client everythingLog <- probe.EverythingLog + client localBus <- probe.LocalBus + client orderService <- probe.OrderService + client refunds <- probe.Refunds + client shipListener <- probe.ShipListener + client widgetListener <- probe.WidgetListener +── inject_point (2) ── + ctor_param probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus):audit : probe.Audit + ctor_param probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus):bus : probe.LocalBus +── di_edge (2) ── + known_bean ctor_param probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus):audit : probe.Audit <- audit (probe.Audit) + known_bean ctor_param probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus):bus : probe.LocalBus <- localBus (probe.LocalBus) +── config_class_ref (2) ── + annotation @EventListener "OrderCancelled" -> probe.OrderCancelled [client] + annotation @EventListener "OrderPlaced" -> probe.OrderPlaced [client] +── config_key_ref (0) ── +── config_binding (0) ── +── config_affects_method (0) ── +── config_entry_point (11) ── + bean_ctor probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus) + queue probe.AllEvents#onApplicationEvent(ApplicationEvent) + queue probe.Audit#afterCommit(OrderPlaced) + queue probe.Audit#on(OrderPlaced) + queue probe.Audit#onCancelled() + queue probe.Audit#onEither() + queue probe.Audit#onExpress(ExpressOrderPlaced) + queue probe.Audit#onWidget(WidgetBuilt) + queue probe.EverythingLog#any(Object) + queue probe.ShipListener#onApplicationEvent(OrderShipped) + queue probe.WidgetListener#onApplicationEvent(WidgetBuilt) +── bean_condition (0) ── +── config_unresolved [DECLARED UNKNOWNS] (0) ── +── remote_edge (0) ── +── remote_unserved [SENT, NO CONSUMER HERE] (0) ── +── remote_unsent [SERVED, NO PRODUCER HERE] (0) ── +── remote_undetermined [DECLARED UNKNOWNS] (0) ── +── persistence_query (0) ── +── persistence_entity (0) ── +── persistence_field (0) ── +── persistence_unresolved [DECLARED UNKNOWNS] (0) ── diff --git a/graph/test/java/expected/65-spring-application-events.edges b/graph/test/java/expected/65-spring-application-events.edges new file mode 100644 index 000000000..80f98c4f8 --- /dev/null +++ b/graph/test/java/expected/65-spring-application-events.edges @@ -0,0 +1,54 @@ +ambiguous_unknown ctor_delegate probe.OrderShipped#(Object) -> - +ambiguous_unknown ctor_delegate probe.WidgetBuilt#(Object) -> - +boundary_lib method probe.OrderService#place() -> external:org.springframework.context.ApplicationEventPublisher.publishEvent +boundary_lib method probe.OrderService#placeAll(List) -> external:java.util.List.forEach +boundary_lib method probe.OrderService#placeAll(List) -> external:org.springframework.context.ApplicationEventPublisher.publishEvent +boundary_lib method probe.OrderService#placeExpress() -> external:org.springframework.context.ApplicationEventPublisher.publishEvent +boundary_lib method probe.OrderService#relay(ApplicationEventPublisher,OrderCancelled) -> external:org.springframework.context.ApplicationEventPublisher.publishEvent +boundary_lib method probe.OrderService#ship() -> external:org.springframework.context.ApplicationContext.publishEvent +boundary_lib method probe.Refunds#refund() -> external:org.springframework.context.ApplicationEventPublisher.publishEvent +event_dispatch event probe.OrderService#place() -> probe.AllEvents#onApplicationEvent(ApplicationEvent) +event_dispatch event probe.OrderService#place() -> probe.Audit#afterCommit(OrderPlaced) +event_dispatch event probe.OrderService#place() -> probe.Audit#on(OrderPlaced) +event_dispatch event probe.OrderService#place() -> probe.Audit#onEither() +event_dispatch event probe.OrderService#place() -> probe.EverythingLog#any(Object) +event_dispatch event probe.OrderService#placeAll(List) -> probe.AllEvents#onApplicationEvent(ApplicationEvent) +event_dispatch event probe.OrderService#placeAll(List) -> probe.Audit#afterCommit(OrderPlaced) +event_dispatch event probe.OrderService#placeAll(List) -> probe.Audit#on(OrderPlaced) +event_dispatch event probe.OrderService#placeAll(List) -> probe.Audit#onEither() +event_dispatch event probe.OrderService#placeAll(List) -> probe.EverythingLog#any(Object) +event_dispatch event probe.OrderService#placeExpress() -> probe.AllEvents#onApplicationEvent(ApplicationEvent) +event_dispatch event probe.OrderService#placeExpress() -> probe.Audit#afterCommit(OrderPlaced) +event_dispatch event probe.OrderService#placeExpress() -> probe.Audit#on(OrderPlaced) +event_dispatch event probe.OrderService#placeExpress() -> probe.Audit#onEither() +event_dispatch event probe.OrderService#placeExpress() -> probe.Audit#onExpress(ExpressOrderPlaced) +event_dispatch event probe.OrderService#placeExpress() -> probe.EverythingLog#any(Object) +event_dispatch event probe.OrderService#relay(ApplicationEventPublisher,OrderCancelled) -> probe.AllEvents#onApplicationEvent(ApplicationEvent) +event_dispatch event probe.OrderService#relay(ApplicationEventPublisher,OrderCancelled) -> probe.Audit#onCancelled() +event_dispatch event probe.OrderService#relay(ApplicationEventPublisher,OrderCancelled) -> probe.Audit#onEither() +event_dispatch event probe.OrderService#relay(ApplicationEventPublisher,OrderCancelled) -> probe.EverythingLog#any(Object) +event_dispatch event probe.OrderService#ship() -> probe.AllEvents#onApplicationEvent(ApplicationEvent) +event_dispatch event probe.OrderService#ship() -> probe.EverythingLog#any(Object) +event_dispatch event probe.OrderService#ship() -> probe.ShipListener#onApplicationEvent(OrderShipped) +event_dispatch event probe.Refunds#refund() -> probe.AllEvents#onApplicationEvent(ApplicationEvent) +event_dispatch event probe.Refunds#refund() -> probe.Audit#onCancelled() +event_dispatch event probe.Refunds#refund() -> probe.Audit#onEither() +event_dispatch event probe.Refunds#refund() -> probe.EverythingLog#any(Object) +known_edge ctor_delegate probe.ExpressOrderPlaced#(String) -> probe.OrderPlaced#(String) +known_edge method probe.Audit#afterCommit(OrderPlaced) -> probe.Audit#record(String) +known_edge method probe.Audit#direct(OrderPlaced) -> probe.Audit#record(String) +known_edge method probe.Audit#on(OrderPlaced) -> probe.Audit#record(String) +known_edge method probe.Audit#onCancelled() -> probe.Audit#record(String) +known_edge method probe.Audit#onEither() -> probe.Audit#record(String) +known_edge method probe.Audit#onExpress(ExpressOrderPlaced) -> probe.Audit#record(String) +known_edge method probe.Audit#onWidget(WidgetBuilt) -> probe.Audit#record(String) +known_edge method probe.OrderService#placeDirect() -> probe.Audit#direct(OrderPlaced) +known_edge method probe.OrderService#placeLocal() -> probe.LocalBus#publishEvent(Object) +known_edge method probe.ShipListener#onApplicationEvent(OrderShipped) -> probe.ShipListener#notifyWarehouse() +known_edge new probe.OrderService#place() -> probe.OrderPlaced#(String) +known_edge new probe.OrderService#placeAll(List) -> probe.OrderPlaced#(String) +known_edge new probe.OrderService#placeDirect() -> probe.OrderPlaced#(String) +known_edge new probe.OrderService#placeExpress() -> probe.ExpressOrderPlaced#(String) +known_edge new probe.OrderService#placeLocal() -> probe.OrderPlaced#(String) +known_edge new probe.OrderService#ship() -> probe.OrderShipped#(Object) +known_edge new probe.Refunds#refund() -> probe.OrderCancelled#() diff --git a/graph/test/java/expected/65-spring-application-events.fields b/graph/test/java/expected/65-spring-application-events.fields new file mode 100644 index 000000000..c98eef021 --- /dev/null +++ b/graph/test/java/expected/65-spring-application-events.fields @@ -0,0 +1,16 @@ +known_edge read probe.Audit#direct(OrderPlaced) -> probe.OrderPlaced#id +known_edge read probe.Audit#on(OrderPlaced) -> probe.OrderPlaced#id +known_edge read probe.Audit#onExpress(ExpressOrderPlaced) -> probe.OrderPlaced#id +known_edge read probe.OrderService#place() -> probe.OrderService#publisher +known_edge read probe.OrderService#placeAll(List) -> probe.OrderService#publisher +known_edge read probe.OrderService#placeDirect() -> probe.OrderService#audit +known_edge read probe.OrderService#placeExpress() -> probe.OrderService#publisher +known_edge read probe.OrderService#placeLocal() -> probe.OrderService#bus +known_edge read probe.OrderService#ship() -> probe.OrderService#ctx +known_edge read probe.Refunds#refund() -> probe.Refunds#events +known_edge write probe.OrderPlaced#(String) -> probe.OrderPlaced#id +known_edge write probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus) -> probe.OrderService#audit +known_edge write probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus) -> probe.OrderService#bus +known_edge write probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus) -> probe.OrderService#ctx +known_edge write probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus) -> probe.OrderService#publisher +known_edge write probe.Refunds#setApplicationEventPublisher(ApplicationEventPublisher) -> probe.Refunds#events diff --git a/graph/test/java/expected/65-spring-application-events.type-use b/graph/test/java/expected/65-spring-application-events.type-use new file mode 100644 index 000000000..89490ac63 --- /dev/null +++ b/graph/test/java/expected/65-spring-application-events.type-use @@ -0,0 +1,56 @@ +ambiguous_unknown ANNOTATION_TYPE 0 probe.AllEvents [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.Audit [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.EverythingLog [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.LocalBus [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.OrderService [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.Refunds [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.ShipListener [ANNOTATION] -> - +ambiguous_unknown ANNOTATION_TYPE 0 probe.WidgetListener [ANNOTATION] -> - +ambiguous_unknown FIELD_TYPE 0 probe.OrderPlaced [FIELD] -> - +ambiguous_unknown FIELD_TYPE 0 probe.OrderService [FIELD] -> - +ambiguous_unknown FIELD_TYPE 0 probe.Refunds [FIELD] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 probe.AllEvents [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 probe.Refunds [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 probe.ShipListener [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 0 probe.WidgetListener [TYPE] -> - +ambiguous_unknown IMPLEMENTS_INTERFACE 1 probe.AllEvents [TYPE] -> - +ambiguous_unknown METHOD_PARAM 0 probe.AllEvents#onApplicationEvent(ApplicationEvent) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.Audit#record(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.EverythingLog#any(Object) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.ExpressOrderPlaced#(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.LocalBus#publishEvent(Object) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.OrderPlaced#(String) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.OrderService#placeAll(List) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.OrderService#relay(ApplicationEventPublisher,OrderCancelled) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.OrderShipped#(Object) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.Refunds#setApplicationEventPublisher(ApplicationEventPublisher) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 probe.WidgetBuilt#(Object) [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 1 probe.OrderService#placeAll(List) [METHOD_PARAM] -> - +ambiguous_unknown SUPER_TYPE 0 probe.OrderShipped [TYPE] -> - +ambiguous_unknown SUPER_TYPE 0 probe.WidgetBuilt [TYPE] -> - +known_edge ANNOTATION_PARAM 0 probe.Audit [ANNOTATION_ARGUMENT] -> probe.OrderCancelled +known_edge ANNOTATION_PARAM 0 probe.Audit [ANNOTATION_ARGUMENT] -> probe.OrderPlaced +known_edge FIELD_TYPE 0 probe.OrderService [FIELD] -> probe.Audit +known_edge FIELD_TYPE 0 probe.OrderService [FIELD] -> probe.LocalBus +known_edge IMPLEMENTS_INTERFACE 1 probe.ShipListener [TYPE] -> probe.OrderShipped +known_edge IMPLEMENTS_INTERFACE 1 probe.WidgetListener [TYPE] -> probe.WidgetBuilt +known_edge LOCAL_VARIABLE 0 probe.OrderService#ship() [LOCAL_VARIABLE] -> probe.OrderShipped +known_edge METHOD_PARAM 0 probe.Audit#afterCommit(OrderPlaced) [METHOD_PARAM] -> probe.OrderPlaced +known_edge METHOD_PARAM 0 probe.Audit#direct(OrderPlaced) [METHOD_PARAM] -> probe.OrderPlaced +known_edge METHOD_PARAM 0 probe.Audit#on(OrderPlaced) [METHOD_PARAM] -> probe.OrderPlaced +known_edge METHOD_PARAM 0 probe.Audit#onExpress(ExpressOrderPlaced) [METHOD_PARAM] -> probe.ExpressOrderPlaced +known_edge METHOD_PARAM 0 probe.Audit#onWidget(WidgetBuilt) [METHOD_PARAM] -> probe.WidgetBuilt +known_edge METHOD_PARAM 0 probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus) [METHOD_PARAM] -> probe.Audit +known_edge METHOD_PARAM 0 probe.OrderService#(ApplicationEventPublisher,ApplicationContext,Audit,LocalBus) [METHOD_PARAM] -> probe.LocalBus +known_edge METHOD_PARAM 0 probe.OrderService#relay(ApplicationEventPublisher,OrderCancelled) [METHOD_PARAM] -> probe.OrderCancelled +known_edge METHOD_PARAM 0 probe.ShipListener#onApplicationEvent(OrderShipped) [METHOD_PARAM] -> probe.OrderShipped +known_edge METHOD_PARAM 0 probe.WidgetListener#onApplicationEvent(WidgetBuilt) [METHOD_PARAM] -> probe.WidgetBuilt +known_edge OBJECT_CREATION_TYPE 0 probe.OrderService#place() [EXPRESSION] -> probe.OrderPlaced +known_edge OBJECT_CREATION_TYPE 0 probe.OrderService#placeAll(List) [EXPRESSION] -> probe.OrderPlaced +known_edge OBJECT_CREATION_TYPE 0 probe.OrderService#placeDirect() [EXPRESSION] -> probe.OrderPlaced +known_edge OBJECT_CREATION_TYPE 0 probe.OrderService#placeExpress() [EXPRESSION] -> probe.ExpressOrderPlaced +known_edge OBJECT_CREATION_TYPE 0 probe.OrderService#placeLocal() [EXPRESSION] -> probe.OrderPlaced +known_edge OBJECT_CREATION_TYPE 0 probe.OrderService#ship() [EXPRESSION] -> probe.OrderShipped +known_edge OBJECT_CREATION_TYPE 0 probe.Refunds#refund() [EXPRESSION] -> probe.OrderCancelled +known_edge SUPER_TYPE 0 probe.ExpressOrderPlaced [TYPE] -> probe.OrderPlaced diff --git a/graph/test/java/run-tests.sh b/graph/test/java/run-tests.sh index b2556b431..964ecb5ec 100755 --- a/graph/test/java/run-tests.sh +++ b/graph/test/java/run-tests.sh @@ -39,6 +39,7 @@ # # Environment: # AXIOM_PARSER path to the parser entrypoint (default parser/dist/index.js — the parser in this repository) +# AXIOM_SUITE_JOBS cases run at once (default: the CPU count; 1 = one at a time, output uncaptured) # # NO EXTERNAL LIBRARY IR IS USED OR REQUIRED — see the note above the EMPTY_LIB line. A case # may however ship its own lib-src/ STUB library (kilobytes, in the repo), which is extracted @@ -157,6 +158,8 @@ PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" . "$HERE/tools/oracle-build.sh" ORACLE_FLAGS="" WORK="$HERE/.work" +# shellcheck source=../tools/case-pool.sh +. "$ROOT/graph/test/tools/case-pool.sh" BLESS=0; KEEP=0; ORACLE=0; NO_TORTURE=0; FILTERS=() for a in "$@"; do case "$a" in --bless) BLESS=1;; --keep) KEEP=1;; --oracle) ORACLE=1;; --no-torture) NO_TORTURE=1;; @@ -245,7 +248,11 @@ EMPTY_LIB="$WORK/.empty-library"; mkdir -p "$EMPTY_LIB"; LIB_ARG="$EMPTY_LIB" pass=0; fail=0; failed=() -for dir in "$HERE"/cases/*/; do +# One case: the body of what was the case loop, unchanged, inside a one-case `for` so its +# `continue`s still mean "next case". pool_run (graph/test/tools/case-pool.sh) runs several +# at once and prints them in case order. +case_body() { +for dir in "$@"; do name="$(basename "$dir")" if [ ${#FILTERS[@]} -gt 0 ]; then match=0; for f in "${FILTERS[@]}"; do [[ "$name" == *"$f"* ]] && match=1; done @@ -592,6 +599,10 @@ PYEOF fail=$((fail+1)); failed+=("$name") fi done +} +echo "running with up to $(pool_jobs) job(s) at once (AXIOM_SUITE_JOBS; 1 = one at a time)" +POOL_INTS="pass fail" POOL_ARRAYS="failed" +pool_run case_body "$HERE"/cases/*/ [ "$KEEP" = "1" ] || rm -rf "$WORK" # ── TORTURE: ten families of construct, scored per family ───────────────────────────────────── # The cases above each pin ONE rule. This asks what happens when a project uses everything at diff --git a/graph/test/javascript/cases/64-memoize-wrapper-result/src/alpha.js b/graph/test/javascript/cases/64-memoize-wrapper-result/src/alpha.js new file mode 100644 index 000000000..17e7413ab --- /dev/null +++ b/graph/test/javascript/cases/64-memoize-wrapper-result/src/alpha.js @@ -0,0 +1,5 @@ +class AlphaPlugin { + static getHooks(c) { return c; } + run() { return 1; } +} +module.exports = AlphaPlugin; diff --git a/graph/test/javascript/cases/64-memoize-wrapper-result/src/app.js b/graph/test/javascript/cases/64-memoize-wrapper-result/src/app.js new file mode 100644 index 000000000..d2ddb319e --- /dev/null +++ b/graph/test/javascript/cases/64-memoize-wrapper-result/src/app.js @@ -0,0 +1,37 @@ +const memoize = require("./memoize"); +const { once, lazy, constant, tap, Gamma } = require("./once"); +const AlphaPlugin = require("./alpha"); +const BetaPlugin = require("./beta"); + +// Two memoized getters of different modules: each call reaches its own module only. +const getAlpha = memoize(() => require("./alpha")); +const getBeta = memoize(() => require("./beta")); + +function hooks(c) { + getAlpha().getHooks(c); + getBeta().getHooks(c); +} + +// `once` and `lazy` over instances. +const firstAlpha = once(() => new AlphaPlugin()); +const makeBeta = lazy(() => new BetaPlugin()); +const firstBeta = once(() => new BetaPlugin()); +const makeAlpha = lazy(() => new AlphaPlugin()); + +function runAll() { + firstAlpha().run(); + makeBeta().run(); + firstBeta().run(); + makeAlpha().run(); +} + +// CONTROLS: a closure that returns its parameter; one that returns something else. +const keepBeta = constant(new BetaPlugin()); +const tapped = tap(() => new AlphaPlugin()); + +function controls() { + keepBeta().run(); + tapped().render(); +} + +module.exports = { hooks, runAll, controls, Gamma }; diff --git a/graph/test/javascript/cases/64-memoize-wrapper-result/src/beta.js b/graph/test/javascript/cases/64-memoize-wrapper-result/src/beta.js new file mode 100644 index 000000000..ce97020d5 --- /dev/null +++ b/graph/test/javascript/cases/64-memoize-wrapper-result/src/beta.js @@ -0,0 +1,5 @@ +class BetaPlugin { + static getHooks(c) { return c; } + run() { return 2; } +} +module.exports = BetaPlugin; diff --git a/graph/test/javascript/cases/64-memoize-wrapper-result/src/many.js b/graph/test/javascript/cases/64-memoize-wrapper-result/src/many.js new file mode 100644 index 000000000..59efb9828 --- /dev/null +++ b/graph/test/javascript/cases/64-memoize-wrapper-result/src/many.js @@ -0,0 +1,10 @@ +// Enough memoized getters to put `memoize` over the parameter fan cap. +const memoize = require("./memoize"); +const g01 = memoize(() => 1); const g02 = memoize(() => 2); const g03 = memoize(() => 3); +const g04 = memoize(() => 4); const g05 = memoize(() => 5); const g06 = memoize(() => 6); +const g07 = memoize(() => 7); const g08 = memoize(() => 8); const g09 = memoize(() => 9); +const g10 = memoize(() => 10); const g11 = memoize(() => 11); const g12 = memoize(() => 12); +const g13 = memoize(() => 13); const g14 = memoize(() => 14); const g15 = memoize(() => 15); +const g16 = memoize(() => 16); const g17 = memoize(() => 17); const g18 = memoize(() => 18); +const g19 = memoize(() => 19); const g20 = memoize(() => 20); const g21 = memoize(() => 21); +module.exports = [g01, g02, g03, g04, g05, g06, g07, g08, g09, g10, g11, g12, g13, g14, g15, g16, g17, g18, g19, g20, g21]; diff --git a/graph/test/javascript/cases/64-memoize-wrapper-result/src/memoize.js b/graph/test/javascript/cases/64-memoize-wrapper-result/src/memoize.js new file mode 100644 index 000000000..51d42e509 --- /dev/null +++ b/graph/test/javascript/cases/64-memoize-wrapper-result/src/memoize.js @@ -0,0 +1,13 @@ +// A memoizing wrapper: its closure returns what calling its parameter returned. +const memoize = fn => { + let cache = false; + let result = undefined; + return () => { + if (cache) return result; + result = fn(); + cache = true; + fn = undefined; + return result; + }; +}; +module.exports = memoize; diff --git a/graph/test/javascript/cases/64-memoize-wrapper-result/src/once.js b/graph/test/javascript/cases/64-memoize-wrapper-result/src/once.js new file mode 100644 index 000000000..274878547 --- /dev/null +++ b/graph/test/javascript/cases/64-memoize-wrapper-result/src/once.js @@ -0,0 +1,26 @@ +// `once`: a function expression returned, the result held in a variable. +function once(f) { + let done = false; + let value; + return function () { + if (!done) { + done = true; + value = f(); + } + return value; + }; +} +// `lazy`: the closure returns the parameter's call directly. +function lazy(make) { + return () => make(); +} +// CONTROL: a closure that returns its parameter itself, not a call of it. +function constant(v) { + return () => v; +} +// CONTROL: a closure that calls its parameter but returns something else. +function tap(g) { + return () => { g(); return new Gamma(); }; +} +class Gamma { render() { return 1; } } +module.exports = { once, lazy, constant, tap, Gamma }; diff --git a/graph/test/javascript/cases/64-shared-identity-writes/src/main.js b/graph/test/javascript/cases/64-shared-identity-writes/src/main.js new file mode 100644 index 000000000..dda2eda64 --- /dev/null +++ b/graph/test/javascript/cases/64-shared-identity-writes/src/main.js @@ -0,0 +1,77 @@ +// A write through a string is not a store (#732). A string is one identity per TEXT, +// so a write through one binding holding 'k' landed on every other expression holding +// 'k'; at runtime a property written onto a primitive is dropped. +function alpha() { return 'a'; } +function beta() { return 'b'; } + +const key = 'k'; +key.fn = beta; +function viaString() { return 'k'.fn(); } // nothing: a string has no such member +/** @param {string} s */ +function tagString(s) { s.fn = alpha; } +tagString('k'); +function viaStringParam() { return 'k'.fn(); } // nothing either + +// A JSDoc typedef is one identity for every value documented with it, so a DATA store +// through it wrote into every other value of that typedef. The objects that really +// flow in keep the store; the typedef keeps only what a call can be made on (a +// function, a class, an instance, an object literal), so both sides reach goEntry. +class Left { go() { return 'l'; } } +class Right { go() { return 'r'; } } +/** + * @typedef {Object} Entry + * @property {string} name + */ +/** @param {Entry} e */ +function putLeft(e) { e.side = new Left(); } +/** @param {Entry} e */ +function putRight(e) { e.side = new Right(); } +/** @param {Entry} e */ +function goEntry(e) { return e.side.go(); } // Left.go, and Right.go through the typedef +const first = { name: 'first' }; +putLeft(first); +function viaTypedef() { return goEntry(first); } +const second = { name: 'second' }; +putRight(second); + +// CONTROL: where the object behind a typedef is not tracked, the typedef is the only +// carrier of a handler stored on it, so a FUNCTION written through it still reads back. +/** + * @typedef {Object} Hooks + * @property {string} name + */ +/** @param {Hooks} hooks */ +function install(hooks) { hooks.onError = alpha; } +/** @param {Hooks} hooks */ +function report(hooks) { return hooks.onError(); } // alpha, through the typedef +// The same holds for an instance or an object literal whose methods are called later, +// a module namespace, and an array of handlers that is iterated. +class Logger { log() { return 'g'; } } +const util = require('./util'); +/** @param {Hooks} hooks */ +function installLogger(hooks) { hooks.logger = new Logger(); hooks.api = { fetch() { return 'f'; } }; } +/** @param {Hooks} hooks */ +function useLogger(hooks) { hooks.logger.log(); return hooks.api.fetch(); } // Logger.log, fetch +/** @param {Hooks} hooks */ +function installMore(hooks) { hooks.util = util; hooks.list = [alpha, beta]; hooks.label = 'x'; } +/** @param {Hooks} hooks */ +function useMore(hooks) { hooks.util.helper(); for (const f of hooks.list) f(); } // helper, alpha, beta +/** @param {Hooks} hooks */ +function useLabel(hooks) { return hooks.label.toUpperCase(); } // nothing: a string store through a typedef is dropped + +// The controls. An object literal and a class instance keep their writes, and a +// typedef's DECLARED members still read. +class Widget { render() { return 'w'; } } +/** + * @typedef {Object} Ctx + * @property {Widget} widget + */ +/** @param {Ctx} c */ +function draw(c) { return c.widget.render(); } // Widget.render, from the declaration +const holder = {}; +holder.run = alpha; +function viaLiteral() { return holder.run(); } // alpha +class Box { constructor() { this.run = beta; } } +function viaInstance() { return new Box().run(); } // beta +module.exports = { viaString, viaStringParam, viaTypedef, install, report, installLogger, useLogger, installMore, useMore, + useLabel, draw, viaLiteral, viaInstance }; diff --git a/graph/test/javascript/cases/64-shared-identity-writes/src/package.json b/graph/test/javascript/cases/64-shared-identity-writes/src/package.json new file mode 100644 index 000000000..4bd47bd99 --- /dev/null +++ b/graph/test/javascript/cases/64-shared-identity-writes/src/package.json @@ -0,0 +1 @@ +{ "name": "64-shared-identity-writes" } diff --git a/graph/test/javascript/cases/64-shared-identity-writes/src/util.js b/graph/test/javascript/cases/64-shared-identity-writes/src/util.js new file mode 100644 index 000000000..b216b5fd0 --- /dev/null +++ b/graph/test/javascript/cases/64-shared-identity-writes/src/util.js @@ -0,0 +1,3 @@ +'use strict'; +function helper() { return 'h'; } +module.exports = { helper }; diff --git a/graph/test/javascript/cases/64-value-callee/src/handlers.js b/graph/test/javascript/cases/64-value-callee/src/handlers.js new file mode 100644 index 000000000..9fa522427 --- /dev/null +++ b/graph/test/javascript/cases/64-value-callee/src/handlers.js @@ -0,0 +1,13 @@ +'use strict'; +const path = require('path'); +function alpha() { return 'a'; } +function runAll(handlers) { handlers.forEach(h => h()); } +function dispatch(table, k) { return table[k](); } +function invoke(cb) { return cb(); } +function runList(ctx) { for (const f of ctx.list) f(); } +function viaLocal(o) { const fn = o.pick; return fn(); } +function viaCall(cb) { return cb.call(null); } +function pure() { return 1 + 2; } +function joins() { return path.join('a', 'b'); } +function direct() { return alpha(); } +module.exports = { alpha, runAll, dispatch, invoke, runList, viaLocal, viaCall, pure, joins, direct }; diff --git a/graph/test/javascript/expected/11-class-members.diag b/graph/test/javascript/expected/11-class-members.diag index 3938fcda9..e80c1f58e 100644 --- a/graph/test/javascript/expected/11-class-members.diag +++ b/graph/test/javascript/expected/11-class-members.diag @@ -9,3 +9,4 @@ unresolved main.js:26:17 FUNCTION_CALL_BIND Holder no_target unresolved main.js:26:55 CONSTRUCTOR_CALL Bound callee_untyped unresolved main.js:26:55 METHOD_CALL go receiver_untyped unresolved main.js:7:24 FUNCTION_CALL String no_target +value_callee main.js:26:55 Bound local diff --git a/graph/test/javascript/expected/25-stream-hooks.diag b/graph/test/javascript/expected/25-stream-hooks.diag index 5cf6dc6a8..ca85b8d0c 100644 --- a/graph/test/javascript/expected/25-stream-hooks.diag +++ b/graph/test/javascript/expected/25-stream-hooks.diag @@ -21,3 +21,9 @@ unresolved streams.js:7:67 FUNCTION_CALL cb callee_untyped unresolved streams.js:7:99 FUNCTION_CALL cb callee_untyped unresolved streams.js:8:55 FUNCTION_CALL cb callee_untyped unresolved streams.js:8:73 METHOD_CALL push no_target +value_callee streams.js:5:120 cb parameter +value_callee streams.js:5:63 cb parameter +value_callee streams.js:5:92 cb parameter +value_callee streams.js:7:67 cb parameter +value_callee streams.js:7:99 cb parameter +value_callee streams.js:8:55 cb parameter diff --git a/graph/test/javascript/expected/27-parse-gaps.diag b/graph/test/javascript/expected/27-parse-gaps.diag index 39eff716c..18aec1be1 100644 --- a/graph/test/javascript/expected/27-parse-gaps.diag +++ b/graph/test/javascript/expected/27-parse-gaps.diag @@ -24,3 +24,4 @@ unresolved jsdoc.js:5:33 METHOD_CALL render receiver_type_unreadable unresolved jsdoc.js:5:33 METHOD_CALL render receiver_untyped unresolved jsdoc.js:8:30 METHOD_CALL render receiver_untyped unresolved partial.js:5:42 FUNCTION_CALL unknownThing callee_untyped +value_callee dynamic.js:3:29 eval dynamic_code diff --git a/graph/test/javascript/expected/32-symbol-keyed-members.diag b/graph/test/javascript/expected/32-symbol-keyed-members.diag index 604fb0bca..af10249bc 100644 --- a/graph/test/javascript/expected/32-symbol-keyed-members.diag +++ b/graph/test/javascript/expected/32-symbol-keyed-members.diag @@ -8,3 +8,5 @@ unresolved queue.js:4:15 FUNCTION_CALL Symbol no_target unresolved queue.js:5:17 METHOD_CALL for no_target unresolved symbols.js:2:28 FUNCTION_CALL Symbol no_target unresolved symbols.js:2:53 FUNCTION_CALL Symbol no_target +value_callee queue.js:33:54 q[Symbol] computed +value_callee queue.js:33:76 q[anySymbol] computed diff --git a/graph/test/javascript/expected/34-options-object-callbacks.diag b/graph/test/javascript/expected/34-options-object-callbacks.diag index 87834728c..f261bb398 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.diag +++ b/graph/test/javascript/expected/34-options-object-callbacks.diag @@ -10,3 +10,4 @@ unresolved main.js:12:86 METHOD_CALL request no_target unresolved main.js:13:29 CONSTRUCTOR_CALL Transform no_target unresolved main.js:8:38 FUNCTION_CALL cb callee_untyped unresolved main.js:9:42 METHOD_CALL map no_target +value_callee main.js:8:38 cb parameter diff --git a/graph/test/javascript/expected/34-options-object-callbacks.lib.diag b/graph/test/javascript/expected/34-options-object-callbacks.lib.diag index 785706d9d..99d2f7ba0 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.lib.diag +++ b/graph/test/javascript/expected/34-options-object-callbacks.lib.diag @@ -8,3 +8,4 @@ unresolved main.js:12:86 METHOD_CALL request no_target unresolved main.js:13:29 CONSTRUCTOR_CALL Transform no_target unresolved main.js:8:38 FUNCTION_CALL cb callee_untyped unresolved main.js:9:42 METHOD_CALL map no_target +value_callee main.js:8:38 cb parameter diff --git a/graph/test/javascript/expected/40-object-keys-strings.diag b/graph/test/javascript/expected/40-object-keys-strings.diag index 114e99542..e3e1b7004 100644 --- a/graph/test/javascript/expected/40-object-keys-strings.diag +++ b/graph/test/javascript/expected/40-object-keys-strings.diag @@ -20,3 +20,4 @@ unresolved main.js:26:42 METHOD_CALL toLowerCase no_target unresolved main.js:34:26 METHOD_CALL padStart no_target unresolved main.js:35:34 COMPUTED_CALL dynamic unresolved main.js:9:21 METHOD_CALL keys no_target +value_callee main.js:35:34 handlers[name] computed diff --git a/graph/test/javascript/expected/58-well-known-symbol.diag b/graph/test/javascript/expected/58-well-known-symbol.diag index 63a5cbad6..3588515bf 100644 --- a/graph/test/javascript/expected/58-well-known-symbol.diag +++ b/graph/test/javascript/expected/58-well-known-symbol.diag @@ -2,3 +2,5 @@ package_entry 58-well-known-symbol . [] DEFAULT_INDEX index.js MISSING_F unresolved main.js:15:14 FUNCTION_CALL Symbol no_target unresolved main.js:21:37 COMPUTED_CALL dynamic unresolved main.js:9:40 COMPUTED_CALL dynamic +value_callee main.js:21:37 b[Symbol[x]] computed +value_callee main.js:9:40 this.items[Symbol.iterator] computed diff --git a/graph/test/javascript/expected/61-dispatch-table-computed-key.diag b/graph/test/javascript/expected/61-dispatch-table-computed-key.diag index b42e3ce0d..cabfa5949 100644 --- a/graph/test/javascript/expected/61-dispatch-table-computed-key.diag +++ b/graph/test/javascript/expected/61-dispatch-table-computed-key.diag @@ -2,3 +2,4 @@ package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED unresolved main.js:10:23 FUNCTION_CALL_CALL hasOwnProperty no_target unresolved main.js:30:10 COMPUTED_CALL dynamic unresolved main.js:69:15 FUNCTION_CALL Number no_target +value_callee main.js:30:10 table[prop] computed diff --git a/graph/test/javascript/expected/64-memoize-wrapper-result.diag b/graph/test/javascript/expected/64-memoize-wrapper-result.diag new file mode 100644 index 000000000..f9fd9b4b2 --- /dev/null +++ b/graph/test/javascript/expected/64-memoize-wrapper-result.diag @@ -0,0 +1,4 @@ +package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - +unresolved memoize.js:7:14 FUNCTION_CALL fn callee_untyped +unresolved memoize.js:7:14 FUNCTION_CALL fn param_fan_capped +value_callee memoize.js:7:14 fn parameter diff --git a/graph/test/javascript/expected/64-memoize-wrapper-result.edges b/graph/test/javascript/expected/64-memoize-wrapper-result.edges new file mode 100644 index 000000000..68101987a --- /dev/null +++ b/graph/test/javascript/expected/64-memoize-wrapper-result.edges @@ -0,0 +1,86 @@ +app.js:11:3 FUNCTION_CALL getAlpha -> known_edge memoize.js:5:10 +app.js:11:3 METHOD_CALL getAlpha().getHooks -> known_edge alpha.js:2:3 getHooks +app.js:12:3 FUNCTION_CALL getBeta -> known_edge memoize.js:5:10 +app.js:12:3 METHOD_CALL getBeta().getHooks -> known_edge beta.js:2:3 getHooks +app.js:16:20 FUNCTION_CALL once -> callback_registered app.js:16:25 +app.js:16:20 FUNCTION_CALL once -> known_edge once.js:2:1 once +app.js:16:31 CONSTRUCTOR_CALL AlphaPlugin -> implicit_constructor - +app.js:17:18 FUNCTION_CALL lazy -> callback_registered app.js:17:23 +app.js:17:18 FUNCTION_CALL lazy -> known_edge once.js:14:1 lazy +app.js:17:29 CONSTRUCTOR_CALL BetaPlugin -> implicit_constructor - +app.js:18:19 FUNCTION_CALL once -> callback_registered app.js:18:24 +app.js:18:19 FUNCTION_CALL once -> known_edge once.js:2:1 once +app.js:18:30 CONSTRUCTOR_CALL BetaPlugin -> implicit_constructor - +app.js:19:19 FUNCTION_CALL lazy -> callback_registered app.js:19:24 +app.js:19:19 FUNCTION_CALL lazy -> known_edge once.js:14:1 lazy +app.js:19:30 CONSTRUCTOR_CALL AlphaPlugin -> implicit_constructor - +app.js:22:3 FUNCTION_CALL firstAlpha -> known_edge once.js:5:10 +app.js:22:3 METHOD_CALL firstAlpha().run -> known_edge alpha.js:3:3 run +app.js:23:3 FUNCTION_CALL makeBeta -> known_edge once.js:15:10 +app.js:23:3 METHOD_CALL makeBeta().run -> known_edge beta.js:3:3 run +app.js:24:3 FUNCTION_CALL firstBeta -> known_edge once.js:5:10 +app.js:24:3 METHOD_CALL firstBeta().run -> known_edge beta.js:3:3 run +app.js:25:3 FUNCTION_CALL makeAlpha -> known_edge once.js:15:10 +app.js:25:3 METHOD_CALL makeAlpha().run -> known_edge alpha.js:3:3 run +app.js:29:18 FUNCTION_CALL constant -> known_edge once.js:18:1 constant +app.js:29:27 CONSTRUCTOR_CALL BetaPlugin -> implicit_constructor - +app.js:30:16 FUNCTION_CALL tap -> callback_registered app.js:30:20 +app.js:30:16 FUNCTION_CALL tap -> known_edge once.js:22:1 tap +app.js:30:26 CONSTRUCTOR_CALL AlphaPlugin -> implicit_constructor - +app.js:33:3 FUNCTION_CALL keepBeta -> known_edge once.js:19:10 +app.js:33:3 METHOD_CALL keepBeta().run -> known_edge beta.js:3:3 run +app.js:34:3 FUNCTION_CALL tapped -> known_edge once.js:23:10 +app.js:34:3 METHOD_CALL tapped().render -> known_edge once.js:25:15 render +app.js:7:18 FUNCTION_CALL memoize -> callback_registered app.js:7:26 +app.js:7:18 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +app.js:8:17 FUNCTION_CALL memoize -> callback_registered app.js:8:25 +app.js:8:17 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:3:13 FUNCTION_CALL memoize -> callback_registered many.js:3:21 +many.js:3:13 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:3:43 FUNCTION_CALL memoize -> callback_registered many.js:3:51 +many.js:3:43 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:3:73 FUNCTION_CALL memoize -> callback_registered many.js:3:81 +many.js:3:73 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:4:13 FUNCTION_CALL memoize -> callback_registered many.js:4:21 +many.js:4:13 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:4:43 FUNCTION_CALL memoize -> callback_registered many.js:4:51 +many.js:4:43 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:4:73 FUNCTION_CALL memoize -> callback_registered many.js:4:81 +many.js:4:73 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:5:13 FUNCTION_CALL memoize -> callback_registered many.js:5:21 +many.js:5:13 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:5:43 FUNCTION_CALL memoize -> callback_registered many.js:5:51 +many.js:5:43 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:5:73 FUNCTION_CALL memoize -> callback_registered many.js:5:81 +many.js:5:73 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:6:13 FUNCTION_CALL memoize -> callback_registered many.js:6:21 +many.js:6:13 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:6:44 FUNCTION_CALL memoize -> callback_registered many.js:6:52 +many.js:6:44 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:6:75 FUNCTION_CALL memoize -> callback_registered many.js:6:83 +many.js:6:75 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:7:13 FUNCTION_CALL memoize -> callback_registered many.js:7:21 +many.js:7:13 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:7:44 FUNCTION_CALL memoize -> callback_registered many.js:7:52 +many.js:7:44 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:7:75 FUNCTION_CALL memoize -> callback_registered many.js:7:83 +many.js:7:75 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:8:13 FUNCTION_CALL memoize -> callback_registered many.js:8:21 +many.js:8:13 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:8:44 FUNCTION_CALL memoize -> callback_registered many.js:8:52 +many.js:8:44 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:8:75 FUNCTION_CALL memoize -> callback_registered many.js:8:83 +many.js:8:75 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:9:13 FUNCTION_CALL memoize -> callback_registered many.js:9:21 +many.js:9:13 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:9:44 FUNCTION_CALL memoize -> callback_registered many.js:9:52 +many.js:9:44 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +many.js:9:75 FUNCTION_CALL memoize -> callback_registered many.js:9:83 +many.js:9:75 FUNCTION_CALL memoize -> known_edge memoize.js:2:17 +memoize.js:7:14 FUNCTION_CALL fn -> ambiguous_unknown - +once.js:15:16 FUNCTION_CALL make -> multi_inferred app.js:17:23 +once.js:15:16 FUNCTION_CALL make -> multi_inferred app.js:19:24 +once.js:23:18 FUNCTION_CALL g -> known_edge app.js:30:20 +once.js:23:30 CONSTRUCTOR_CALL Gamma -> implicit_constructor - +once.js:8:15 FUNCTION_CALL f -> multi_inferred app.js:16:25 +once.js:8:15 FUNCTION_CALL f -> multi_inferred app.js:18:24 diff --git a/graph/test/javascript/expected/64-memoize-wrapper-result.oracle b/graph/test/javascript/expected/64-memoize-wrapper-result.oracle new file mode 100644 index 000000000..c64a7851d --- /dev/null +++ b/graph/test/javascript/expected/64-memoize-wrapper-result.oracle @@ -0,0 +1,38 @@ +app.js:16:20 FUNCTION_CALL once EXACT once.js:2:1 +app.js:16:31 CONSTRUCTOR_CALL AlphaPlugin SYNTHESIZED_OK +app.js:17:18 FUNCTION_CALL lazy EXACT once.js:14:1 +app.js:17:29 CONSTRUCTOR_CALL BetaPlugin SYNTHESIZED_OK +app.js:18:19 FUNCTION_CALL once EXACT once.js:2:1 +app.js:18:30 CONSTRUCTOR_CALL BetaPlugin SYNTHESIZED_OK +app.js:19:19 FUNCTION_CALL lazy EXACT once.js:14:1 +app.js:19:30 CONSTRUCTOR_CALL AlphaPlugin SYNTHESIZED_OK +app.js:29:18 FUNCTION_CALL constant EXACT once.js:18:1 +app.js:29:27 CONSTRUCTOR_CALL BetaPlugin SYNTHESIZED_OK +app.js:30:16 FUNCTION_CALL tap EXACT once.js:22:1 +app.js:30:26 CONSTRUCTOR_CALL AlphaPlugin SYNTHESIZED_OK +app.js:34:3 METHOD_CALL render EXACT once.js:25:15 +app.js:7:18 FUNCTION_CALL memoize EXACT memoize.js:2:17 +app.js:8:17 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:3:13 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:3:43 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:3:73 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:4:13 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:4:43 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:4:73 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:5:13 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:5:43 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:5:73 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:6:13 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:6:44 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:6:75 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:7:13 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:7:44 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:7:75 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:8:13 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:8:44 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:8:75 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:9:13 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:9:44 FUNCTION_CALL memoize EXACT memoize.js:2:17 +many.js:9:75 FUNCTION_CALL memoize EXACT memoize.js:2:17 +once.js:23:30 CONSTRUCTOR_CALL Gamma SYNTHESIZED_OK +# defects: 0 diff --git a/graph/test/javascript/expected/64-shared-identity-writes.diag b/graph/test/javascript/expected/64-shared-identity-writes.diag new file mode 100644 index 000000000..52cc811b1 --- /dev/null +++ b/graph/test/javascript/expected/64-shared-identity-writes.diag @@ -0,0 +1,4 @@ +package_entry 64-shared-identity-writes . [] DEFAULT_INDEX index.js MISSING_FILE -> - +unresolved main.js:13:36 METHOD_CALL fn no_target +unresolved main.js:60:35 METHOD_CALL toUpperCase receiver_untyped +unresolved main.js:9:31 METHOD_CALL fn no_target diff --git a/graph/test/javascript/expected/64-shared-identity-writes.edges b/graph/test/javascript/expected/64-shared-identity-writes.edges new file mode 100644 index 000000000..88d6f96a1 --- /dev/null +++ b/graph/test/javascript/expected/64-shared-identity-writes.edges @@ -0,0 +1,22 @@ +main.js:12:1 FUNCTION_CALL tagString -> known_edge main.js:11:1 tagString +main.js:13:36 METHOD_CALL 'k'.fn -> ambient_terminal - +main.js:26:32 CONSTRUCTOR_CALL Left -> implicit_constructor - +main.js:28:33 CONSTRUCTOR_CALL Right -> implicit_constructor - +main.js:30:30 METHOD_CALL e.side.go -> multi_inferred main.js:19:14 go +main.js:30:30 METHOD_CALL e.side.go -> multi_inferred main.js:20:15 go +main.js:32:1 FUNCTION_CALL putLeft -> known_edge main.js:26:1 putLeft +main.js:33:32 FUNCTION_CALL goEntry -> known_edge main.js:30:1 goEntry +main.js:35:1 FUNCTION_CALL putRight -> known_edge main.js:28:1 putRight +main.js:46:33 METHOD_CALL hooks.onError -> known_edge main.js:4:1 alpha +main.js:52:48 CONSTRUCTOR_CALL Logger -> implicit_constructor - +main.js:54:29 METHOD_CALL hooks.logger.log -> known_edge main.js:49:16 log +main.js:54:56 METHOD_CALL hooks.api.fetch -> known_edge main.js:52:76 fetch +main.js:58:27 METHOD_CALL hooks.util.helper -> known_edge util.js:2:1 helper +main.js:58:76 FUNCTION_CALL f -> multi_inferred main.js:4:1 alpha +main.js:58:76 FUNCTION_CALL f -> multi_inferred main.js:5:1 beta +main.js:60:35 METHOD_CALL hooks.label.toUpperCase -> ambiguous_unknown - +main.js:70:27 METHOD_CALL c.widget.render -> known_edge main.js:64:16 render +main.js:73:32 METHOD_CALL holder.run -> known_edge main.js:4:1 alpha +main.js:75:33 CONSTRUCTOR_CALL Box -> known_edge main.js:74:13 +main.js:75:33 METHOD_CALL new Box().run -> known_edge main.js:5:1 beta +main.js:9:31 METHOD_CALL 'k'.fn -> ambient_terminal - diff --git a/graph/test/javascript/expected/64-shared-identity-writes.oracle b/graph/test/javascript/expected/64-shared-identity-writes.oracle new file mode 100644 index 000000000..fa1e22836 --- /dev/null +++ b/graph/test/javascript/expected/64-shared-identity-writes.oracle @@ -0,0 +1,12 @@ +main.js:12:1 FUNCTION_CALL tagString EXACT main.js:11:1 +main.js:26:32 CONSTRUCTOR_CALL Left SYNTHESIZED_OK +main.js:28:33 CONSTRUCTOR_CALL Right SYNTHESIZED_OK +main.js:32:1 FUNCTION_CALL putLeft EXACT main.js:26:1 +main.js:33:32 FUNCTION_CALL goEntry EXACT main.js:30:1 +main.js:35:1 FUNCTION_CALL putRight EXACT main.js:28:1 +main.js:52:48 CONSTRUCTOR_CALL Logger SYNTHESIZED_OK +main.js:70:27 METHOD_CALL render EXACT main.js:64:16 +main.js:73:32 METHOD_CALL run EXACT main.js:4:1 +main.js:75:33 CONSTRUCTOR_CALL Box EXACT main.js:74:13 +main.js:75:33 METHOD_CALL run EXACT main.js:5:1 +# defects: 0 diff --git a/graph/test/javascript/expected/64-value-callee.diag b/graph/test/javascript/expected/64-value-callee.diag new file mode 100644 index 000000000..d392b7a25 --- /dev/null +++ b/graph/test/javascript/expected/64-value-callee.diag @@ -0,0 +1,16 @@ +import_cause handlers.js:2:14 path builtin +package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - +unresolved handlers.js:11:27 METHOD_CALL join no_target +unresolved handlers.js:4:29 METHOD_CALL forEach receiver_untyped +unresolved handlers.js:4:51 FUNCTION_CALL h callee_untyped +unresolved handlers.js:5:38 COMPUTED_CALL dynamic +unresolved handlers.js:6:30 FUNCTION_CALL cb callee_untyped +unresolved handlers.js:7:51 FUNCTION_CALL f callee_untyped +unresolved handlers.js:8:50 FUNCTION_CALL fn callee_untyped +unresolved handlers.js:9:31 FUNCTION_CALL_CALL cb callee_untyped +value_callee handlers.js:4:51 h parameter +value_callee handlers.js:5:38 table[k] computed +value_callee handlers.js:6:30 cb parameter +value_callee handlers.js:7:51 f loop_variable +value_callee handlers.js:8:50 fn local +value_callee handlers.js:9:31 cb.call parameter diff --git a/graph/test/javascript/expected/64-value-callee.edges b/graph/test/javascript/expected/64-value-callee.edges new file mode 100644 index 000000000..2044afa30 --- /dev/null +++ b/graph/test/javascript/expected/64-value-callee.edges @@ -0,0 +1,10 @@ +handlers.js:11:27 METHOD_CALL path.join -> ambient_terminal - +handlers.js:12:28 FUNCTION_CALL alpha -> known_edge handlers.js:3:1 alpha +handlers.js:4:29 METHOD_CALL handlers.forEach -> ambiguous_unknown - +handlers.js:4:29 METHOD_CALL handlers.forEach -> callback_registered handlers.js:4:46 +handlers.js:4:51 FUNCTION_CALL h -> ambiguous_unknown - +handlers.js:5:38 COMPUTED_CALL table[k] -> dynamic_terminal - +handlers.js:6:30 FUNCTION_CALL cb -> ambiguous_unknown - +handlers.js:7:51 FUNCTION_CALL f -> ambiguous_unknown - +handlers.js:8:50 FUNCTION_CALL fn -> ambiguous_unknown - +handlers.js:9:31 FUNCTION_CALL_CALL cb.call -> ambient_terminal - diff --git a/graph/test/javascript/expected/64-value-callee.oracle b/graph/test/javascript/expected/64-value-callee.oracle new file mode 100644 index 000000000..cc6bbd7a9 --- /dev/null +++ b/graph/test/javascript/expected/64-value-callee.oracle @@ -0,0 +1,2 @@ +handlers.js:12:28 FUNCTION_CALL alpha EXACT handlers.js:3:1 +# defects: 0 diff --git a/graph/test/javascript/run-tests.sh b/graph/test/javascript/run-tests.sh index e8f7b0967..ad3b83627 100755 --- a/graph/test/javascript/run-tests.sh +++ b/graph/test/javascript/run-tests.sh @@ -27,12 +27,15 @@ # # Environment: # AXIOM_PARSER path to the parser entrypoint (default parser/dist/index.js in this repository) +# AXIOM_SUITE_JOBS cases run at once (default: the CPU count; 1 = one at a time, output uncaptured) # ───────────────────────────────────────────────────────────────────────────── set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$HERE/../../.." && pwd)" PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" WORK="$HERE/.work" +# shellcheck source=../tools/case-pool.sh +. "$ROOT/graph/test/tools/case-pool.sh" BLESS=0; KEEP=0; ORACLE=0; FILTERS=() for a in "$@"; do case "$a" in --bless) BLESS=1;; --keep) KEEP=1;; --oracle) ORACLE=1;; @@ -76,7 +79,11 @@ oracle_check() { check_golden "$w/actual.oracle" "$golden" "oracle golden" } -for dir in "$HERE"/cases/*/; do +# One case: the body of what was the case loop, unchanged, inside a one-case `for` so its +# `continue`s still mean "next case". pool_run (graph/test/tools/case-pool.sh) runs several +# at once and prints them in case order. +case_body() { +for dir in "$@"; do name="$(basename "$dir")" if [ ${#FILTERS[@]} -gt 0 ]; then match=0; for f in "${FILTERS[@]}"; do [[ "$name" == *"$f"* ]] && match=1; done @@ -124,6 +131,10 @@ for dir in "$HERE"/cases/*/; do if [ $ok = 1 ]; then echo "ok"; pass=$((pass+1)); else fail=$((fail+1)); failed+=("$name"); fi [ "$KEEP" = "1" ] || rm -rf "$w" done +} +echo "running with up to $(pool_jobs) job(s) at once (AXIOM_SUITE_JOBS; 1 = one at a time)" +POOL_INTS="pass fail" POOL_ARRAYS="failed" +pool_run case_body "$HERE"/cases/*/ echo; echo "passed: $pass failed: $fail" [ $fail -eq 0 ] || { printf ' %s\n' "${failed[@]}"; exit 1; } # bin/axiomcode --library staging: every source-tree entry gets its own intermediate diff --git a/graph/test/javascript/tools/normalize_diagnostics.py b/graph/test/javascript/tools/normalize_diagnostics.py index 4114a29d7..242a8bfd5 100644 --- a/graph/test/javascript/tools/normalize_diagnostics.py +++ b/graph/test/javascript/tools/normalize_diagnostics.py @@ -85,6 +85,10 @@ def raw(path, width): for f in raw(os.path.join(out, 'unresolved-receiver.csv'), 4): pos, kind, _ = site.get(f[0], ('?', f[2], '?')) lines.add('unresolved %s %s %s %s' % (pos, kind, f[1], f[3])) +# unresolved_value_callee(CallExpr, Binding): the callee is a value, so the site may call anything (#1649) +for f in raw(os.path.join(out, 'unresolved-value-callee.csv'), 2): + pos, _, text = site.get(f[0], ('?', '?', '?')) + lines.add('value_callee %s %s %s' % (pos, text, f[1])) # package_entry(Prov, Package, Subpath, Condition, Source, Target, Outcome, ModuleHash) for f in raw(os.path.join(out, 'package-entry.csv'), 8): lines.add('package_entry %s %s [%s] %s %s %s -> %s' % ( diff --git a/graph/test/python/cases/30-fixture-visibility/src/app/__init__.py b/graph/test/python/cases/30-fixture-visibility/src/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/python/cases/30-fixture-visibility/src/app/svc.py b/graph/test/python/cases/30-fixture-visibility/src/app/svc.py new file mode 100644 index 000000000..6bf27ae5d --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/app/svc.py @@ -0,0 +1,46 @@ +"""Production code. Each function is reached from a test ONLY through one fixture body, +so which fixture a test is wired to decides which of these it reaches.""" + + +def make_repo(): + return "repo" + + +def make_service(): + return "service" + + +def make_cache(): + return "cache" + + +def make_client(): + return "client" + + +def make_user(name): + return {"name": name} + + +def make_widget(): + return "widget" + + +def make_orphan(): + return "orphan" + + +def make_helper(): + return "helper" + + +def make_hidden(): + return "hidden" + + +def module_account(): + return "module" + + +def class_account(): + return "class" diff --git a/graph/test/python/cases/30-fixture-visibility/src/conftest.py b/graph/test/python/cases/30-fixture-visibility/src/conftest.py new file mode 100644 index 000000000..59956a286 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/conftest.py @@ -0,0 +1,2 @@ +"""The root conftest, and the one place a `pytest_plugins` list is honoured.""" +pytest_plugins = ["testsupport.plugin"] diff --git a/graph/test/python/cases/30-fixture-visibility/src/other/__init__.py b/graph/test/python/cases/30-fixture-visibility/src/other/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/python/cases/30-fixture-visibility/src/other/conftest.py b/graph/test/python/cases/30-fixture-visibility/src/other/conftest.py new file mode 100644 index 000000000..aab7d61cf --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/other/conftest.py @@ -0,0 +1,3 @@ +"""A sibling directory's conftest, star-importing its own fixture module by its +ABSOLUTE name (tests/conftest.py uses the relative spelling).""" +from other.more_fixtures import * diff --git a/graph/test/python/cases/30-fixture-visibility/src/other/more_fixtures.py b/graph/test/python/cases/30-fixture-visibility/src/other/more_fixtures.py new file mode 100644 index 000000000..07b5de802 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/other/more_fixtures.py @@ -0,0 +1,8 @@ +import pytest + +from app.svc import make_widget + + +@pytest.fixture +def widget(): + return make_widget() diff --git a/graph/test/python/cases/30-fixture-visibility/src/other/test_other.py b/graph/test/python/cases/30-fixture-visibility/src/other/test_other.py new file mode 100644 index 000000000..fc796a332 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/other/test_other.py @@ -0,0 +1,8 @@ +def test_widget(widget): + """other/more_fixtures.py::widget, through this directory's conftest.""" + assert widget + + +def test_service_here(service): + """testsupport/plugin.py::service: a plugin fixture is visible outside tests/ too.""" + assert service diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/__init__.py b/graph/test/python/cases/30-fixture-visibility/src/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/conftest.py b/graph/test/python/cases/30-fixture-visibility/src/tests/conftest.py new file mode 100644 index 000000000..887e35943 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/conftest.py @@ -0,0 +1,28 @@ +"""Four ways a fixture reaches this directory's tests, and one way it does not. + + cache declared here + repo star-imported from .fixtures + client registered under the name given to the decorator; the def is + `client_fixture`, and a test asking for `client_fixture` gets nothing + user requested three ways by tests/test_params.py, one of which shadows it +""" +import pytest + +from app.svc import make_cache, make_client, make_user + +from .fixtures import * + + +@pytest.fixture +def cache(): + return make_cache() + + +@pytest.fixture(name="client") +def client_fixture(): + return make_client() + + +@pytest.fixture +def user(): + return make_user("default") diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/fixtures.py b/graph/test/python/cases/30-fixture-visibility/src/tests/fixtures.py new file mode 100644 index 000000000..487b895ef --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/fixtures.py @@ -0,0 +1,19 @@ +"""Fixtures that reach tests only because tests/conftest.py star-imports this module. + +The runner registers every public attribute of a conftest, and `from .fixtures import *` +makes `repo` one. `_hidden` is not re-exported by a star import (a leading underscore), +so it is not a conftest attribute and no test may reach it through the star. +""" +import pytest + +from app.svc import make_hidden, make_repo + + +@pytest.fixture +def repo(): + return make_repo() + + +@pytest.fixture +def _hidden(): + return make_hidden() diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/helpers.py b/graph/test/python/cases/30-fixture-visibility/src/tests/helpers.py new file mode 100644 index 000000000..25a1fb28e --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/helpers.py @@ -0,0 +1,10 @@ +"""Imported by NAME into one test module, which makes `helper_fx` an attribute of that +module and so a fixture the runner finds there, and only there.""" +import pytest + +from app.svc import make_helper + + +@pytest.fixture +def helper_fx(): + return make_helper() diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/test_accounts.py b/graph/test/python/cases/30-fixture-visibility/src/tests/test_accounts.py new file mode 100644 index 000000000..74ee5a01c --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/test_accounts.py @@ -0,0 +1,36 @@ +"""A fixture a class declares serves that class and its subclasses, and overrides the module's.""" +import pytest + +from app.svc import class_account, module_account + + +@pytest.fixture +def account(): + return module_account() + + +class TestOverride: + @pytest.fixture + def account(self): + return class_account() + + def test_in_class(self, account): + """TestOverride.account only: the class fixture shadows the module one.""" + assert account == "class" + + +class TestOverrideChild(TestOverride): + def test_in_subclass(self, account): + """TestOverride.account, inherited.""" + assert account == "class" + + +class TestPlain: + def test_no_override(self, account): + """the module fixture: a SIBLING class's fixture is not visible here.""" + assert account == "module" + + +def test_module_level(account): + """the module fixture.""" + assert account == "module" diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/test_explicit.py b/graph/test/python/cases/30-fixture-visibility/src/tests/test_explicit.py new file mode 100644 index 000000000..4e8ecf7b4 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/test_explicit.py @@ -0,0 +1,12 @@ +"""A fixture imported by name into the test module is a fixture of this module.""" +from .helpers import helper_fx + + +def test_helper(helper_fx): + """tests/helpers.py::helper_fx, through the import above.""" + assert helper_fx + + +def test_repo_here(repo): + """tests/fixtures.py::repo, through the conftest star import, as in test_svc.py.""" + assert repo diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/test_params.py b/graph/test/python/cases/30-fixture-visibility/src/tests/test_params.py new file mode 100644 index 000000000..1b7dafc3d --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/test_params.py @@ -0,0 +1,32 @@ +"""A DIRECT parametrize argname is a value the runner passes in, not a fixture request. +An indirect one goes through the fixture.""" +import pytest + + +@pytest.mark.parametrize("user", [{"name": "direct"}]) +def test_direct(user): + """NOTHING: `user` is supplied by the parametrize decorator.""" + assert user["name"] == "direct" + + +@pytest.mark.parametrize("user, cache", [({"name": "a"}, "c")]) +def test_direct_pair(user, cache): + """NOTHING for either: both argnames are parametrized directly.""" + assert user and cache + + +@pytest.mark.parametrize("user", ["other"], indirect=True) +def test_indirect(user): + """tests/conftest.py::user, which receives "other" as request.param.""" + assert user + + +@pytest.mark.parametrize("n", [1, 2]) +def test_other_argname(n, user): + """tests/conftest.py::user: only `n` is parametrized.""" + assert user and n + + +def test_plain(user): + """tests/conftest.py::user.""" + assert user diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/test_svc.py b/graph/test/python/cases/30-fixture-visibility/src/tests/test_svc.py new file mode 100644 index 000000000..7009ddbb3 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/test_svc.py @@ -0,0 +1,41 @@ +"""The requesters. Each test names one fixture; the comment says which def runs.""" + + +def test_repo(repo): + """tests/fixtures.py::repo, through the conftest's star import.""" + assert repo + + +def test_service(service): + """testsupport/plugin.py::service, through the root conftest's pytest_plugins.""" + assert service + + +def test_cache(cache): + """tests/conftest.py::cache, declared in the conftest. The control that always worked.""" + assert cache + + +def test_client(client): + """tests/conftest.py::client_fixture, registered as `client`.""" + assert client + + +def test_client_by_def_name(client_fixture): + """NOTHING: the fixture is called `client`, and `client_fixture` names no fixture.""" + assert client_fixture + + +def test_orphan(orphan): + """NOTHING: tests/unused_fixtures.py is never loaded.""" + assert orphan + + +def test_hidden(_hidden): + """NOTHING: a star import does not re-export a leading-underscore name.""" + assert _hidden + + +def test_widget(widget): + """NOTHING: `widget` is star-imported into other/conftest.py, a SIBLING directory.""" + assert widget diff --git a/graph/test/python/cases/30-fixture-visibility/src/tests/unused_fixtures.py b/graph/test/python/cases/30-fixture-visibility/src/tests/unused_fixtures.py new file mode 100644 index 000000000..58b1166f3 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/tests/unused_fixtures.py @@ -0,0 +1,14 @@ +"""A fixture module that NOTHING imports and no conftest names. + +`orphan` is declared with the fixture decorator and a test requests a parameter called +`orphan`, but the runner never loads this module, so the request fails. The name alone +is not visibility. +""" +import pytest + +from app.svc import make_orphan + + +@pytest.fixture +def orphan(): + return make_orphan() diff --git a/graph/test/python/cases/30-fixture-visibility/src/testsupport/__init__.py b/graph/test/python/cases/30-fixture-visibility/src/testsupport/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/python/cases/30-fixture-visibility/src/testsupport/plugin.py b/graph/test/python/cases/30-fixture-visibility/src/testsupport/plugin.py new file mode 100644 index 000000000..ce3972c56 --- /dev/null +++ b/graph/test/python/cases/30-fixture-visibility/src/testsupport/plugin.py @@ -0,0 +1,13 @@ +"""A plugin module, loaded because the root conftest lists it in `pytest_plugins`. + +A plugin's fixtures are registered for the whole session, so every test sees `service`, +including the one under other/, which is outside the conftest's own directory. +""" +import pytest + +from app.svc import make_service + + +@pytest.fixture +def service(): + return make_service() diff --git a/graph/test/python/cases/33-url-view-forms/src/catalog/__init__.py b/graph/test/python/cases/33-url-view-forms/src/catalog/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/python/cases/33-url-view-forms/src/catalog/urls.py b/graph/test/python/cases/33-url-view-forms/src/catalog/urls.py new file mode 100644 index 000000000..667d1895b --- /dev/null +++ b/graph/test/python/cases/33-url-view-forms/src/catalog/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from catalog import views + +urlpatterns = [ + path("", views.catalog_index), +] diff --git a/graph/test/python/cases/33-url-view-forms/src/catalog/views.py b/graph/test/python/cases/33-url-view-forms/src/catalog/views.py new file mode 100644 index 000000000..96e995c73 --- /dev/null +++ b/graph/test/python/cases/33-url-view-forms/src/catalog/views.py @@ -0,0 +1,2 @@ +def catalog_index(request): + return "catalog" diff --git a/graph/test/python/cases/33-url-view-forms/src/shop/__init__.py b/graph/test/python/cases/33-url-view-forms/src/shop/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/python/cases/33-url-view-forms/src/shop/decorators.py b/graph/test/python/cases/33-url-view-forms/src/shop/decorators.py new file mode 100644 index 000000000..5cbe25556 --- /dev/null +++ b/graph/test/python/cases/33-url-view-forms/src/shop/decorators.py @@ -0,0 +1,11 @@ +"""A decorator the project declares itself. It returns its inner wrapper, so the name of +every function it decorates denotes that one shared wrapper.""" +import functools + + +def audited(view): + @functools.wraps(view) + def wrapper(request, *args, **kwargs): + return view(request, *args, **kwargs) + + return wrapper diff --git a/graph/test/python/cases/33-url-view-forms/src/shop/urls.py b/graph/test/python/cases/33-url-view-forms/src/shop/urls.py new file mode 100644 index 000000000..0d8047b2e --- /dev/null +++ b/graph/test/python/cases/33-url-view-forms/src/shop/urls.py @@ -0,0 +1,43 @@ +from django.urls import include, path, re_path + +from shop import views +from shop.views import order_settings + +report_patterns = [ + path("report/", views.order_report), +] + +export_patterns = [ + path("export/", views.order_export), +] + +draft_patterns = [ + path("draft/", views.order_draft), +] + +status_patterns = [ + path("", views.api_status), +] + +api_patterns = [ + path("status/", include(status_patterns)), +] + +urlpatterns = [ + path("orders/", views.order_list), + path("orders/settings/", order_settings), + path("orders/history/", views.order_history), + path( + "orders/cancel/", + views.order_cancel, + name="order-cancel", + ), + path("orders/extra/", include(report_patterns)), + path("orders/more/", include((export_patterns, "exports"))), + path("widgets/", views.WidgetView.as_view()), + path("catalog/", include("catalog.urls")), + path("credit/", include([path("notes/", views.order_credit_note)])), + path("api/v1/", include(api_patterns)), + path("api/v2/", include(api_patterns)), + re_path(r"^legacy/(?P[0-9]+)/$", views.order_legacy), +] diff --git a/graph/test/python/cases/33-url-view-forms/src/shop/views.py b/graph/test/python/cases/33-url-view-forms/src/shop/views.py new file mode 100644 index 000000000..2b1cede2d --- /dev/null +++ b/graph/test/python/cases/33-url-view-forms/src/shop/views.py @@ -0,0 +1,88 @@ +"""Views in the forms a route table names them. Each routed one is dispatched by the +framework on a request; nothing in this project calls it.""" +from django.contrib.auth.decorators import login_required +from django.views import View +from django.views.decorators.http import require_POST + +from shop.decorators import audited + + +def order_list(request): + return _render("list") + + +@login_required +def order_settings(request): + return _render("settings") + + +@login_required +@require_POST +def order_cancel(request): + return _render("cancelled") + + +@audited +def order_history(request): + return _render("history") + + +def order_report(request): + return _render("report") + + +def order_export(request): + return _render("export") + + +def order_credit_note(request): + return _render("credit") + + +def api_status(request): + """Mounted twice, two tables down: served at /api/v1/status/ and /api/v2/status/.""" + return _render("status") + + +def order_legacy(request, pk): + """A regular-expression route: routed, but no literal path to name.""" + return _render("legacy") + + +def order_view_like(request): + """Named like a view, routed nowhere: not an entry point.""" + return _render("unrouted") + + +@login_required +def order_audit(request): + """Decorated like the routed views, routed nowhere: not an entry point.""" + return _render("audit") + + +def order_draft(request): + """Only in a list no table mounts: not an entry point.""" + return _render("draft") + + +class BaseWidgetView(View): + def get(self, request): + return _render("widgets") + + +class WidgetView(BaseWidgetView): + def post(self, request): + return _render("created") + + def summarise(self): + """Not an HTTP verb: the framework never dispatches to it.""" + return _render("summary") + + +class UnroutedView(View): + def get(self, request): + return _render("never") + + +def _render(key): + return key diff --git a/graph/test/python/expected/21-url-and-signal-dispatch.framework b/graph/test/python/expected/21-url-and-signal-dispatch.framework index ec3e4fde2..24cf8eba5 100644 --- a/graph/test/python/expected/21-url-and-signal-dispatch.framework +++ b/graph/test/python/expected/21-url-and-signal-dispatch.framework @@ -2,11 +2,11 @@ signal_dispatch registered main.place_order (main.py) -> main.on_order_connected (main.py) [signal] signal_dispatch registered main.place_order (main.py) -> main.on_order_placed (main.py) [signal] signal_dispatch registered ship.ship_order (ship.py) -> handlers.on_shipped (handlers.py) [signal] - url_dispatch registered main. (main.py) -> main.detail (main.py) [route_table] - url_dispatch registered main. (main.py) -> main.index (main.py) [route_table] + url_dispatch registered main. (main.py) -> main.detail (main.py) [/items//] + url_dispatch registered main. (main.py) -> main.index (main.py) [/] url_dispatch registered main. (main.py) -> main.legacy (main.py) [route_table] - url_dispatch registered urls. (urls.py) -> views.profile (views.py) [route_table] - url_dispatch registered urls. (urls.py) -> views.settings_page (views.py) [route_table] + url_dispatch registered urls. (urls.py) -> views.profile (views.py) [/profile//] + url_dispatch registered urls. (urls.py) -> views.settings_page (views.py) [/settings/] ── framework_unjoined (2) ── 2 signal_dispatch no_receiver ── remote_edge (0) ── diff --git a/graph/test/python/expected/30-fixture-visibility.edges b/graph/test/python/expected/30-fixture-visibility.edges new file mode 100644 index 000000000..cafafc473 --- /dev/null +++ b/graph/test/python/expected/30-fixture-visibility.edges @@ -0,0 +1,23 @@ +ambiguous_unknown DECORATOR_APPLICATION tests.conftest. -> - +ambiguous_unknown DECORATOR_APPLICATION tests.test_params. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE other.more_fixtures. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE tests.conftest. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE tests.fixtures. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE tests.helpers. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE tests.test_accounts. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE tests.test_accounts.TestOverride. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE tests.unused_fixtures. -> - +ambiguous_unknown DECORATOR_ATTRIBUTE testsupport.plugin. -> - +boundary_lib DECORATOR_CALL tests.conftest. -> external:pytest.fixture +boundary_lib DECORATOR_CALL tests.test_params. -> external:pytest.mark.parametrize +known_edge SIMPLE_CALL other.more_fixtures.widget -> app.svc.make_widget +known_edge SIMPLE_CALL tests.conftest.cache -> app.svc.make_cache +known_edge SIMPLE_CALL tests.conftest.client_fixture -> app.svc.make_client +known_edge SIMPLE_CALL tests.conftest.user -> app.svc.make_user +known_edge SIMPLE_CALL tests.fixtures._hidden -> app.svc.make_hidden +known_edge SIMPLE_CALL tests.fixtures.repo -> app.svc.make_repo +known_edge SIMPLE_CALL tests.helpers.helper_fx -> app.svc.make_helper +known_edge SIMPLE_CALL tests.test_accounts.TestOverride.account -> app.svc.class_account +known_edge SIMPLE_CALL tests.test_accounts.account -> app.svc.module_account +known_edge SIMPLE_CALL tests.unused_fixtures.orphan -> app.svc.make_orphan +known_edge SIMPLE_CALL testsupport.plugin.service -> app.svc.make_service diff --git a/graph/test/python/expected/30-fixture-visibility.entries b/graph/test/python/expected/30-fixture-visibility.entries new file mode 100644 index 000000000..5d5c2f148 --- /dev/null +++ b/graph/test/python/expected/30-fixture-visibility.entries @@ -0,0 +1,10 @@ +── entry_point (9) ── + fixture other.more_fixtures.widget other/more_fixtures.py:7 + fixture tests.conftest.cache tests/conftest.py:17 + fixture tests.conftest.client_fixture tests/conftest.py:22 + fixture tests.conftest.user tests/conftest.py:27 + fixture tests.fixtures.repo tests/fixtures.py:13 + fixture tests.helpers.helper_fx tests/helpers.py:9 + fixture tests.test_accounts.TestOverride.account tests/test_accounts.py:14 + fixture tests.test_accounts.account tests/test_accounts.py:8 + fixture testsupport.plugin.service testsupport/plugin.py:12 diff --git a/graph/test/python/expected/30-fixture-visibility.framework b/graph/test/python/expected/30-fixture-visibility.framework new file mode 100644 index 000000000..94e97b891 --- /dev/null +++ b/graph/test/python/expected/30-fixture-visibility.framework @@ -0,0 +1,20 @@ +── framework_edge (15) ── + fixture_injection by_name other.test_other.test_service_here (other/test_other.py) -> testsupport.plugin.service (testsupport/plugin.py) [service] + fixture_injection by_name other.test_other.test_widget (other/test_other.py) -> other.more_fixtures.widget (other/more_fixtures.py) [widget] + fixture_injection by_name tests.test_accounts.TestOverride.test_in_class (tests/test_accounts.py) -> tests.test_accounts.TestOverride.account (tests/test_accounts.py) [account] + fixture_injection by_name tests.test_accounts.TestOverrideChild.test_in_subclass (tests/test_accounts.py) -> tests.test_accounts.TestOverride.account (tests/test_accounts.py) [account] + fixture_injection by_name tests.test_accounts.TestPlain.test_no_override (tests/test_accounts.py) -> tests.test_accounts.account (tests/test_accounts.py) [account] + fixture_injection by_name tests.test_accounts.test_module_level (tests/test_accounts.py) -> tests.test_accounts.account (tests/test_accounts.py) [account] + fixture_injection by_name tests.test_explicit.test_helper (tests/test_explicit.py) -> tests.helpers.helper_fx (tests/helpers.py) [helper_fx] + fixture_injection by_name tests.test_explicit.test_repo_here (tests/test_explicit.py) -> tests.fixtures.repo (tests/fixtures.py) [repo] + fixture_injection by_name tests.test_params.test_indirect (tests/test_params.py) -> tests.conftest.user (tests/conftest.py) [user] + fixture_injection by_name tests.test_params.test_other_argname (tests/test_params.py) -> tests.conftest.user (tests/conftest.py) [user] + fixture_injection by_name tests.test_params.test_plain (tests/test_params.py) -> tests.conftest.user (tests/conftest.py) [user] + fixture_injection by_name tests.test_svc.test_cache (tests/test_svc.py) -> tests.conftest.cache (tests/conftest.py) [cache] + fixture_injection by_name tests.test_svc.test_client (tests/test_svc.py) -> tests.conftest.client_fixture (tests/conftest.py) [client] + fixture_injection by_name tests.test_svc.test_repo (tests/test_svc.py) -> tests.fixtures.repo (tests/fixtures.py) [repo] + fixture_injection by_name tests.test_svc.test_service (tests/test_svc.py) -> testsupport.plugin.service (testsupport/plugin.py) [service] +── framework_unjoined (0) ── +── remote_edge (0) ── +── remote_unserved (0) ── +── remote_unsent (0) ── diff --git a/graph/test/python/expected/30-fixture-visibility.tiers b/graph/test/python/expected/30-fixture-visibility.tiers new file mode 100644 index 000000000..ea812c21a --- /dev/null +++ b/graph/test/python/expected/30-fixture-visibility.tiers @@ -0,0 +1,28 @@ +distinct call sites emitted: 31 + +--- by tier: edge ROWS, and the distinct SITES they cover --- + 15 rows 15 sites ambiguous_unknown + 5 rows 5 sites boundary_lib + 11 rows 11 sites known_edge + +--- edge rows by call kind --- + 5 DECORATOR_APPLICATION + 10 DECORATOR_ATTRIBUTE + 5 DECORATOR_CALL + 11 SIMPLE_CALL + +--- unresolved reasons --- + 5 decorator_factory_result_untyped + 10 unmodelled_decorator + +--- the engine's own conservation ledger --- + 31 _total_sites + 15 ambiguous_unknown + 5 boundary_lib + 11 known_edge + +--- reconciling rows against the conserved site count --- + edge rows 31 + minus extra rows from multi-target sites 0 + = tier/site pairs 31 + engine's conserved site total 31 diff --git a/graph/test/python/expected/33-url-view-forms.edges b/graph/test/python/expected/33-url-view-forms.edges new file mode 100644 index 000000000..f5bb9f3f3 --- /dev/null +++ b/graph/test/python/expected/33-url-view-forms.edges @@ -0,0 +1,24 @@ +ambiguous_unknown DECORATOR_APPLICATION shop.decorators.audited -> - +ambiguous_unknown DECORATOR_BARE shop.views. -> - +ambiguous_unknown METHOD_CALL shop.urls. -> - +ambiguous_unknown SIMPLE_CALL catalog.urls. -> - +ambiguous_unknown SIMPLE_CALL shop.urls. -> - +boundary_lib DECORATOR_CALL shop.decorators.audited -> external:functools.wraps +known_edge DECORATOR_BARE shop.views. -> shop.decorators.audited +known_edge SIMPLE_CALL shop.decorators.audited..wrapper -> shop.views.order_history +known_edge SIMPLE_CALL shop.views.BaseWidgetView.get -> shop.views._render +known_edge SIMPLE_CALL shop.views.UnroutedView.get -> shop.views._render +known_edge SIMPLE_CALL shop.views.WidgetView.post -> shop.views._render +known_edge SIMPLE_CALL shop.views.WidgetView.summarise -> shop.views._render +known_edge SIMPLE_CALL shop.views.api_status -> shop.views._render +known_edge SIMPLE_CALL shop.views.order_audit -> shop.views._render +known_edge SIMPLE_CALL shop.views.order_cancel -> shop.views._render +known_edge SIMPLE_CALL shop.views.order_credit_note -> shop.views._render +known_edge SIMPLE_CALL shop.views.order_draft -> shop.views._render +known_edge SIMPLE_CALL shop.views.order_export -> shop.views._render +known_edge SIMPLE_CALL shop.views.order_history -> shop.views._render +known_edge SIMPLE_CALL shop.views.order_legacy -> shop.views._render +known_edge SIMPLE_CALL shop.views.order_list -> shop.views._render +known_edge SIMPLE_CALL shop.views.order_report -> shop.views._render +known_edge SIMPLE_CALL shop.views.order_settings -> shop.views._render +known_edge SIMPLE_CALL shop.views.order_view_like -> shop.views._render diff --git a/graph/test/python/expected/33-url-view-forms.entries b/graph/test/python/expected/33-url-view-forms.entries new file mode 100644 index 000000000..5e00ea76a --- /dev/null +++ b/graph/test/python/expected/33-url-view-forms.entries @@ -0,0 +1,13 @@ +── entry_point (12) ── + url catalog.views.catalog_index catalog/views.py:1 + url shop.views.BaseWidgetView.get shop/views.py:69 + url shop.views.WidgetView.post shop/views.py:74 + url shop.views.api_status shop/views.py:42 + url shop.views.order_cancel shop/views.py:21 + url shop.views.order_credit_note shop/views.py:38 + url shop.views.order_export shop/views.py:34 + url shop.views.order_history shop/views.py:26 + url shop.views.order_legacy shop/views.py:47 + url shop.views.order_list shop/views.py:10 + url shop.views.order_report shop/views.py:30 + url shop.views.order_settings shop/views.py:15 diff --git a/graph/test/python/expected/33-url-view-forms.framework b/graph/test/python/expected/33-url-view-forms.framework new file mode 100644 index 000000000..c266848e7 --- /dev/null +++ b/graph/test/python/expected/33-url-view-forms.framework @@ -0,0 +1,18 @@ +── framework_edge (13) ── + url_dispatch registered catalog.urls. (catalog/urls.py) -> catalog.views.catalog_index (catalog/views.py) [/] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.BaseWidgetView.get (shop/views.py) [/widgets/] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.WidgetView.post (shop/views.py) [/widgets/] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.api_status (shop/views.py) [/api/v1/status/] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.api_status (shop/views.py) [/api/v2/status/] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.order_cancel (shop/views.py) [/orders/cancel/] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.order_credit_note (shop/views.py) [/credit/notes/] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.order_export (shop/views.py) [/orders/more/export/] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.order_history (shop/views.py) [/orders/history/] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.order_legacy (shop/views.py) [route_table] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.order_list (shop/views.py) [/orders/] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.order_report (shop/views.py) [/orders/extra/report/] + url_dispatch registered shop.urls. (shop/urls.py) -> shop.views.order_settings (shop/views.py) [/orders/settings/] +── framework_unjoined (0) ── +── remote_edge (0) ── +── remote_unserved (0) ── +── remote_unsent (0) ── diff --git a/graph/test/python/expected/33-url-view-forms.tiers b/graph/test/python/expected/33-url-view-forms.tiers new file mode 100644 index 000000000..2713fd477 --- /dev/null +++ b/graph/test/python/expected/33-url-view-forms.tiers @@ -0,0 +1,30 @@ +distinct call sites emitted: 51 + +--- by tier: edge ROWS, and the distinct SITES they cover --- + 32 rows 32 sites ambiguous_unknown + 1 rows 1 sites boundary_lib + 18 rows 18 sites known_edge + +--- edge rows by call kind --- + 1 DECORATOR_APPLICATION + 5 DECORATOR_BARE + 1 DECORATOR_CALL + 1 METHOD_CALL + 43 SIMPLE_CALL + +--- unresolved reasons --- + 1 decorator_factory_result_untyped + 27 no_rule + 4 unmodelled_decorator + +--- the engine's own conservation ledger --- + 51 _total_sites + 32 ambiguous_unknown + 1 boundary_lib + 18 known_edge + +--- reconciling rows against the conserved site count --- + edge rows 51 + minus extra rows from multi-target sites 0 + = tier/site pairs 51 + engine's conserved site total 51 diff --git a/graph/test/python/run-tests.sh b/graph/test/python/run-tests.sh index 04189cff8..254ec5731 100755 --- a/graph/test/python/run-tests.sh +++ b/graph/test/python/run-tests.sh @@ -36,6 +36,7 @@ # # Environment: # AXIOM_PARSER parser entrypoint (default parser/dist/index.js — the parser in this repository) +# AXIOM_SUITE_JOBS cases run at once (default: the CPU count; 1 = one at a time, output uncaptured) # AXIOM_PY_ORACLE harness checkout (--oracle only; default ../../../callchain-oracle/python) # AXIOM_PY_PYTHON pinned interpreter (default python3.10; also used by the # tier-1 attribution preflight, which is version-sensitive) @@ -92,6 +93,8 @@ PINNED_PY="$PY" GUARD_PY="$PINNED_PY" command -v "$GUARD_PY" >/dev/null 2>&1 || GUARD_PY="$PY" WORK="$HERE/.work" +# shellcheck source=../tools/case-pool.sh +. "$ROOT/graph/test/tools/case-pool.sh" export AXIOM_PY_ORACLE="$ORACLE_HOME" BLESS=0; KEEP=0; ORACLE=0; ORACLE_ONLY=0; FILTERS=() @@ -266,7 +269,11 @@ fi pass=0; fail=0; failed=() -for dir in "$HERE"/cases/*/; do +# One case: the body of what was the case loop, unchanged, inside a one-case `for` so its +# `continue`s still mean "next case". pool_run (graph/test/tools/case-pool.sh) runs several +# at once and prints them in case order. +case_body() { +for dir in "$@"; do name="$(basename "$dir")" [ -d "$dir/src" ] || continue if [ ${#FILTERS[@]} -gt 0 ]; then @@ -412,6 +419,10 @@ for dir in "$HERE"/cases/*/; do fail=$((fail+1)); failed+=("$name") fi done +} +echo "running with up to $(pool_jobs) job(s) at once (AXIOM_SUITE_JOBS; 1 = one at a time)" +POOL_INTS="pass fail" POOL_ARRAYS="failed" +pool_run case_body "$HERE"/cases/*/ # ── THE WHOLE-PROJECT FIXTURES ─────────────────────────────────────────────── # test/python/projects holds two realistic projects, 29 files and ~1,040 lines, which diff --git a/graph/test/tools/case-pool.sh b/graph/test/tools/case-pool.sh new file mode 100644 index 000000000..348b5ffc6 --- /dev/null +++ b/graph/test/tools/case-pool.sh @@ -0,0 +1,165 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Run a suite's per-case loop CONCURRENTLY, and report it exactly as the serial loop did. +# Sourced, not executed. +# +# Every engine suite is one loop over cases/*/, and every case is a separate program: its +# own parse, its own solve, its own oracle, its own work directory. Nearly all of a case's +# few seconds is fixed per-process cost (node, python, the solver starting up), so on a +# machine with several CPUs the serial loop left all but one idle. This runs up to +# AXIOM_SUITE_JOBS cases at a time and prints them as the loop would have: +# +# * each case's output (stdout and stderr) is captured to its own file and printed IN CASE +# ORDER, whole, as soon as every case before it has finished — the diagnostics of a +# failing case read exactly as they did; +# * the counters the loop body updates (pass/fail and the failed-case list, whatever the +# suite calls them) are summed in case order, so the summary and the failed list match. +# +# The loop body is not rewritten. A suite wraps it, unchanged, in a one-case `for` so its +# `continue`s keep working: +# +# case_body() { for dir in "$@"; do done; } +# POOL_INTS="pass fail" POOL_ARRAYS="failed" # (and/or POOL_STRINGS for "$X $name" lists) +# pool_run case_body "$HERE"/cases/*/ +# +# WHAT IS NOT SHARED BETWEEN CONCURRENT CASES +# * the compiled engine: the FIRST case that gets past the suite's filter runs alone, so +# it compiles (or restores) the engine once; every later case finds it cached. A compile +# racing another is still safe (run-souffle.sh builds to a temp name and renames), only +# wasteful, and this keeps it from happening. +# * the staged library facts: run-souffle.sh caches them under a key made of each module's +# directory NAME and its CSVs' size and mtime (seconds). Two cases whose library IR sits +# in a directory of the same name (`$w/libir`) and was parsed in the same second can +# share a key while holding different rows — impossible when cases run one after +# another, likely when they run side by side. Each concurrent job therefore stages into +# its own AXIOM_LIBFACTS_CACHE, so no key is ever shared between them. +# +# AXIOM_SUITE_JOBS=N at most N cases at once. Default: the number of CPUs. +# AXIOM_SUITE_JOBS=1 the old loop, verbatim: every case runs in this shell, nothing is +# captured, nothing is reordered. Use it to debug a case. +# ───────────────────────────────────────────────────────────────────────────── + +pool_cpus() { + local n + n="$(getconf _NPROCESSORS_ONLN 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 1)" + case "$n" in ''|*[!0-9]*|0) n=1;; esac + printf '%s\n' "$n" +} + +# The concurrency this run will use; suites print it so a log says how it was produced. +pool_jobs() { + local j="${AXIOM_SUITE_JOBS:-$(pool_cpus)}" + case "$j" in ''|*[!0-9]*|0) j=1;; esac + printf '%s\n' "$j" +} + +# pool_run ... runs for every arg; see the header. +pool_run() { + local _fn="$1"; shift + local _jobs; _jobs="$(pool_jobs)" + if [ "$_jobs" -le 1 ] || [ "$#" -le 1 ]; then + local _a + for _a in "$@"; do "$_fn" "$_a"; done + return 0 + fi + + _POOL_TMP="$(mktemp -d "${TMPDIR:-/tmp}/suite-pool.XXXXXX")" || { echo "pool: no temp dir" >&2; return 1; } + _POOL_N=$#; _POOL_NEXT=1; _POOL_PIDS="" + local _i=0 _a _warm=1 + # Background jobs of a non-interactive shell ignore SIGINT, so an interrupted suite would + # leave its cases running. Stop them, and leave the caller's traps as they were after. + trap '_pool_abort 130' INT TERM + + for _a in "$@"; do + _i=$((_i+1)) + _pool_launch "$_fn" "$_i" "$_a" + if [ "$_warm" = 1 ]; then + # One at a time until a case has actually run (produced output): that case pays the + # engine compile, if there is one, before anything runs beside it. + while [ ! -f "$_POOL_TMP/$_i.done" ]; do sleep 0.1; done + [ -s "$_POOL_TMP/$_i.out" ] && _warm=0 + _pool_flush + continue + fi + while [ $(( _i - $(_pool_finished) )) -ge "$_jobs" ]; do + _pool_flush; sleep 0.1 + done + _pool_flush + done + while [ "$_POOL_NEXT" -le "$_POOL_N" ]; do _pool_flush; [ "$_POOL_NEXT" -le "$_POOL_N" ] && sleep 0.1; done + wait + trap - INT TERM + rm -rf "$_POOL_TMP" + return 0 +} + +# One case in a background subshell: the counters start at zero there, and whatever the +# body added is written out for _pool_merge. `.done` is written last, after the subshell has +# exited, so a case whose body called `exit` is still noticed (no `.res`). +_pool_launch() { + local fn="$1" i="$2" arg="$3" d="$_POOL_TMP" + { + ( + export AXIOM_LIBFACTS_CACHE="$d/$i.libfacts" + local v + for v in ${POOL_INTS:-}; do eval "$v=0"; done + for v in ${POOL_STRINGS:-}; do eval "$v="; done + for v in ${POOL_ARRAYS:-}; do eval "$v=()"; done + "$fn" "$arg" + { + for v in ${POOL_INTS:-} ${POOL_STRINGS:-}; do eval "printf 'S\t%s\t%s\n' \"\$v\" \"\${$v}\""; done + for v in ${POOL_ARRAYS:-}; do + eval "for _e in \${$v[@]+\"\${$v[@]}\"}; do printf 'A\t%s\t%s\n' \"\$v\" \"\$_e\"; done" + done + } > "$d/$i.res.tmp" && mv -f "$d/$i.res.tmp" "$d/$i.res" + ) > "$d/$i.out" 2>&1 + echo "$?" > "$d/$i.rc" + rm -rf "$d/$i.libfacts" + : > "$d/$i.done" + } & + _POOL_PIDS="${_POOL_PIDS:-} $!" +} + +# How many launched cases have finished (counted by their `.done` marker, not by `jobs`, +# whose view from inside a command substitution differs between bash versions). +_pool_finished() { + local f n=0 + for f in "$_POOL_TMP"/*.done; do [ -e "$f" ] && n=$((n+1)); done + printf '%s\n' "$n" +} + +# Print, in order, every finished case whose predecessors have all been printed. +_pool_flush() { + local d="$_POOL_TMP" kind var val v + while [ "$_POOL_NEXT" -le "$_POOL_N" ] && [ -f "$d/$_POOL_NEXT.done" ]; do + cat "$d/$_POOL_NEXT.out" + if [ ! -f "$d/$_POOL_NEXT.res" ]; then + # The body exited instead of finishing. Serially that ended the suite with its + # status, so it does here too. + _pool_abort "$(cat "$d/$_POOL_NEXT.rc" 2>/dev/null || echo 1)" + fi + while IFS=$'\t' read -r kind var val; do + [ "$kind" = A ] && { eval "$var+=(\"\$val\")"; continue; } + for v in ${POOL_INTS:-}; do + [ "$v" = "$var" ] && { eval "$var=\$(( $var + $val ))"; continue 2; } + done + eval "$var=\"\${$var}\$val\"" + done < "$d/$_POOL_NEXT.res" + _POOL_NEXT=$((_POOL_NEXT+1)) + done +} + +# A case is a tree of processes (the subshell, then bash, node, the solver), so stop the tree. +_pool_killtree() { + local c + for c in $(pgrep -P "$1" 2>/dev/null); do _pool_killtree "$c"; done + kill "$1" 2>/dev/null +} + +_pool_abort() { + local p + for p in ${_POOL_PIDS:-}; do _pool_killtree "$p"; done + wait 2>/dev/null + trap - INT TERM + rm -rf "${_POOL_TMP:-/nonexistent-pool}" + exit "${1:-1}" +} diff --git a/graph/test/typescript/cases/68-decorator-framework-roots/src/container.ts b/graph/test/typescript/cases/68-decorator-framework-roots/src/container.ts index 0a4ee48dd..ae6b362c6 100644 --- a/graph/test/typescript/cases/68-decorator-framework-roots/src/container.ts +++ b/graph/test/typescript/cases/68-decorator-framework-roots/src/container.ts @@ -40,6 +40,35 @@ export class OrdersView { refresh(): void {} } +// Dependency injection. The container, not the code, hands AuditService its +// OrderService and OrderController its OrderService: both slots are keyed by the class. +// The Clock slot is keyed by a TOKEN, so its declared type (an interface nobody +// provides) says nothing about what arrives, and it must not be reported as a slot. +function Inject(_token: string) { return (_t: object, _k: string | symbol | undefined, _i: number): void => {}; } +interface Clock { now(): number; } + +@Injectable() +export class AuditService { + constructor(private readonly orders: OrderService, @Inject("CLOCK") private readonly clock: Clock) {} + record(id: string): string { return this.orders.find(id) + this.clock.now(); } +} + +// A slot on a container-owned class that no provider answers: reported, never dropped. +export class Unprovided { ping(): void {} } +@Controller("/audit") +export class AuditController { + constructor(private readonly audit: AuditService, private readonly extra: Unprovided) {} + @Get(":id") + one(id: string): string { this.extra.ping(); return this.audit.record(id); } +} + +// The DI control: the same constructor parameter on a class no decorator marks. Its +// caller is in the code (`new HandBuilt(svc)`), so it is NOT an injection point. +export class HandBuilt { + constructor(private readonly svc: OrderService) {} + run(): string { return this.svc.find("x"); } +} + // A PLAIN class, the negative control. It carries no framework decorator, so its // constructor must NOT be a root and its ngOnInit must NOT be a lifecycle hook even // though the name matches exactly. diff --git a/graph/test/typescript/cases/77-package-published-entries/src/plugins/deep/tracer.ts b/graph/test/typescript/cases/77-package-published-entries/src/plugins/deep/tracer.ts index 08843e7d0..6e35d6fc2 100644 --- a/graph/test/typescript/cases/77-package-published-entries/src/plugins/deep/tracer.ts +++ b/graph/test/typescript/cases/77-package-published-entries/src/plugins/deep/tracer.ts @@ -1,5 +1,5 @@ // CONTROL: two segments below plugins/, which the one-segment `*` does not match, and -// imported (not called) by logger.ts, so it is not an unimported module either. +// imported (neither called nor published) by logger.ts, so it is not an unimported module either. // tracer must stay unreachable. export function tracer() { return 'trace'; diff --git a/graph/test/typescript/cases/77-package-published-entries/src/plugins/logger.ts b/graph/test/typescript/cases/77-package-published-entries/src/plugins/logger.ts index 87820c2c6..61c7f5d2a 100644 --- a/graph/test/typescript/cases/77-package-published-entries/src/plugins/logger.ts +++ b/graph/test/typescript/cases/77-package-published-entries/src/plugins/logger.ts @@ -1,7 +1,9 @@ // Published by the "./plugins/*" subpath pattern: `*` = logger. import { tracer } from './deep/tracer'; -export const traced = tracer; +// Reads tracer without publishing it: a published const bound to `tracer` itself would +// hand the function to a consumer, which is a root (79-package-published-members). +export const tracerName = tracer.name; export function logger(message: string) { return format(message); diff --git a/graph/test/typescript/cases/78-callable-collections/src/pipeline.ts b/graph/test/typescript/cases/78-callable-collections/src/pipeline.ts new file mode 100644 index 000000000..729b63fef --- /dev/null +++ b/graph/test/typescript/cases/78-callable-collections/src/pipeline.ts @@ -0,0 +1,83 @@ +import { widen, narrow, square, halve, pushed, spreadIn, neverRun, exportedSteps, type Node } from './steps' + +// Callables registered as DATA and dispatched by iteration, index or key (#967). None of +// these call sites names a declaration, so each is a value call through the collection: +// a dispatch-rung route in the envelope, not a resolved call-site edge. + +const steps = [widen, narrow] +const frozen = [square] as const +const table = { grow: widen, shrink: narrow } +const registry: Array<(n: Node) => Node> = [] +registry.push(pushed) +const extended = [...steps, spreadIn] +const idle = [neverRun] + +// iterated +export function runIterated(node: Node): Node { + for (const step of steps) node = step(node) + return node +} + +// iterated through `as const` +export function runFrozen(node: Node): Node { + for (const step of frozen) node = step(node) + return node +} + +// indexed, and indexed into a local first +export function runIndexed(node: Node, i: number): Node { + const pick = steps[i] + return pick(steps[0](node)) +} + +// a table read under a computed key reaches every entry; under a written key, only that entry +export function runByKey(node: Node, k: 'grow' | 'shrink'): Node { + return table[k](node) +} +export function runByName(node: Node): Node { + return table.shrink(node) +} + +// the element parameter of an array method's callback +export function runForEach(node: Node): void { + steps.forEach((step) => step(node)) +} +export function runReduce(node: Node): Node { + return frozen.reduce((acc, step) => step(acc), node) +} + +// filled by push, and by a spread of another registry +export function runRegistry(node: Node): Node { + for (const step of registry) node = step(node) + for (const step of extended) node = step(node) + return node +} + +// imported from the module that declares it +export function runImported(node: Node): Node { + for (const step of exportedSteps) node = step(node) + return node +} + +// the values of a table +export function runValues(node: Node): Node { + for (const step of Object.values(table)) node = step(node) + return node +} + +// CONTROL: the idle array is only measured, never run, so neverRun keeps no caller. +export function countIdle(): number { + return idle.length +} + +// CONTROL: a for-in binds KEYS, never elements, so nothing here is a call through the table. +export function keysOnly(): string[] { + const out: string[] = [] + for (const k in table) out.push(k) + return out +} + +// CONTROL: called by name, the shape that never needed a rule. +export function runDirect(node: Node): Node { + return halve(node) +} diff --git a/graph/test/typescript/cases/78-callable-collections/src/steps.ts b/graph/test/typescript/cases/78-callable-collections/src/steps.ts new file mode 100644 index 000000000..4967d656c --- /dev/null +++ b/graph/test/typescript/cases/78-callable-collections/src/steps.ts @@ -0,0 +1,18 @@ +export type Node = { width: number } + +// Each step is named at NO call site: it is reached only through the collection holding it. +export function widen(node: Node): Node { return { width: measure(node) + 1 } } +export function narrow(node: Node): Node { return { width: measure(node) - 1 } } +export function square(node: Node): Node { return { width: measure(node) * 2 } } +export function halve(node: Node): Node { return { width: measure(node) / 2 } } +export function pushed(node: Node): Node { return { width: measure(node) + 3 } } +export function spreadIn(node: Node): Node { return { width: measure(node) + 4 } } + +// CONTROL: held in an array nothing ever iterates, indexes or calls through. +export function neverRun(node: Node): Node { return { width: measure(node) + 5 } } + +// The far end of every chain. +export function measure(node: Node): number { return node.width } + +// An exported registry, iterated from another module. +export const exportedSteps = [halve] diff --git a/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/app.ts b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/app.ts new file mode 100644 index 000000000..198ebefae --- /dev/null +++ b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/app.ts @@ -0,0 +1,49 @@ +// A FUNCTION HANDED TO A HIGHER-ORDER FUNCTION WITH NO BODY. `map`, `forEach` and `onReady` are declarations +// only, so the site that hands the callback over is what reaches it: each function below reaches its own +// callback and nothing another function handed to the same `map`. +import { RATE } from './rate' + +const seen: number[] = [] +function record(n: number): void { + seen[0] = n +} + +export function scaled(xs: number[]): number[] { + return xs.map((x) => x * RATE) +} + +export function doubled(xs: number[]): number[] { + return xs.map((x) => x * 2) +} + +function double(n: number): number { + return n * 2 +} + +// a named function handed over: still reached, from the site that names it +export function named(xs: number[]): number[] { + return xs.map(double) +} + +// …and one held by a local first +export function viaLocal(xs: number[]): void { + const log = (n: number) => record(n) + xs.forEach(log) +} + +// an ambient declaration with no body anywhere: a host API that calls back +declare function onReady(cb: () => void): void + +export function atStartup(): void { + onReady(() => record(RATE)) +} + +// CONTROL: a project higher-order function has a body that calls its parameter, so the callback is +// still a dispatch candidate of the parameter's function type, reached through `each`'s own call. +export function each(xs: number[], fn: (n: number) => void): void { + for (const x of xs) fn(x) +} + +export function useEach(xs: number[]): void { + each(xs, (n) => record(n + RATE)) +} diff --git a/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/globals.d.ts b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/globals.d.ts new file mode 100644 index 000000000..32f96ef81 --- /dev/null +++ b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/globals.d.ts @@ -0,0 +1,15 @@ +// The slice of the standard library this case hands callbacks to. Declared here, as client code, so the +// client-only pass sees `map` and `forEach` as what they are to the project: signatures with no body. +interface Array { + map(fn: (v: T) => U): U[]; + forEach(fn: (v: T) => void): void; +} +interface Object {} +interface Boolean {} +interface Number {} +interface String {} +interface Function {} +interface CallableFunction extends Function {} +interface NewableFunction extends Function {} +interface IArguments {} +interface RegExp {} diff --git a/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/rate.ts b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/rate.ts new file mode 100644 index 000000000..b8af0f524 --- /dev/null +++ b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/rate.ts @@ -0,0 +1 @@ +export const RATE = 3 diff --git a/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/tsconfig.json b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/tsconfig.json new file mode 100644 index 000000000..904cf7a11 --- /dev/null +++ b/graph/test/typescript/cases/78-hof-callback-at-library-boundary/src/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true + } +} diff --git a/graph/test/typescript/cases/78-keyof-constrained-overload/lib/bus.ts b/graph/test/typescript/cases/78-keyof-constrained-overload/lib/bus.ts new file mode 100644 index 000000000..9ffeabcc5 --- /dev/null +++ b/graph/test/typescript/cases/78-keyof-constrained-overload/lib/bus.ts @@ -0,0 +1,20 @@ +// The declarations live in the case's LIBRARY, parsed separately and handed to the engine +// with --library. Every overload pair here is ONE member with two signatures, so the +// parser gives both the same declarationGroupKey and the group rules can fire (#334). +export interface OpenEvent { at: number; } +export interface CloseEvent { code: number; } +export interface EventMap { open: OpenEvent; close: CloseEvent; } +export interface Handlers { start(): void; stop(): void; } + +export interface Bus { + // A typed map's keys are its FIELDS. + emit(type: K): EventMap[K]; + emit(type: string): unknown; + // …and its METHODS are keys too. + run(name: K): void; + run(name: string): void; + // Control: the WIDENING signature is declared first, so it is the first applicable + // one for any string and the compiler takes it even for a key. + pick(type: string): void; + pick(type: K): void; +} diff --git a/graph/test/typescript/cases/78-keyof-constrained-overload/src/app.ts b/graph/test/typescript/cases/78-keyof-constrained-overload/src/app.ts new file mode 100644 index 000000000..992212281 --- /dev/null +++ b/graph/test/typescript/cases/78-keyof-constrained-overload/src/app.ts @@ -0,0 +1,48 @@ +import type { Bus } from '../lib/bus'; + +// WHAT THIS CASE PINS. A generic overload whose parameter is `K extends keyof X`, beside +// a widening `string` sibling. The compiler takes the generic when the argument is a +// string literal that IS a key of X, and the `string` one otherwise. The engine used to +// take `string` at every site, because the literal matched `string` and matched nothing +// concretely in `K` (#334). + +export function viaKey(b: Bus): void { + b.emit('open'); +} + +export function viaMethodKey(b: Bus): void { + b.run('start'); +} + +// Control: a literal that is NOT a key — only `emit(string)` applies. +export function viaNonKey(b: Bus): void { + b.emit('other'); +} + +// Control: a general string is not a keyof — `emit(string)`. +export function viaString(b: Bus, t: string): void { + b.emit(t); +} + +// Control: the widening signature is declared FIRST, so it wins even for a key. +export function viaWideningFirst(b: Bus): void { + b.pick('open'); +} + +// The same shape declared in CLIENT code, on a class with an implementation. +interface Slots { a: number; b: string; } + +class Registry { + get(k: K): Slots[K]; + get(k: string): unknown; + get(k: string | number): unknown { return k; } +} + +export function viaClassKey(r: Registry): unknown { + return r.get('a'); +} + +// Control: not a key of Slots. +export function viaClassNonKey(r: Registry): unknown { + return r.get('zzz'); +} diff --git a/graph/test/typescript/cases/78-logical-default-callback/src/options.ts b/graph/test/typescript/cases/78-logical-default-callback/src/options.ts new file mode 100644 index 000000000..5f5bde461 --- /dev/null +++ b/graph/test/typescript/cases/78-logical-default-callback/src/options.ts @@ -0,0 +1,68 @@ +// AN OPTIONAL CALLBACK WITH A DEFAULT. `const onError = options.onError || defaultOnError` is how a +// TypeScript API takes an optional callback: the property if the caller supplied one, a module default +// otherwise. The variable is typed by the compiler as the property's function type, so a call through it +// names that type's call signature, exactly as the undefaulted binding (`plain`) does. +export type ErrorHandler = (message: string) => void +export type Formatter = (value: number, digits: number) => string + +export interface Options { + onError?: ErrorHandler + format?: Formatter + tags?: string[] +} + +export function defaultOnError(message: string): void { + console.error(message) +} + +const defaultFormat = (value: number, digits: number): string => value.toFixed(digits) + +export function withOr(options: Options): void { + const onError = options.onError || defaultOnError + onError('or') +} + +export function withNullish(options: Options): string { + const format = options.format ?? defaultFormat + return format(1, 2) +} + +// A reassigned `let` keeps the same shape: the value is still one operand or the other. +export function withLet(options: Options): void { + let report = options.onError ?? defaultOnError + report('let') +} + +// CONTROL: the identical binding with no default. Already resolved; must not change. +export function plain(options: Options): void { + const onError = options.onError + if (onError) onError('plain') +} + +// CONTROL: an array defaulted with `|| []`, then mapped. The receiver stays the property's array type; +// the default must not add the empty array literal's type to it. +export function tagList(options: Options): string[] { + const tags = options.tags || [] + return tags.map((t) => t.toUpperCase()) +} + +// CONTROL: `&&` yields its RIGHT operand, so a guarded handler keeps the handler's signature and a +// boolean left operand contributes nothing. +export function guarded(enabled: boolean, options: Options): void { + const handler = enabled && options.onError + if (handler) handler('guarded') +} + +// CONTROL: BOTH operands carry a call signature — a property holding a function, defaulted to the +// object that carries it, which is itself callable. The compiler resolves the call to the LEFT +// operand's signature; the right one must not become a second candidate. +export type Reporter = (code: number) => void +export interface Callable { + (code: number): void + report?: Reporter +} + +export function reportWith(sink: Callable): void { + const report = sink.report || sink + report(7) +} diff --git a/graph/test/typescript/cases/78-logical-default-callback/src/tsconfig.json b/graph/test/typescript/cases/78-logical-default-callback/src/tsconfig.json new file mode 100644 index 000000000..904cf7a11 --- /dev/null +++ b/graph/test/typescript/cases/78-logical-default-callback/src/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true + } +} diff --git a/graph/test/typescript/cases/78-merged-interface-later-block-first/src/augment.ts b/graph/test/typescript/cases/78-merged-interface-later-block-first/src/augment.ts new file mode 100644 index 000000000..b82c2b039 --- /dev/null +++ b/graph/test/typescript/cases/78-merged-interface-later-block-first/src/augment.ts @@ -0,0 +1,7 @@ +import type { Matcher } from "./text"; +declare module "./text" { + interface Text { find(m: Matcher): 2; } + interface Typed { take(m: Matcher): 2; } + interface Strict { hold(m: Matcher): 2; } +} +export {}; diff --git a/graph/test/typescript/cases/78-merged-interface-later-block-first/src/repro.ts b/graph/test/typescript/cases/78-merged-interface-later-block-first/src/repro.ts new file mode 100644 index 000000000..ae6a6301f --- /dev/null +++ b/graph/test/typescript/cases/78-merged-interface-later-block-first/src/repro.ts @@ -0,0 +1,15 @@ +import "./augment"; +import type { Text, Plain, Typed, Strict, Rx, Bare } from "./text"; +export function use(t: Text, s: string, p: Plain): void { + t.find(/x/); + s.match(/x/); + p.pick(/x/); +} +// The augmentation's block is tried first, and a Rx fits its Matcher. +export function typed(t: Typed, r: Rx): void { + t.take(r); +} +// Control: a Bare does not fit the augmentation's Matcher, so the original stays. +export function strict(t: Strict, b: Bare): void { + t.hold(b); +} diff --git a/graph/test/typescript/cases/78-merged-interface-later-block-first/src/text.ts b/graph/test/typescript/cases/78-merged-interface-later-block-first/src/text.ts new file mode 100644 index 000000000..3f5d77bfb --- /dev/null +++ b/graph/test/typescript/cases/78-merged-interface-later-block-first/src/text.ts @@ -0,0 +1,8 @@ +export interface Matcher { exec(s: string): unknown } +export interface Text { find(m: RegExp): 1; } +export interface Plain { pick(m: RegExp): 1; } + +export class Rx { exec(s: string): unknown { return s; } } +export class Bare { size(): number { return 0; } } +export interface Typed { take(m: Rx): 1; } +export interface Strict { hold(m: Bare): 1; } diff --git a/graph/test/typescript/cases/78-merged-interface-later-block-first/src/tsconfig.json b/graph/test/typescript/cases/78-merged-interface-later-block-first/src/tsconfig.json new file mode 100644 index 000000000..59e429ea5 --- /dev/null +++ b/graph/test/typescript/cases/78-merged-interface-later-block-first/src/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "target": "es2022", "lib": ["es2022"], "module": "commonjs", "moduleResolution": "node10", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "noEmit": true }, "include": ["."] } diff --git a/graph/test/typescript/cases/78-merged-interface-later-block-first/src/twice.ts b/graph/test/typescript/cases/78-merged-interface-later-block-first/src/twice.ts new file mode 100644 index 000000000..49c9c18f2 --- /dev/null +++ b/graph/test/typescript/cases/78-merged-interface-later-block-first/src/twice.ts @@ -0,0 +1,12 @@ +import type { Matcher, Rx, Bare } from "./text"; +// Two blocks of one interface in one file: the compiler tries the second block first. +export interface Twice { go(m: Rx): 1; } +export interface Twice { go(m: Matcher): 2; } +// Control: the second block's parameter does not fit a Bare, so the first block stays. +export interface Once { keep(m: Bare): 1; } +export interface Once { keep(m: Matcher): 2; } + +export function run(t: Twice, o: Once, r: Rx, b: Bare): void { + t.go(r); + o.keep(b); +} diff --git a/graph/test/typescript/cases/79-function-typed-parameter-dispatch/src/logger.ts b/graph/test/typescript/cases/79-function-typed-parameter-dispatch/src/logger.ts new file mode 100644 index 000000000..5a8f09d88 --- /dev/null +++ b/graph/test/typescript/cases/79-function-typed-parameter-dispatch/src/logger.ts @@ -0,0 +1,32 @@ +import { StateCreator, StoreApi } from './store' + +// The middleware: its inner arrow's parameters carry no annotation and are typed by LoggerImpl's +// declared return type. +type LoggerImpl = (fn: StateCreator) => StateCreator + +const loggerImpl: LoggerImpl = (fn) => (set, get, api) => { + const logged: typeof set = (partial: any, replace?: any) => { + set(partial, replace) + console.log(get()) + } + console.log(api.getState()) + return fn(logged, get, api) +} +export const logger = loggerImpl + +// The same types written as annotations: nothing contextual. +export function explicitlyTyped(set: StoreApi['setState'], get: StoreApi['getState']): T { + set({}) + return get() +} + +// CONTROLS. An index that is not one literal names a union over several members, and a conditional +// alias bound to a non-literal key is the same question: both stay unresolved rather than picking one. +type Pick1 = K extends keyof T ? T[K] : never +export function controlUnionKey( + f: StoreApi['getState' | 'getInitialState'], + g: Pick1, 'getState' | 'getInitialState'>, +): void { + f() + g() +} diff --git a/graph/test/typescript/cases/79-function-typed-parameter-dispatch/src/store.ts b/graph/test/typescript/cases/79-function-typed-parameter-dispatch/src/store.ts new file mode 100644 index 000000000..ccb767912 --- /dev/null +++ b/graph/test/typescript/cases/79-function-typed-parameter-dispatch/src/store.ts @@ -0,0 +1,44 @@ +// A MIDDLEWARE RECEIVES FUNCTIONS AS PARAMETERS (#838). `(set, get, api) => ...` is written with no +// annotation: each parameter is typed by the function type the middleware is declared to return, and +// that type reaches the store's functions only through an indexed access (`StoreApi['setState']`), +// a conditional alias (`Get`), and a member whose type is itself an indexed access over a +// type literal's method (`SetStateInternal`). Each call below resolves to the signature the compiler +// names, and the store's own `setState`/`getState` arrows are its dispatch candidates. +type SetStateInternal = { + _(partial: T | Partial, replace?: false): void + _(state: T, replace: true): void +}['_'] + +export interface StoreApi { + setState: SetStateInternal + getState: () => T + getInitialState: () => T + subscribe: (listener: (state: T, prev: T) => void) => () => void +} + +export interface StoreMutators {} +type Get = K extends keyof T ? T[K] : F +export type Mutate = Ms extends [] + ? S + : Ms extends [[infer Mi, infer Ma], ...infer Mrs] + ? Mutate[Mi & keyof StoreMutators], Mrs> + : never + +export type StateCreator = (( + set: Get, Mis>, 'setState', never>, + get: Get, Mis>, 'getState', never>, + api: Mutate, Mis>, +) => T) & { $$mutators?: Mis } + +export function createStore(createState: StateCreator): StoreApi { + let state: T + const setState: StoreApi['setState'] = (partial: T | Partial, replace?: boolean) => { + state = replace ? (partial as T) : Object.assign({}, state, partial) + } + const getState: StoreApi['getState'] = () => state + const subscribe: StoreApi['subscribe'] = (listener) => () => listener(state, state) + const getInitialState: StoreApi['getInitialState'] = () => state + const api: StoreApi = { setState, getState, getInitialState, subscribe } + state = createState(setState, getState, api) + return api +} diff --git a/graph/test/typescript/cases/79-function-typed-parameter-dispatch/src/tsconfig.json b/graph/test/typescript/cases/79-function-typed-parameter-dispatch/src/tsconfig.json new file mode 100644 index 000000000..904cf7a11 --- /dev/null +++ b/graph/test/typescript/cases/79-function-typed-parameter-dispatch/src/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true + } +} diff --git a/graph/test/typescript/cases/79-package-published-members/src/package.json b/graph/test/typescript/cases/79-package-published-members/src/package.json new file mode 100644 index 000000000..3c2dc9734 --- /dev/null +++ b/graph/test/typescript/cases/79-package-published-members/src/package.json @@ -0,0 +1,11 @@ +{ + "name": "published-members", + "private": false, + "exports": { + "./cls": "./dist/cls.js", + "./anon": "./dist/anon.js", + "./bound": "./dist/bound.js", + "./ns": "./dist/ns.js", + "./api": "./dist/api.js" + } +} diff --git a/graph/test/typescript/cases/79-package-published-members/src/src/anon.ts b/graph/test/typescript/cases/79-package-published-members/src/src/anon.ts new file mode 100644 index 000000000..76897d35f --- /dev/null +++ b/graph/test/typescript/cases/79-package-published-members/src/src/anon.ts @@ -0,0 +1,2 @@ +// An anonymous default arrow of a published module. +export default (x: number) => x; diff --git a/graph/test/typescript/cases/79-package-published-members/src/src/api.ts b/graph/test/typescript/cases/79-package-published-members/src/src/api.ts new file mode 100644 index 000000000..2bd8a13c3 --- /dev/null +++ b/graph/test/typescript/cases/79-package-published-members/src/src/api.ts @@ -0,0 +1,5 @@ +// A published object literal of callables. +export const api = { + method() { return 1; }, + arrow: () => 2, +}; diff --git a/graph/test/typescript/cases/79-package-published-members/src/src/bound.ts b/graph/test/typescript/cases/79-package-published-members/src/src/bound.ts new file mode 100644 index 000000000..3020f7536 --- /dev/null +++ b/graph/test/typescript/cases/79-package-published-members/src/src/bound.ts @@ -0,0 +1,14 @@ +// A published const bound to a method reference: the headline API of many libraries. +class Engine { + produce(x: number) { return x + 1; } + finish(x: number) { return x - 1; } + static make() { return new Engine(); } + // CONTROL: a method no published const names stays unrooted. + unused() { return 0; } +} +const engine = new Engine(); +export const produce = engine.produce; +export const finish = engine.finish.bind(engine); +export const make = Engine.make; +// CONTROL: a published const holding a plain value roots nothing. +export const version = 1; diff --git a/graph/test/typescript/cases/79-package-published-members/src/src/cls.ts b/graph/test/typescript/cases/79-package-published-members/src/src/cls.ts new file mode 100644 index 000000000..07a1ade90 --- /dev/null +++ b/graph/test/typescript/cases/79-package-published-members/src/src/cls.ts @@ -0,0 +1,14 @@ +// An exported class of a published module: its public members are the API. +export class Store { + get() { return 1; } + static create() { return new Store(); } + // CONTROL: private and #private members are not reachable from a consumer. + private secret() { return 2; } + #hidden() { return 3; } +} + +// CONTROL: a class that is declared but not exported publishes nothing. +class Internal { + run() { return 4; } +} +export const internalCount = 1; diff --git a/graph/test/typescript/cases/79-package-published-members/src/src/index.test.ts b/graph/test/typescript/cases/79-package-published-members/src/src/index.test.ts new file mode 100644 index 000000000..d1102f74e --- /dev/null +++ b/graph/test/typescript/cases/79-package-published-members/src/src/index.test.ts @@ -0,0 +1,8 @@ +// The project's own test imports each published module, so none is an unimported module. +import { Store } from './cls'; +import anon from './anon'; +import { produce } from './bound'; +import { Result } from './ns'; +import { api } from './api'; + +export const all = [Store, anon, produce, Result, api]; diff --git a/graph/test/typescript/cases/79-package-published-members/src/src/ns.ts b/graph/test/typescript/cases/79-package-published-members/src/src/ns.ts new file mode 100644 index 000000000..bb2a281cc --- /dev/null +++ b/graph/test/typescript/cases/79-package-published-members/src/src/ns.ts @@ -0,0 +1,6 @@ +// The exported functions of an exported namespace. +export namespace Result { + export function ok(x: number) { return { ok: true, x }; } + // CONTROL: a namespace function that is not exported is not published. + function hidden() { return 0; } +} diff --git a/graph/test/typescript/cases/79-package-published-members/src/tsconfig.json b/graph/test/typescript/cases/79-package-published-members/src/tsconfig.json new file mode 100644 index 000000000..7b6480a12 --- /dev/null +++ b/graph/test/typescript/cases/79-package-published-members/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "compilerOptions": { "strict": true, "target": "es2020", "module": "esnext", "outDir": "dist" }, + "include": ["src"] +} diff --git a/graph/test/typescript/expected/08-function-values-and-callbacks.edges b/graph/test/typescript/expected/08-function-values-and-callbacks.edges index c0af49567..c0aeef314 100644 --- a/graph/test/typescript/expected/08-function-values-and-callbacks.edges +++ b/graph/test/typescript/expected/08-function-values-and-callbacks.edges @@ -1,5 +1,6 @@ ambiguous_unknown FUNCTION_CALL values#useLibrary() @L40 -> - ambiguous_unknown METHOD_CALL values#drive() @L29 -> - +callback_registered FUNCTION_CALL values#useLibrary() @L40 -> values#local(?) known_edge CONSTRUCTOR_CALL values#drive() @L27 -> Pipeline#() known_edge FUNCTION_CALL values#apply(Transform,string) @L12 -> values#(string) known_edge FUNCTION_CALL values#drive() @L30 -> values#(string) diff --git a/graph/test/typescript/expected/08-function-values-and-callbacks.lib.edges b/graph/test/typescript/expected/08-function-values-and-callbacks.lib.edges index 7cd0700d6..20caf154d 100644 --- a/graph/test/typescript/expected/08-function-values-and-callbacks.lib.edges +++ b/graph/test/typescript/expected/08-function-values-and-callbacks.lib.edges @@ -2,6 +2,7 @@ ambiguous_unknown METHOD_CALL values#drive() @L29 -> - boundary_lib FUNCTION_CALL values#useLibrary() @L40 -> hooks#(string) boundary_lib FUNCTION_CALL values#useLibrary() @L40 -> hooks#identity(?) boundary_lib FUNCTION_CALL values#useLibrary() @L40 -> hooks#withHook(Hook,string) +callback_registered FUNCTION_CALL values#useLibrary() @L40 -> values#local(?) known_edge CONSTRUCTOR_CALL values#drive() @L27 -> Pipeline#() known_edge FUNCTION_CALL values#apply(Transform,string) @L12 -> values#(string) known_edge FUNCTION_CALL values#drive() @L30 -> values#(string) diff --git a/graph/test/typescript/expected/17-constrained-generics.edges b/graph/test/typescript/expected/17-constrained-generics.edges index 765f00dc8..a2f3599cc 100644 --- a/graph/test/typescript/expected/17-constrained-generics.edges +++ b/graph/test/typescript/expected/17-constrained-generics.edges @@ -7,6 +7,7 @@ ambiguous_unknown FUNCTION_CALL consumer#widestLast() @L35 -> - ambiguous_unknown METHOD_CALL consumer#ownConstraintNamed(T) @L46 -> - ambiguous_unknown METHOD_CALL curried-local#pluck(unknown,unknown) @L33 -> - ambiguous_unknown METHOD_CALL curried-local#widest(unknown) @L49 -> - +callback_registered METHOD_CALL curried-local#pluck(unknown,unknown) @L33 -> curried-local#()@L33 known_edge FUNCTION_CALL consumer#localDataFirst() @L56 -> curried-local#pluck(readonly T[],'name') known_edge FUNCTION_CALL consumer#localDataLast() @L59 -> curried-local#pluck('name') known_edge FUNCTION_CALL consumer#localKeyed(Row) @L62 -> curried-local#at(T,T) diff --git a/graph/test/typescript/expected/17-constrained-generics.lib.edges b/graph/test/typescript/expected/17-constrained-generics.lib.edges index 6655ffe07..a31de0df3 100644 --- a/graph/test/typescript/expected/17-constrained-generics.lib.edges +++ b/graph/test/typescript/expected/17-constrained-generics.lib.edges @@ -7,6 +7,7 @@ boundary_lib FUNCTION_CALL consumer#keyedFirst(Row) @L23 -> curried#at(T,T) boundary_lib FUNCTION_CALL consumer#keyedLast() @L27 -> curried#at(T) boundary_lib FUNCTION_CALL consumer#widestFirst() @L31 -> curried#widest(readonly T[]) boundary_lib FUNCTION_CALL consumer#widestLast() @L35 -> curried#widest() +callback_registered METHOD_CALL curried-local#pluck(unknown,unknown) @L33 -> curried-local#()@L33 known_edge FUNCTION_CALL consumer#localDataFirst() @L56 -> curried-local#pluck(readonly T[],'name') known_edge FUNCTION_CALL consumer#localDataLast() @L59 -> curried-local#pluck('name') known_edge FUNCTION_CALL consumer#localKeyed(Row) @L62 -> curried-local#at(T,T) diff --git a/graph/test/typescript/expected/19-overload-gauntlet.edges b/graph/test/typescript/expected/19-overload-gauntlet.edges index a56fdb1ac..62ee92406 100644 --- a/graph/test/typescript/expected/19-overload-gauntlet.edges +++ b/graph/test/typescript/expected/19-overload-gauntlet.edges @@ -8,5 +8,6 @@ ambiguous_unknown FUNCTION_CALL consumer#viaMappedType(Readonly) @L34 -> - ambiguous_unknown FUNCTION_CALL consumer#viaRequired(Required) @L38 -> - ambiguous_unknown METHOD_CALL consumer#viaReceiver(Store,Row,Cell) @L52 -> - ambiguous_unknown METHOD_CALL consumer#viaReceiver(Store,Row,Cell) @L53 -> - +callback_registered FUNCTION_CALL consumer#viaContextualCallback(readonly Row[]) @L23 -> consumer#(?) known_edge FUNCTION_CALL consumer#viaInferredReturn() @L16 -> consumer#makeCount() known_edge FUNCTION_CALL consumer#viaInferredReturn() @L17 -> consumer#makeLabel() diff --git a/graph/test/typescript/expected/19-overload-gauntlet.lib.edges b/graph/test/typescript/expected/19-overload-gauntlet.lib.edges index b5604bc5f..30f1eb6d1 100644 --- a/graph/test/typescript/expected/19-overload-gauntlet.lib.edges +++ b/graph/test/typescript/expected/19-overload-gauntlet.lib.edges @@ -8,5 +8,6 @@ boundary_lib FUNCTION_CALL consumer#viaMappedType(Readonly) @L34 -> api#shape(Ce boundary_lib FUNCTION_CALL consumer#viaRequired(Required) @L38 -> api#shape(Cell) boundary_lib METHOD_CALL consumer#viaReceiver(Store,Row,Cell) @L52 -> Store#put(string,Row) boundary_lib METHOD_CALL consumer#viaReceiver(Store,Row,Cell) @L53 -> Store#put(string,Cell) +callback_registered FUNCTION_CALL consumer#viaContextualCallback(readonly Row[]) @L23 -> consumer#(?) known_edge FUNCTION_CALL consumer#viaInferredReturn() @L16 -> consumer#makeCount() known_edge FUNCTION_CALL consumer#viaInferredReturn() @L17 -> consumer#makeLabel() diff --git a/graph/test/typescript/expected/24-hedged-overload-return-union.lib.edges b/graph/test/typescript/expected/24-hedged-overload-return-union.lib.edges index da41b1a4a..3638f8d05 100644 --- a/graph/test/typescript/expected/24-hedged-overload-return-union.lib.edges +++ b/graph/test/typescript/expected/24-hedged-overload-return-union.lib.edges @@ -1,4 +1,4 @@ -boundary_lib METHOD_CALL consumer#viaVideo(Factory) @L17 -> Factory#create(string) +boundary_lib METHOD_CALL consumer#viaVideo(Factory) @L17 -> Factory#create(T) boundary_lib METHOD_CALL consumer#viaVideo(Factory) @L18 -> BaseWidget#attach(string) boundary_lib METHOD_CALL consumer#viaVideo(Factory) @L18 -> SourceWidget#attach(string) boundary_lib METHOD_CALL consumer#viaVideo(Factory) @L18 -> VideoWidget#attach(string) diff --git a/graph/test/typescript/expected/24-hedged-overload-return-union.lib.known-missing b/graph/test/typescript/expected/24-hedged-overload-return-union.lib.known-missing deleted file mode 100644 index 316b7936b..000000000 --- a/graph/test/typescript/expected/24-hedged-overload-return-union.lib.known-missing +++ /dev/null @@ -1,38 +0,0 @@ -# Accepted gap for 24-hedged-overload-return-union (client->library pass). A NEW missing -# edge fails the suite; a line here that STARTS working also fails, so the debt cannot rot. -# -# THE PREREQUISITE IS CONSTRAINT APPLICABILITY. This records the trade rather than paying -# for it with accuracy. The parser now groups a reopened type's members, so the two -# `create` signatures finally share a declarationGroupKey. That is correct, and it is a -# large gain: the group-based selection rules, which could never fire for an interface's -# signatures while the column was empty, now commit where the engine used to hedge — -# EXACT 13,168 -> 13,656 on the held-out project, with MISSED unchanged at 749. -# -# What it also does is decide this site wrongly: -# -# create(kind: K) declared first -# create(kind: string) -# -# f.create('video') the compiler picks the GENERIC — a literal IS a keyof -# f.create(k) the compiler picks (string) — a general string is not -# -# `arg_matches_concretely` answers identically at both sites: the string signature -# matches, the generic does not, because the proxy compares the argument against a -# PARAMETER's type and that type is `K`. So no rule built on it can be right at both, and -# the oracle requires one edge from each. Separating them needs `K extends keyof -# WidgetMap` evaluated against the argument. overload.dl's header already says it: "the -# prerequisite is applicability that separates these sets, not the group key." -# -# Two suppressions were measured and both cost far more than this line, which is why the -# rules are unchanged (#334): -# -# restrict the rules to FUNCTION_DECLARATION groups — their entire domain before the -# parser change: EXACT 13,656 -> 13,089 (-567) -# leave any group holding a GENERIC signature unpruned: -# EXACT 13,656 -> 13,465 (-191) -# and 19-overload-gauntlet's `widen` set stops committing, extra 1 -> 3 -# -# Both recover exactly this one edge and nothing else: corpus MISSED is 749 in every -# variant. The shape is real and hermetic, and chasing it is not worth paying for until -# constraints are testable. -consumer#viaVideo(Factory) -> Factory#create(T) diff --git a/graph/test/typescript/expected/24-hedged-overload-return-union.lib.oracle b/graph/test/typescript/expected/24-hedged-overload-return-union.lib.oracle index 16fa65f23..0514eace3 100644 --- a/graph/test/typescript/expected/24-hedged-overload-return-union.lib.oracle +++ b/graph/test/typescript/expected/24-hedged-overload-return-union.lib.oracle @@ -1,7 +1,5 @@ -oracle=4 engine=8 agree=3 missing=1 (known 1, NEW 0) extra=5 - known consumer#viaVideo(Factory) -> Factory#create(T) +oracle=4 engine=8 agree=4 missing=0 (known 0, NEW 0) extra=4 extra consumer#viaVideo(Factory) -> BaseWidget#attach(string) - extra consumer#viaVideo(Factory) -> Factory#create(string) extra consumer#viaVideo(Factory) -> SourceWidget#attach(string) extra consumer#viaWidening(Factory,string) -> SourceWidget#attach(string) extra consumer#viaWidening(Factory,string) -> VideoWidget#attach(string) diff --git a/graph/test/typescript/expected/27-readonly-array-receiver.edges b/graph/test/typescript/expected/27-readonly-array-receiver.edges index a19575d39..eb4616e15 100644 --- a/graph/test/typescript/expected/27-readonly-array-receiver.edges +++ b/graph/test/typescript/expected/27-readonly-array-receiver.edges @@ -1,3 +1,4 @@ +callback_registered METHOD_CALL readonly#useEvery(readonly ((v: number) =[]) @L11 -> readonly#(?) known_edge FUNCTION_CALL readonly#(?) @L11 -> readonly#(number) known_edge METHOD_CALL readonly#mutablePush(string[]) @L27 -> Array#push(T) known_edge METHOD_CALL readonly#mutableSlice(string[]) @L22 -> Array#slice(number,number) diff --git a/graph/test/typescript/expected/27-readonly-array-receiver.envelope b/graph/test/typescript/expected/27-readonly-array-receiver.envelope deleted file mode 100644 index 17c00930a..000000000 --- a/graph/test/typescript/expected/27-readonly-array-receiver.envelope +++ /dev/null @@ -1 +0,0 @@ -value globals#@11:15 -> readonly# diff --git a/graph/test/typescript/expected/28-property-chain-container.edges b/graph/test/typescript/expected/28-property-chain-container.edges index e62a0689a..e5c7a9d31 100644 --- a/graph/test/typescript/expected/28-property-chain-container.edges +++ b/graph/test/typescript/expected/28-property-chain-container.edges @@ -1,3 +1,7 @@ +callback_registered METHOD_CALL Builder#viaField() @L17 -> Builder#(?) +callback_registered METHOD_CALL Builder#viaGetter() @L22 -> Builder#(?) +callback_registered METHOD_CALL Builder#viaLocal() @L28 -> Builder#(?) +callback_registered METHOD_CALL chain#viaAnnotated(Column[]) @L34 -> chain#(?) known_edge METHOD_CALL Builder#(?) @L17 -> Column#isVirtual() known_edge METHOD_CALL Builder#(?) @L22 -> Column#isVirtual() known_edge METHOD_CALL Builder#(?) @L28 -> Column#isVirtual() diff --git a/graph/test/typescript/expected/28-property-chain-container.envelope b/graph/test/typescript/expected/28-property-chain-container.envelope deleted file mode 100644 index 31bc2ad2f..000000000 --- a/graph/test/typescript/expected/28-property-chain-container.envelope +++ /dev/null @@ -1,4 +0,0 @@ -value globals#@2:16 -> chain# -value globals#@2:16 -> chain#Builder.@17 -value globals#@2:16 -> chain#Builder.@22 -value globals#@2:16 -> chain#Builder.@28 diff --git a/graph/test/typescript/expected/34-callback-of-callback.edges b/graph/test/typescript/expected/34-callback-of-callback.edges index cd263c396..d2ff99be3 100644 --- a/graph/test/typescript/expected/34-callback-of-callback.edges +++ b/graph/test/typescript/expected/34-callback-of-callback.edges @@ -1,3 +1,7 @@ +callback_registered CONSTRUCTOR_CALL exec#viaExecutor() @L12 -> exec#(?,?) +callback_registered CONSTRUCTOR_CALL exec#viaRenamedExecutor() @L20 -> exec#(?,?) +callback_registered FUNCTION_CALL exec#withSink((emit: Sink) =) @L29 -> exec#() +callback_registered METHOD_CALL exec#viaNamedParam(Payload[]) @L40 -> exec#(?) known_edge CONSTRUCTOR_CALL exec#(?,?) @L13 -> Payload#() known_edge CONSTRUCTOR_CALL exec#(?,?) @L21 -> Payload#() known_edge CONSTRUCTOR_CALL exec#(?) @L33 -> Payload#() diff --git a/graph/test/typescript/expected/34-callback-of-callback.envelope b/graph/test/typescript/expected/34-callback-of-callback.envelope index af22a1296..1fc52a968 100644 --- a/graph/test/typescript/expected/34-callback-of-callback.envelope +++ b/graph/test/typescript/expected/34-callback-of-callback.envelope @@ -1,5 +1,2 @@ value exec#@28:31 -> exec#@32 -value globals#@12:21 -> exec#@12 -value globals#@12:21 -> exec#@20 -value globals#@17:14 -> exec#@40 value_call exec#withSink -> exec#@32 diff --git a/graph/test/typescript/expected/38-dynamic-import.edges b/graph/test/typescript/expected/38-dynamic-import.edges index 944dc653e..1dd6e8c36 100644 --- a/graph/test/typescript/expected/38-dynamic-import.edges +++ b/graph/test/typescript/expected/38-dynamic-import.edges @@ -1,5 +1,6 @@ ambiguous_unknown METHOD_CALL r352#ctlComputedSpecifier(string) @L39 -> - ambiguous_unknown METHOD_CALL r352#viaThenCallback() @L27 -> - +callback_registered METHOD_CALL r352#viaThenCallback() @L27 -> r352#(?) intrinsic_terminal DYNAMIC_IMPORT_CALL r352#ctlComputedSpecifier(string) @L38 -> - intrinsic_terminal DYNAMIC_IMPORT_CALL r352#viaLiteralBare() @L17 -> - intrinsic_terminal DYNAMIC_IMPORT_CALL r352#viaLiteralDestructured() @L22 -> - diff --git a/graph/test/typescript/expected/41-library-generic-return.edges b/graph/test/typescript/expected/41-library-generic-return.edges index af6638f56..50c29bb94 100644 --- a/graph/test/typescript/expected/41-library-generic-return.edges +++ b/graph/test/typescript/expected/41-library-generic-return.edges @@ -1,5 +1,6 @@ ambiguous_unknown METHOD_CALL r356#ctlNonGenericLibReturn(string) @L28 -> - ambiguous_unknown METHOD_CALL r356#viaGenericLibReturn() @L35 -> - +callback_registered METHOD_CALL r356#viaGenericLibReturn() @L35 -> r356#(AlphaOp) known_edge CONSTRUCTOR_CALL r356#ctlClientGenericReturn() @L43 -> AlphaOp#() known_edge CONSTRUCTOR_CALL r356#makeAlpha() @L48 -> AlphaOp#() known_edge CONSTRUCTOR_CALL r356#viaGenericLibReturn() @L35 -> AlphaOp#() diff --git a/graph/test/typescript/expected/64-array-dispatch.envelope b/graph/test/typescript/expected/64-array-dispatch.envelope new file mode 100644 index 000000000..9f8a80ae1 --- /dev/null +++ b/graph/test/typescript/expected/64-array-dispatch.envelope @@ -0,0 +1,3 @@ +value_call pipeline#runByName -> steps#squareRange +value_call pipeline#runIndexed -> steps#widenRange +value_call pipeline#runIterated -> steps#widenRange diff --git a/graph/test/typescript/expected/64-array-dispatch.known-missing b/graph/test/typescript/expected/64-array-dispatch.known-missing index e15505412..282e63097 100644 --- a/graph/test/typescript/expected/64-array-dispatch.known-missing +++ b/graph/test/typescript/expected/64-array-dispatch.known-missing @@ -11,6 +11,13 @@ # Measured against the TypeScript compiler's own answer (checker.getResolvedSignature, # via tools/tsc_oracle_case.mjs): oracle=7 engine=4 agree=4 extra=0. runDirect, and # each step's own call to measure, resolve and are the controls. +# +# They are no longer SILENT (#967): each is a `value_call` in +# expected/64-array-dispatch.envelope, a dispatch-rung route that impact, path and +# test selection walk. The call-site answer stays absent on purpose. For a collection +# of several functions the compiler names one arbitrary member of the element union, +# and a call-site answer could only match it by being wrong at every other element +# (case 78 measures that). pipeline#runIterated(Node) -> steps#widenRange(Node) pipeline#runIndexed(Node) -> steps#widenRange(Node) pipeline#runByName(Node) -> steps#squareRange(Node) diff --git a/graph/test/typescript/expected/68-decorator-framework-roots.edges b/graph/test/typescript/expected/68-decorator-framework-roots.edges index f5395c282..f01d2cdfb 100644 --- a/graph/test/typescript/expected/68-decorator-framework-roots.edges +++ b/graph/test/typescript/expected/68-decorator-framework-roots.edges @@ -1,8 +1,17 @@ +known_edge DECORATOR_CALL AuditController#one(string) @L61 -> container#Get(string) +known_edge DECORATOR_CALL AuditService#(OrderService,Clock) @L52 -> container#Inject(string) known_edge DECORATOR_CALL OrderController#add(string) @L29 -> container#Post(string) known_edge DECORATOR_CALL OrderController#getOne(string) @L26 -> container#Get(string) known_edge DECORATOR_CALL container#() @L15 -> container#Injectable() known_edge DECORATOR_CALL container#() @L22 -> container#Controller(string) known_edge DECORATOR_CALL container#() @L36 -> container#Component({ selector: string }) +known_edge DECORATOR_CALL container#() @L50 -> container#Injectable() +known_edge DECORATOR_CALL container#() @L58 -> container#Controller(string) +known_edge METHOD_CALL AuditController#one(string) @L62 -> AuditService#record(string) +known_edge METHOD_CALL AuditController#one(string) @L62 -> Unprovided#ping() +known_edge METHOD_CALL AuditService#record(string) @L53 -> Clock#now() +known_edge METHOD_CALL AuditService#record(string) @L53 -> OrderService#find(string) +known_edge METHOD_CALL HandBuilt#run() @L69 -> OrderService#find(string) known_edge METHOD_CALL OrderController#add(string) @L30 -> OrderService#create(string) known_edge METHOD_CALL OrderController#getOne(string) @L27 -> OrderService#find(string) known_edge METHOD_CALL OrderService#create(string) @L19 -> OrderService#find(string) diff --git a/graph/test/typescript/expected/68-decorator-framework-roots.entries b/graph/test/typescript/expected/68-decorator-framework-roots.entries index 92e9a12f1..ba24c10e8 100644 --- a/graph/test/typescript/expected/68-decorator-framework-roots.entries +++ b/graph/test/typescript/expected/68-decorator-framework-roots.entries @@ -1,8 +1,16 @@ -── entry_point (7) ── +── entry_point (10) ── + bean_ctor container#AuditController. container.ts:60 + bean_ctor container#AuditService. container.ts:52 bean_ctor container#OrderController. container.ts:24 bean_ctor container#OrderService. container.ts:15 bean_ctor container#OrdersView. container.ts:36 + http container#AuditController.one container.ts:61 http container#OrderController.add container.ts:29 http container#OrderController.getOne container.ts:26 lifecycle container#OrdersView.ngOnInit container.ts:39 unimported_module container# container.ts:1 +── di_edge (4) ── + known_bean container#AuditController.(audit: AuditService) <- AuditService + known_bean container#AuditService.(orders: OrderService) <- OrderService + known_bean container#OrderController.(svc: OrderService) <- OrderService + unsatisfied container#AuditController.(extra: Unprovided) <- - diff --git a/graph/test/typescript/expected/68-decorator-framework-roots.fields b/graph/test/typescript/expected/68-decorator-framework-roots.fields index 3ba442ec2..cc318c0fc 100644 --- a/graph/test/typescript/expected/68-decorator-framework-roots.fields +++ b/graph/test/typescript/expected/68-decorator-framework-roots.fields @@ -1,2 +1,7 @@ +known_edge read AuditController#one(string) -> AuditController#audit +known_edge read AuditController#one(string) -> AuditController#extra +known_edge read AuditService#record(string) -> AuditService#clock +known_edge read AuditService#record(string) -> AuditService#orders +known_edge read HandBuilt#run() -> HandBuilt#svc known_edge read OrderController#add(string) -> OrderController#svc known_edge read OrderController#getOne(string) -> OrderController#svc diff --git a/graph/test/typescript/expected/68-decorator-framework-roots.fields-oracle b/graph/test/typescript/expected/68-decorator-framework-roots.fields-oracle index bc03d45ad..06422fb1e 100644 --- a/graph/test/typescript/expected/68-decorator-framework-roots.fields-oracle +++ b/graph/test/typescript/expected/68-decorator-framework-roots.fields-oracle @@ -1,7 +1,7 @@ 68-decorator-framework-roots [fields] - precision 1.0000 (2 correct, 0 wrong) - recall 1.0000 (2 of 2 the compiler resolved) - sites 2 resolved 2 (100.0%) - tiers known_edge=2 - access read=2 + precision 1.0000 (7 correct, 0 wrong) + recall 1.0000 (7 of 7 the compiler resolved) + sites 7 resolved 7 (100.0%) + tiers known_edge=7 + access read=7 not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/68-decorator-framework-roots.known-missing b/graph/test/typescript/expected/68-decorator-framework-roots.known-missing index 0f22aca41..8b0563608 100644 --- a/graph/test/typescript/expected/68-decorator-framework-roots.known-missing +++ b/graph/test/typescript/expected/68-decorator-framework-roots.known-missing @@ -2,17 +2,19 @@ # compiler resolves and this engine does not. A NEW missing edge fails the suite; # a line here that STARTS working also fails, so the debt cannot rot. # -# All five are one gap, tracked as #233: a decorator FACTORY is called and gets its +# All seven are one gap, tracked as #233: a decorator FACTORY is called and gets its # edge (call_kind DECORATOR_CALL), but the function that factory RETURNS is then # applied to the decorated declaration, and that second hop has no call site. The # compiler sees it, so every legacy decorator in this ecosystem loses one edge. # # This case does not depend on the gap. The roots it exists to test are derived from # the decorator's NAME through annotation_on, never from the application edge, which -# is why they are correct while these five are still absent. When #233 lands, these +# is why they are correct while these seven are still absent. When #233 lands, these # lines start working and this file must be emptied in the same commit. OrderController#add(string) -> container#(object,string,PropertyDescriptor) OrderController#getOne(string) -> container#(object,string,PropertyDescriptor) container#() -> container#(Function) container#() -> container#(Function) container#() -> container#(Function) +AuditController#one(string) -> container#(object,string,PropertyDescriptor) +AuditService#(OrderService,Clock) -> container#(object,string | symbol | undefined,number) diff --git a/graph/test/typescript/expected/68-decorator-framework-roots.oracle b/graph/test/typescript/expected/68-decorator-framework-roots.oracle index 2f5b72ae5..892447a7d 100644 --- a/graph/test/typescript/expected/68-decorator-framework-roots.oracle +++ b/graph/test/typescript/expected/68-decorator-framework-roots.oracle @@ -1,4 +1,6 @@ -oracle=14 engine=9 agree=9 missing=5 (known 5, NEW 0) extra=0 +oracle=23 engine=16 agree=16 missing=7 (known 7, NEW 0) extra=0 + known AuditController#one(string) -> container#(object,string,PropertyDescriptor) + known AuditService#(OrderService,Clock) -> container#(object,string | symbol | undefined,number) known OrderController#add(string) -> container#(object,string,PropertyDescriptor) known OrderController#getOne(string) -> container#(object,string,PropertyDescriptor) known container#() -> container#(Function) diff --git a/graph/test/typescript/expected/68-decorator-framework-roots.type-use b/graph/test/typescript/expected/68-decorator-framework-roots.type-use index d09c15409..df0c9abcf 100644 --- a/graph/test/typescript/expected/68-decorator-framework-roots.type-use +++ b/graph/test/typescript/expected/68-decorator-framework-roots.type-use @@ -1,5 +1,12 @@ +ambiguous_unknown DECORATOR_TYPE 0 AuditController [DECORATOR] -> - +ambiguous_unknown DECORATOR_TYPE 0 AuditService [DECORATOR] -> - ambiguous_unknown DECORATOR_TYPE 0 OrderController [DECORATOR] -> - ambiguous_unknown DECORATOR_TYPE 0 OrderService [DECORATOR] -> - ambiguous_unknown DECORATOR_TYPE 0 OrdersView [DECORATOR] -> - ambiguous_unknown METHOD_PARAM 0 container [METHOD_PARAM] -> - +known_edge METHOD_PARAM 0 AuditController [METHOD_PARAM] -> AuditService +known_edge METHOD_PARAM 0 AuditController [METHOD_PARAM] -> Unprovided +known_edge METHOD_PARAM 0 AuditService [METHOD_PARAM] -> Clock +known_edge METHOD_PARAM 0 AuditService [METHOD_PARAM] -> OrderService +known_edge METHOD_PARAM 0 HandBuilt [METHOD_PARAM] -> OrderService known_edge METHOD_PARAM 0 OrderController [METHOD_PARAM] -> OrderService diff --git a/graph/test/typescript/expected/68-decorator-framework-roots.types-oracle b/graph/test/typescript/expected/68-decorator-framework-roots.types-oracle index 39ce39da7..5a8e71359 100644 --- a/graph/test/typescript/expected/68-decorator-framework-roots.types-oracle +++ b/graph/test/typescript/expected/68-decorator-framework-roots.types-oracle @@ -1,7 +1,7 @@ 68-decorator-framework-roots [types] - precision 1.0000 (1 correct, 0 wrong) - recall 1.0000 (1 of 1 the compiler resolved) - sites 11 resolved 1 (9.1%) - tiers ambiguous_unknown=10 known_edge=1 - contexts DECORATOR_TYPE=5 METHOD_PARAM=6 - not scored: 10 rows whose target is not a client declaration + precision 1.0000 (6 correct, 0 wrong) + recall 1.0000 (6 of 6 the compiler resolved) + sites 20 resolved 6 (30.0%) + tiers ambiguous_unknown=14 known_edge=6 + contexts DECORATOR_TYPE=9 METHOD_PARAM=11 + not scored: 14 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/69-named-and-inline-handlers.edges b/graph/test/typescript/expected/69-named-and-inline-handlers.edges index cf666d4da..9f3b7b6cd 100644 --- a/graph/test/typescript/expected/69-named-and-inline-handlers.edges +++ b/graph/test/typescript/expected/69-named-and-inline-handlers.edges @@ -1,6 +1,9 @@ ambient_terminal FUNCTION_CALL routes#() @L35 -> routes#it(string,() =) ambient_terminal FUNCTION_CALL routes#() @L50 -> routes#registerAll(Record string) ambiguous_unknown METHOD_CALL routes#() @L40 -> - +callback_registered FUNCTION_CALL routes#() @L35 -> routes#namedSpec() +callback_registered METHOD_CALL routes#() @L24 -> routes#() +callback_registered METHOD_CALL routes#() @L30 -> routes#namedHandler() known_edge FUNCTION_CALL routes#() @L24 -> routes#inlineWork() known_edge FUNCTION_CALL routes#() @L40 -> routes#notAHandler() known_edge FUNCTION_CALL routes#namedHandler() @L28 -> routes#namedWork() diff --git a/graph/test/typescript/expected/69-named-and-inline-handlers.envelope b/graph/test/typescript/expected/69-named-and-inline-handlers.envelope deleted file mode 100644 index 8b02681eb..000000000 --- a/graph/test/typescript/expected/69-named-and-inline-handlers.envelope +++ /dev/null @@ -1 +0,0 @@ -value routes#@21:41 -> routes#namedSpec diff --git a/graph/test/typescript/expected/70-function-type-target-dispatch.edges b/graph/test/typescript/expected/70-function-type-target-dispatch.edges index 0001be686..619b9e47d 100644 --- a/graph/test/typescript/expected/70-function-type-target-dispatch.edges +++ b/graph/test/typescript/expected/70-function-type-target-dispatch.edges @@ -1,3 +1,6 @@ +callback_registered METHOD_CALL targets#viaAnnotatedParam(CB[]) @L14 -> targets#(?) +callback_registered METHOD_CALL targets#viaAsCast(unknown) @L19 -> targets#(?) +callback_registered METHOD_CALL targets#viaGenericMap(number[]) @L31 -> targets#(?)@L31 known_edge FUNCTION_CALL targets#(?) @L14 -> targets#(number) known_edge FUNCTION_CALL targets#(?) @L19 -> targets#(number) known_edge FUNCTION_CALL targets#viaForOf(CB[]) @L23 -> targets#(number) diff --git a/graph/test/typescript/expected/70-function-type-target-dispatch.envelope b/graph/test/typescript/expected/70-function-type-target-dispatch.envelope index 17ac3194b..1dd6161a1 100644 --- a/graph/test/typescript/expected/70-function-type-target-dispatch.envelope +++ b/graph/test/typescript/expected/70-function-type-target-dispatch.envelope @@ -1,5 +1,2 @@ -value globals#@4:14 -> targets#@14 -value globals#@4:14 -> targets#@19 -value globals#@4:14 -> targets#@31 value targets#@6:18 -> targets#@10 value targets#@6:18 -> targets#@8 diff --git a/graph/test/typescript/expected/72-object-literal-and-expression-callees.edges b/graph/test/typescript/expected/72-object-literal-and-expression-callees.edges index f50f45644..178b6e843 100644 --- a/graph/test/typescript/expected/72-object-literal-and-expression-callees.edges +++ b/graph/test/typescript/expected/72-object-literal-and-expression-callees.edges @@ -1,3 +1,5 @@ +callback_registered FUNCTION_CALL middleware#dispatch(Entry[][],number) @L11 -> middleware#() +callback_registered METHOD_CALL pool#usePool(Pool) @L21 -> pool#() known_edge FUNCTION_CALL fetcher#get(string,Options) @L17 -> fetcher#(string) known_edge FUNCTION_CALL fetcher#load() @L21 -> fetcher#get(string,Options) known_edge FUNCTION_CALL middleware#boot() @L15 -> middleware#dispatch(Entry[][],number) diff --git a/graph/test/typescript/expected/72-object-literal-and-expression-callees.envelope b/graph/test/typescript/expected/72-object-literal-and-expression-callees.envelope index 15989edb1..fc04d0270 100644 --- a/graph/test/typescript/expected/72-object-literal-and-expression-callees.envelope +++ b/graph/test/typescript/expected/72-object-literal-and-expression-callees.envelope @@ -1,6 +1,5 @@ value fetcher#@4:21 -> fetcher# value middleware#@4:23 -> middleware#@7 -value pool#@6:11 -> pool#@21 value pool#Pool.run -> pool#@11 value pool#Pool.run -> pool#@16 value pool#Pool.run -> pool#@25 @@ -10,4 +9,5 @@ value pool#Pool.size -> pool#size value registry#@3:22 -> registry#money value_call fetcher#get -> fetcher# value_call fetcher#get -> fetcher#defaultFetch +value_call pool#usePool -> pool#@25 value_call registry#price -> registry#money diff --git a/graph/test/typescript/expected/74-jsx-component-forms.edges b/graph/test/typescript/expected/74-jsx-component-forms.edges index 4bbd01501..887e1bbaf 100644 --- a/graph/test/typescript/expected/74-jsx-component-forms.edges +++ b/graph/test/typescript/expected/74-jsx-component-forms.edges @@ -7,6 +7,10 @@ ambiguous_unknown FUNCTION_CALL parts#FieldImpl({ label: string },unknown) @L39 ambiguous_unknown FUNCTION_CALL settings#Settings() @L6 -> - ambiguous_unknown METHOD_CALL app#App() @L15 -> - ambiguous_unknown METHOD_CALL parts#formatName(string) @L25 -> - +callback_registered FUNCTION_CALL app#() @L4 -> app#() +callback_registered FUNCTION_CALL parts#() @L33 -> parts#UserCard({ id: string }) +callback_registered FUNCTION_CALL parts#() @L41 -> parts#FieldImpl({ label: string },unknown) +callback_registered FUNCTION_CALL parts#() @L73 -> parts#buildDefault() intrinsic_terminal DYNAMIC_IMPORT_CALL app#() @L4 -> - known_edge FUNCTION_CALL app#useFactory() @L23 -> parts#()@L22 known_edge FUNCTION_CALL parts#FieldImpl({ label: string },unknown) @L39 -> parts#trackClick(string) diff --git a/graph/test/typescript/expected/74-jsx-component-forms.envelope b/graph/test/typescript/expected/74-jsx-component-forms.envelope index 3b3998b48..690fd675e 100644 --- a/graph/test/typescript/expected/74-jsx-component-forms.envelope +++ b/graph/test/typescript/expected/74-jsx-component-forms.envelope @@ -1,5 +1 @@ value parts#@18:21 -> parts# -value parts#@18:21 -> parts#UserCard -value parts#@20:47 -> parts#FieldImpl -value parts#@21:39 -> app# -value parts#@22:47 -> parts#buildDefault diff --git a/graph/test/typescript/expected/75-jsx-wrapper-guards.edges b/graph/test/typescript/expected/75-jsx-wrapper-guards.edges index 68ca2dc39..78ebdfe74 100644 --- a/graph/test/typescript/expected/75-jsx-wrapper-guards.edges +++ b/graph/test/typescript/expected/75-jsx-wrapper-guards.edges @@ -19,6 +19,18 @@ ambiguous_unknown METHOD_CALL app#() @L15 -> - ambiguous_unknown METHOD_CALL app#() @L16 -> - ambiguous_unknown METHOD_CALL app#() @L18 -> - ambiguous_unknown METHOD_CALL app#ArrowRenameTag({ icon: Fc }[]) @L57 -> - +callback_registered FUNCTION_CALL app#() @L15 -> app#() +callback_registered FUNCTION_CALL app#() @L16 -> app#() +callback_registered FUNCTION_CALL app#() @L18 -> app#() +callback_registered FUNCTION_CALL app#() @L19 -> app#() +callback_registered FUNCTION_CALL app#() @L21 -> app#() +callback_registered FUNCTION_CALL app#() @L22 -> app#() +callback_registered FUNCTION_CALL app#() @L29 -> app#MemoInner() +callback_registered FUNCTION_CALL app#() @L30 -> app#ObservedImpl() +callback_registered METHOD_CALL app#() @L15 -> app#(?) +callback_registered METHOD_CALL app#() @L16 -> app#(?) +callback_registered METHOD_CALL app#() @L18 -> app#(?) +callback_registered METHOD_CALL app#ArrowRenameTag({ icon: Fc }[]) @L57 -> app#(?,?) intrinsic_terminal DYNAMIC_IMPORT_CALL app#() @L15 -> - intrinsic_terminal DYNAMIC_IMPORT_CALL app#() @L16 -> - intrinsic_terminal DYNAMIC_IMPORT_CALL app#() @L18 -> - diff --git a/graph/test/typescript/expected/75-jsx-wrapper-guards.envelope b/graph/test/typescript/expected/75-jsx-wrapper-guards.envelope index 4274ea0da..ae87f78fd 100644 --- a/graph/test/typescript/expected/75-jsx-wrapper-guards.envelope +++ b/graph/test/typescript/expected/75-jsx-wrapper-guards.envelope @@ -1,12 +1,4 @@ -value parts#@15:21 -> app#MemoInner -value parts#@15:21 -> app#ObservedImpl value parts#@15:21 -> parts# -value parts#@18:36 -> app#@15 -value parts#@18:36 -> app#@18 -value parts#@18:36 -> app#@21 -value parts#@19:39 -> app#@16 -value parts#@19:39 -> app#@19 -value parts#@19:39 -> app#@22 value parts#@22:30 -> app#@25 value parts#@26:35 -> app#@26 value_call app#Page -> app#MadeInner diff --git a/graph/test/typescript/expected/76-jsx-default-nested-wrappers.edges b/graph/test/typescript/expected/76-jsx-default-nested-wrappers.edges index 5b6145e44..c0fb8e397 100644 --- a/graph/test/typescript/expected/76-jsx-default-nested-wrappers.edges +++ b/graph/test/typescript/expected/76-jsx-default-nested-wrappers.edges @@ -12,6 +12,12 @@ ambiguous_unknown FUNCTION_CALL parts#MemoFwdInner({},unknown) @L40 -> - ambiguous_unknown JSX_COMPONENT_CALL app#App() @L19 -> - ambiguous_unknown METHOD_CALL app#() @L6 -> - ambiguous_unknown METHOD_CALL app#makeName(number) @L26 -> - +callback_registered FUNCTION_CALL app#() @L10 -> parts#() +callback_registered FUNCTION_CALL app#() @L6 -> app#() +callback_registered FUNCTION_CALL app#() @L8 -> app#() +callback_registered FUNCTION_CALL defmemo#() @L4 -> parts#DefaultMemoInner() +callback_registered FUNCTION_CALL parts#() @L39 -> parts#MemoFwdInner({},unknown) +callback_registered METHOD_CALL app#() @L6 -> app#(?) intrinsic_terminal DYNAMIC_IMPORT_CALL app#() @L6 -> - intrinsic_terminal DYNAMIC_IMPORT_CALL app#() @L8 -> - known_edge FUNCTION_CALL app#() @L10 -> parts#pickOther(Fc) diff --git a/graph/test/typescript/expected/76-jsx-default-nested-wrappers.envelope b/graph/test/typescript/expected/76-jsx-default-nested-wrappers.envelope index 539de9007..509328b85 100644 --- a/graph/test/typescript/expected/76-jsx-default-nested-wrappers.envelope +++ b/graph/test/typescript/expected/76-jsx-default-nested-wrappers.envelope @@ -1,7 +1,3 @@ value parts#@14:21 -> parts# -value parts#@14:21 -> parts#DefaultMemoInner value parts#@14:21 -> parts#NestedPickedInner value parts#@14:21 -> parts#PickedInner -value parts#@16:47 -> parts#MemoFwdInner -value parts#@17:36 -> app#@6 -value parts#@17:36 -> app#@8 diff --git a/graph/test/typescript/expected/77-package-published-entries.edges b/graph/test/typescript/expected/77-package-published-entries.edges index 434d7ba30..313a7e054 100644 --- a/graph/test/typescript/expected/77-package-published-entries.edges +++ b/graph/test/typescript/expected/77-package-published-entries.edges @@ -1,6 +1,6 @@ known_edge FUNCTION_CALL index#useStore(number) @L11 -> internal#describeState(number) known_edge FUNCTION_CALL index#useStore(number) @L9 -> vanilla#createStore(number) -known_edge FUNCTION_CALL plugins/logger#logger(string) @L7 -> plugins/logger#format(string) +known_edge FUNCTION_CALL plugins/logger#logger(string) @L9 -> plugins/logger#format(string) known_edge FUNCTION_CALL vanilla#createBoundStore(number) @L12 -> vanilla#seal(number) known_edge FUNCTION_CALL vanilla#createCastStore(number) @L18 -> vanilla#seal(number) known_edge FUNCTION_CALL vanilla#createStore(number) @L4 -> vanilla#seal(number) diff --git a/graph/test/typescript/expected/77-package-published-entries.entries b/graph/test/typescript/expected/77-package-published-entries.entries index c670ddf0d..eedc22bc3 100644 --- a/graph/test/typescript/expected/77-package-published-entries.entries +++ b/graph/test/typescript/expected/77-package-published-entries.entries @@ -1,6 +1,6 @@ ── entry_point (6) ── package_export index#useStore index.ts:8 - package_export plugins/logger#logger logger.ts:6 + package_export plugins/logger#logger logger.ts:8 package_export vanilla# vanilla.ts:12 package_export vanilla# vanilla.ts:18 package_export vanilla#createStore vanilla.ts:3 diff --git a/graph/test/typescript/expected/77-package-published-entries.fields b/graph/test/typescript/expected/77-package-published-entries.fields index 52a0bdc35..0233e0761 100644 --- a/graph/test/typescript/expected/77-package-published-entries.fields +++ b/graph/test/typescript/expected/77-package-published-entries.fields @@ -1 +1,2 @@ ambiguous_unknown read index#useStore(number) -> - +ambiguous_unknown read plugins/logger#() -> - diff --git a/graph/test/typescript/expected/77-package-published-entries.fields-oracle b/graph/test/typescript/expected/77-package-published-entries.fields-oracle index f7d053de2..85d59b992 100644 --- a/graph/test/typescript/expected/77-package-published-entries.fields-oracle +++ b/graph/test/typescript/expected/77-package-published-entries.fields-oracle @@ -1,8 +1,8 @@ 77-package-published-entries [fields] precision 0.0000 (0 correct, 0 wrong) recall 0.0000 (0 of 1 the compiler resolved) - sites 1 resolved 0 (0.0%) - tiers ambiguous_unknown=1 - access read=1 - not scored: 1 rows whose target is not a client declaration + sites 2 resolved 0 (0.0%) + tiers ambiguous_unknown=2 + access read=2 + not scored: 2 rows whose target is not a client declaration MISSING index#useStore(number) READ vanilla#value diff --git a/graph/test/typescript/expected/78-callable-collections.edges b/graph/test/typescript/expected/78-callable-collections.edges new file mode 100644 index 000000000..531f4a3d4 --- /dev/null +++ b/graph/test/typescript/expected/78-callable-collections.edges @@ -0,0 +1,28 @@ +ambiguous_unknown FUNCTION_CALL pipeline#(?) @L43 -> - +ambiguous_unknown FUNCTION_CALL pipeline#(?,?) @L46 -> - +ambiguous_unknown FUNCTION_CALL pipeline#runFrozen(Node) @L23 -> - +ambiguous_unknown FUNCTION_CALL pipeline#runImported(Node) @L58 -> - +ambiguous_unknown FUNCTION_CALL pipeline#runIndexed(Node,number) @L30 -> - +ambiguous_unknown FUNCTION_CALL pipeline#runIterated(Node) @L17 -> - +ambiguous_unknown FUNCTION_CALL pipeline#runRegistry(Node) @L52 -> - +ambiguous_unknown FUNCTION_CALL pipeline#runValues(Node) @L64 -> - +ambiguous_unknown METHOD_CALL pipeline#() @L11 -> - +ambiguous_unknown METHOD_CALL pipeline#keysOnly() @L76 -> - +ambiguous_unknown METHOD_CALL pipeline#runByKey(Node,'grow' | 'shrink') @L35 -> - +ambiguous_unknown METHOD_CALL pipeline#runByName(Node) @L38 -> - +ambiguous_unknown METHOD_CALL pipeline#runForEach(Node) @L43 -> - +ambiguous_unknown METHOD_CALL pipeline#runIndexed(Node,number) @L30 -> - +ambiguous_unknown METHOD_CALL pipeline#runReduce(Node) @L46 -> - +ambiguous_unknown METHOD_CALL pipeline#runValues(Node) @L64 -> - +callback_registered METHOD_CALL pipeline#() @L11 -> steps#pushed(Node) +callback_registered METHOD_CALL pipeline#runForEach(Node) @L43 -> pipeline#(?) +callback_registered METHOD_CALL pipeline#runReduce(Node) @L46 -> pipeline#(?,?) +known_edge FUNCTION_CALL pipeline#runDirect(Node) @L82 -> steps#halve(Node) +known_edge FUNCTION_CALL pipeline#runRegistry(Node) @L51 -> pipeline#(Node) +known_edge FUNCTION_CALL steps#halve(Node) @L7 -> steps#measure(Node) +known_edge FUNCTION_CALL steps#narrow(Node) @L5 -> steps#measure(Node) +known_edge FUNCTION_CALL steps#neverRun(Node) @L12 -> steps#measure(Node) +known_edge FUNCTION_CALL steps#pushed(Node) @L8 -> steps#measure(Node) +known_edge FUNCTION_CALL steps#spreadIn(Node) @L9 -> steps#measure(Node) +known_edge FUNCTION_CALL steps#square(Node) @L6 -> steps#measure(Node) +known_edge FUNCTION_CALL steps#widen(Node) @L4 -> steps#measure(Node) diff --git a/graph/test/typescript/expected/78-callable-collections.entries b/graph/test/typescript/expected/78-callable-collections.entries new file mode 100644 index 000000000..9622ff48f --- /dev/null +++ b/graph/test/typescript/expected/78-callable-collections.entries @@ -0,0 +1,15 @@ +── entry_point (14) ── + exported_from_entry_module pipeline#countIdle pipeline.ts:69 + exported_from_entry_module pipeline#keysOnly pipeline.ts:74 + exported_from_entry_module pipeline#runByKey pipeline.ts:34 + exported_from_entry_module pipeline#runByName pipeline.ts:37 + exported_from_entry_module pipeline#runDirect pipeline.ts:81 + exported_from_entry_module pipeline#runForEach pipeline.ts:42 + exported_from_entry_module pipeline#runFrozen pipeline.ts:22 + exported_from_entry_module pipeline#runImported pipeline.ts:57 + exported_from_entry_module pipeline#runIndexed pipeline.ts:28 + exported_from_entry_module pipeline#runIterated pipeline.ts:16 + exported_from_entry_module pipeline#runReduce pipeline.ts:45 + exported_from_entry_module pipeline#runRegistry pipeline.ts:50 + exported_from_entry_module pipeline#runValues pipeline.ts:63 + unimported_module pipeline# pipeline.ts:1 diff --git a/graph/test/typescript/expected/78-callable-collections.envelope b/graph/test/typescript/expected/78-callable-collections.envelope new file mode 100644 index 000000000..5a1721884 --- /dev/null +++ b/graph/test/typescript/expected/78-callable-collections.envelope @@ -0,0 +1,19 @@ +value pipeline#@10:23 -> steps#pushed +value_call pipeline#@43 -> steps#narrow +value_call pipeline#@43 -> steps#widen +value_call pipeline#@46 -> steps#square +value_call pipeline#runByKey -> steps#narrow +value_call pipeline#runByKey -> steps#widen +value_call pipeline#runByName -> steps#narrow +value_call pipeline#runFrozen -> steps#square +value_call pipeline#runImported -> steps#halve +value_call pipeline#runIndexed -> steps#narrow +value_call pipeline#runIndexed -> steps#widen +value_call pipeline#runIterated -> steps#narrow +value_call pipeline#runIterated -> steps#widen +value_call pipeline#runRegistry -> steps#narrow +value_call pipeline#runRegistry -> steps#pushed +value_call pipeline#runRegistry -> steps#spreadIn +value_call pipeline#runRegistry -> steps#widen +value_call pipeline#runValues -> steps#narrow +value_call pipeline#runValues -> steps#widen diff --git a/graph/test/typescript/expected/78-callable-collections.fields b/graph/test/typescript/expected/78-callable-collections.fields new file mode 100644 index 000000000..b5e742ff3 --- /dev/null +++ b/graph/test/typescript/expected/78-callable-collections.fields @@ -0,0 +1,2 @@ +ambiguous_unknown read pipeline#countIdle() -> - +known_edge read steps#measure(Node) -> { width: number }#width diff --git a/graph/test/typescript/expected/78-callable-collections.fields-oracle b/graph/test/typescript/expected/78-callable-collections.fields-oracle new file mode 100644 index 000000000..8fc5cf9aa --- /dev/null +++ b/graph/test/typescript/expected/78-callable-collections.fields-oracle @@ -0,0 +1,10 @@ +78-callable-collections [fields] + precision 0.0000 (0 correct, 1 wrong) + recall 0.0000 (0 of 2 the compiler resolved) + sites 2 resolved 1 (50.0%) + tiers ambiguous_unknown=1 known_edge=1 + access read=2 + not scored: 1 rows whose target is not a client declaration + WRONG steps#measure(Node) READ { width: number }#width + MISSING pipeline#runByName(Node) READ pipeline#shrink + MISSING steps#measure(Node) READ steps#width diff --git a/graph/test/typescript/expected/78-callable-collections.known-missing b/graph/test/typescript/expected/78-callable-collections.known-missing new file mode 100644 index 000000000..92411e10e --- /dev/null +++ b/graph/test/typescript/expected/78-callable-collections.known-missing @@ -0,0 +1,20 @@ +# Accepted call-site gaps for 78-callable-collections. Each line is an edge the TypeScript +# compiler resolves and this engine does not. A NEW missing edge fails the suite; +# a line here that STARTS working also fails, so the debt cannot rot. +# +# Every site here calls a function held as an ELEMENT of a collection (#967). The compiler +# answers each with ONE declaration: the element type of `[widen, narrow]` is the union of +# their types, reduced to one arbitrary member, so it names widen and never narrow. A +# call-site answer cannot match that without being wrong at every other element. These +# sites are answered in the ENVELOPE instead (expected/78-callable-collections.envelope, +# `value_call`): every element the collection holds, at the dispatch rung. +pipeline#(?) -> steps#widen(Node) +pipeline#(?,?) -> steps#square(Node) +pipeline#runByKey(Node,'grow' | 'shrink') -> steps#widen(Node) +pipeline#runByName(Node) -> steps#narrow(Node) +pipeline#runFrozen(Node) -> steps#square(Node) +pipeline#runImported(Node) -> steps#halve(Node) +pipeline#runIndexed(Node,number) -> steps#widen(Node) +pipeline#runIterated(Node) -> steps#widen(Node) +pipeline#runRegistry(Node) -> steps#widen(Node) +pipeline#runValues(Node) -> steps#widen(Node) diff --git a/graph/test/typescript/expected/78-callable-collections.oracle b/graph/test/typescript/expected/78-callable-collections.oracle new file mode 100644 index 000000000..82d64eaae --- /dev/null +++ b/graph/test/typescript/expected/78-callable-collections.oracle @@ -0,0 +1,11 @@ +oracle=19 engine=9 agree=9 missing=10 (known 10, NEW 0) extra=0 + known pipeline#(?) -> steps#widen(Node) + known pipeline#(?,?) -> steps#square(Node) + known pipeline#runByKey(Node,'grow' | 'shrink') -> steps#widen(Node) + known pipeline#runByName(Node) -> steps#narrow(Node) + known pipeline#runFrozen(Node) -> steps#square(Node) + known pipeline#runImported(Node) -> steps#halve(Node) + known pipeline#runIndexed(Node,number) -> steps#widen(Node) + known pipeline#runIterated(Node) -> steps#widen(Node) + known pipeline#runRegistry(Node) -> steps#widen(Node) + known pipeline#runValues(Node) -> steps#widen(Node) diff --git a/graph/test/typescript/expected/78-callable-collections.type-use b/graph/test/typescript/expected/78-callable-collections.type-use new file mode 100644 index 000000000..4b9e4a950 --- /dev/null +++ b/graph/test/typescript/expected/78-callable-collections.type-use @@ -0,0 +1,8 @@ +ambiguous_unknown AS_TARGET 0 pipeline [EXPRESSION] -> - +ambiguous_unknown VARIABLE_TYPE 0 pipeline [VARIABLE] -> - +known_edge METHOD_PARAM 0 pipeline [METHOD_PARAM] -> Node +known_edge METHOD_PARAM 0 steps [METHOD_PARAM] -> Node +known_edge METHOD_PARAM 2 pipeline [VARIABLE] -> Node +known_edge METHOD_RETURN 0 pipeline [METHOD] -> Node +known_edge METHOD_RETURN 0 steps [METHOD] -> Node +known_edge METHOD_RETURN 2 pipeline [VARIABLE] -> Node diff --git a/graph/test/typescript/expected/78-callable-collections.types-oracle b/graph/test/typescript/expected/78-callable-collections.types-oracle new file mode 100644 index 000000000..91626944f --- /dev/null +++ b/graph/test/typescript/expected/78-callable-collections.types-oracle @@ -0,0 +1,7 @@ +78-callable-collections [types] + precision 1.0000 (2 correct, 0 wrong) + recall 1.0000 (2 of 2 the compiler resolved) + sites 40 resolved 38 (95.0%) + tiers ambiguous_unknown=2 known_edge=38 + contexts AS_TARGET=1 METHOD_PARAM=20 METHOD_RETURN=18 VARIABLE_TYPE=1 + not scored: 2 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.edges b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.edges new file mode 100644 index 000000000..4c3712657 --- /dev/null +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.edges @@ -0,0 +1,15 @@ +ambient_terminal FUNCTION_CALL app#atStartup() @L38 -> app#onReady(() =) +callback_registered FUNCTION_CALL app#atStartup() @L38 -> app#() +callback_registered METHOD_CALL app#doubled(number[]) @L16 -> app#(?) +callback_registered METHOD_CALL app#named(number[]) @L25 -> app#double(number) +callback_registered METHOD_CALL app#scaled(number[]) @L12 -> app#(?) +callback_registered METHOD_CALL app#viaLocal(number[]) @L31 -> app#log(number) +known_edge FUNCTION_CALL app#() @L38 -> app#record(number) +known_edge FUNCTION_CALL app#(?) @L48 -> app#record(number) +known_edge FUNCTION_CALL app#each(number[],(n: number) =) @L44 -> app#(number) +known_edge FUNCTION_CALL app#log(number) @L30 -> app#record(number) +known_edge FUNCTION_CALL app#useEach(number[]) @L48 -> app#each(number[],(n: number) =) +known_edge METHOD_CALL app#doubled(number[]) @L16 -> Array#map((v: T) =) +known_edge METHOD_CALL app#named(number[]) @L25 -> Array#map((v: T) =) +known_edge METHOD_CALL app#scaled(number[]) @L12 -> Array#map((v: T) =) +known_edge METHOD_CALL app#viaLocal(number[]) @L31 -> Array#forEach((v: T) =) diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.entries b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.entries new file mode 100644 index 000000000..5c3d7421a --- /dev/null +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.entries @@ -0,0 +1,10 @@ +── entry_point (9) ── + exported_from_entry_module app#atStartup app.ts:37 + exported_from_entry_module app#doubled app.ts:15 + exported_from_entry_module app#each app.ts:43 + exported_from_entry_module app#named app.ts:24 + exported_from_entry_module app#scaled app.ts:11 + exported_from_entry_module app#useEach app.ts:47 + exported_from_entry_module app#viaLocal app.ts:29 + unimported_module app# app.ts:1 + unimported_module globals# globals.d.ts:1 diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.envelope b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.envelope new file mode 100644 index 000000000..234b7d617 --- /dev/null +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.envelope @@ -0,0 +1,2 @@ +value app#@43:40 -> app#@48 +value_call app#each -> app#@48 diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.fields b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.fields new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.fields-oracle b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.fields-oracle new file mode 100644 index 000000000..be4f8be64 --- /dev/null +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.fields-oracle @@ -0,0 +1,7 @@ +78-hof-callback-at-library-boundary [fields] + precision 0.0000 (0 correct, 0 wrong) + recall 0.0000 (0 of 0 the compiler resolved) + sites 0 resolved 0 + tiers + access + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.oracle b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.oracle new file mode 100644 index 000000000..f31a2b293 --- /dev/null +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.oracle @@ -0,0 +1 @@ +oracle=10 engine=10 agree=10 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.type-use b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.type-use new file mode 100644 index 000000000..960a82a1b --- /dev/null +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.type-use @@ -0,0 +1,2 @@ +known_edge SUPER_TYPE 0 CallableFunction [HERITAGE] -> Function +known_edge SUPER_TYPE 0 NewableFunction [HERITAGE] -> Function diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.types-oracle b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.types-oracle new file mode 100644 index 000000000..e7a4988d6 --- /dev/null +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.types-oracle @@ -0,0 +1,7 @@ +78-hof-callback-at-library-boundary [types] + precision 1.0000 (2 correct, 0 wrong) + recall 1.0000 (2 of 2 the compiler resolved) + sites 2 resolved 2 (100.0%) + tiers known_edge=2 + contexts SUPER_TYPE=2 + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/78-keyof-constrained-overload.edges b/graph/test/typescript/expected/78-keyof-constrained-overload.edges new file mode 100644 index 000000000..f2c67286a --- /dev/null +++ b/graph/test/typescript/expected/78-keyof-constrained-overload.edges @@ -0,0 +1,7 @@ +ambiguous_unknown METHOD_CALL app#viaKey(Bus) @L10 -> - +ambiguous_unknown METHOD_CALL app#viaMethodKey(Bus) @L14 -> - +ambiguous_unknown METHOD_CALL app#viaNonKey(Bus) @L19 -> - +ambiguous_unknown METHOD_CALL app#viaString(Bus,string) @L24 -> - +ambiguous_unknown METHOD_CALL app#viaWideningFirst(Bus) @L29 -> - +known_edge METHOD_CALL app#viaClassKey(Registry) @L42 -> Registry#get(T) +known_edge METHOD_CALL app#viaClassNonKey(Registry) @L47 -> Registry#get(string) diff --git a/graph/test/typescript/expected/78-keyof-constrained-overload.entries b/graph/test/typescript/expected/78-keyof-constrained-overload.entries new file mode 100644 index 000000000..42001f526 --- /dev/null +++ b/graph/test/typescript/expected/78-keyof-constrained-overload.entries @@ -0,0 +1,9 @@ +── entry_point (8) ── + exported_from_entry_module app#viaClassKey app.ts:41 + exported_from_entry_module app#viaClassNonKey app.ts:46 + exported_from_entry_module app#viaKey app.ts:9 + exported_from_entry_module app#viaMethodKey app.ts:13 + exported_from_entry_module app#viaNonKey app.ts:18 + exported_from_entry_module app#viaString app.ts:23 + exported_from_entry_module app#viaWideningFirst app.ts:28 + unimported_module app# app.ts:1 diff --git a/graph/test/typescript/expected/78-keyof-constrained-overload.fields b/graph/test/typescript/expected/78-keyof-constrained-overload.fields new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/typescript/expected/78-keyof-constrained-overload.fields-oracle b/graph/test/typescript/expected/78-keyof-constrained-overload.fields-oracle new file mode 100644 index 000000000..340173828 --- /dev/null +++ b/graph/test/typescript/expected/78-keyof-constrained-overload.fields-oracle @@ -0,0 +1,7 @@ +78-keyof-constrained-overload [fields] + precision 0.0000 (0 correct, 0 wrong) + recall 0.0000 (0 of 0 the compiler resolved) + sites 0 resolved 0 + tiers + access + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/78-keyof-constrained-overload.lib.edges b/graph/test/typescript/expected/78-keyof-constrained-overload.lib.edges new file mode 100644 index 000000000..95fe6ea54 --- /dev/null +++ b/graph/test/typescript/expected/78-keyof-constrained-overload.lib.edges @@ -0,0 +1,7 @@ +boundary_lib METHOD_CALL app#viaKey(Bus) @L10 -> Bus#emit(T) +boundary_lib METHOD_CALL app#viaMethodKey(Bus) @L14 -> Bus#run(T) +boundary_lib METHOD_CALL app#viaNonKey(Bus) @L19 -> Bus#emit(string) +boundary_lib METHOD_CALL app#viaString(Bus,string) @L24 -> Bus#emit(string) +boundary_lib METHOD_CALL app#viaWideningFirst(Bus) @L29 -> Bus#pick(string) +known_edge METHOD_CALL app#viaClassKey(Registry) @L42 -> Registry#get(T) +known_edge METHOD_CALL app#viaClassNonKey(Registry) @L47 -> Registry#get(string) diff --git a/graph/test/typescript/expected/78-keyof-constrained-overload.lib.oracle b/graph/test/typescript/expected/78-keyof-constrained-overload.lib.oracle new file mode 100644 index 000000000..fa4000b6a --- /dev/null +++ b/graph/test/typescript/expected/78-keyof-constrained-overload.lib.oracle @@ -0,0 +1 @@ +oracle=7 engine=7 agree=7 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/typescript/expected/78-keyof-constrained-overload.oracle b/graph/test/typescript/expected/78-keyof-constrained-overload.oracle new file mode 100644 index 000000000..036d48375 --- /dev/null +++ b/graph/test/typescript/expected/78-keyof-constrained-overload.oracle @@ -0,0 +1 @@ +oracle=2 engine=2 agree=2 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/typescript/expected/78-keyof-constrained-overload.type-use b/graph/test/typescript/expected/78-keyof-constrained-overload.type-use new file mode 100644 index 000000000..3ca059ffe --- /dev/null +++ b/graph/test/typescript/expected/78-keyof-constrained-overload.type-use @@ -0,0 +1,4 @@ +ambiguous_unknown METHOD_PARAM 0 app [METHOD_PARAM] -> - +known_edge METHOD_PARAM 0 app [METHOD_PARAM] -> Registry +known_edge TYPE_ELEMENT 1 Registry [METHOD] -> Slots +known_edge TYPE_ELEMENT 1 Registry [TYPE_PARAMETER] -> Slots diff --git a/graph/test/typescript/expected/78-keyof-constrained-overload.types-oracle b/graph/test/typescript/expected/78-keyof-constrained-overload.types-oracle new file mode 100644 index 000000000..79655ad19 --- /dev/null +++ b/graph/test/typescript/expected/78-keyof-constrained-overload.types-oracle @@ -0,0 +1,7 @@ +78-keyof-constrained-overload [types] + precision 1.0000 (2 correct, 0 wrong) + recall 1.0000 (2 of 2 the compiler resolved) + sites 9 resolved 4 (44.4%) + tiers ambiguous_unknown=5 known_edge=4 + contexts METHOD_PARAM=7 TYPE_ELEMENT=2 + not scored: 5 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/78-logical-default-callback.edges b/graph/test/typescript/expected/78-logical-default-callback.edges new file mode 100644 index 000000000..3af8f46c6 --- /dev/null +++ b/graph/test/typescript/expected/78-logical-default-callback.edges @@ -0,0 +1,11 @@ +ambiguous_unknown METHOD_CALL options#(?) @L46 -> - +ambiguous_unknown METHOD_CALL options#defaultFormat(number,number) @L18 -> - +ambiguous_unknown METHOD_CALL options#defaultOnError(string) @L15 -> - +ambiguous_unknown METHOD_CALL options#tagList(Options) @L46 -> - +callback_registered METHOD_CALL options#tagList(Options) @L46 -> options#(?) +known_edge FUNCTION_CALL options#guarded(boolean,Options) @L53 -> options#(string) +known_edge FUNCTION_CALL options#plain(Options) @L39 -> options#(string) +known_edge FUNCTION_CALL options#reportWith(Callable) @L67 -> options#(number) +known_edge FUNCTION_CALL options#withLet(Options) @L33 -> options#(string) +known_edge FUNCTION_CALL options#withNullish(Options) @L27 -> options#(number,number) +known_edge FUNCTION_CALL options#withOr(Options) @L22 -> options#(string) diff --git a/graph/test/typescript/expected/78-logical-default-callback.entries b/graph/test/typescript/expected/78-logical-default-callback.entries new file mode 100644 index 000000000..e2d98ae70 --- /dev/null +++ b/graph/test/typescript/expected/78-logical-default-callback.entries @@ -0,0 +1,10 @@ +── entry_point (9) ── + exported_from_entry_module options#defaultOnError options.ts:14 + exported_from_entry_module options#guarded options.ts:51 + exported_from_entry_module options#plain options.ts:37 + exported_from_entry_module options#reportWith options.ts:65 + exported_from_entry_module options#tagList options.ts:44 + exported_from_entry_module options#withLet options.ts:31 + exported_from_entry_module options#withNullish options.ts:25 + exported_from_entry_module options#withOr options.ts:20 + unimported_module options# options.ts:1 diff --git a/graph/test/typescript/expected/78-logical-default-callback.envelope b/graph/test/typescript/expected/78-logical-default-callback.envelope new file mode 100644 index 000000000..8021f74a5 --- /dev/null +++ b/graph/test/typescript/expected/78-logical-default-callback.envelope @@ -0,0 +1,5 @@ +value options#@5:28 -> options#defaultOnError +value options#@6:25 -> options#@18 +value_call options#withLet -> options#defaultOnError +value_call options#withNullish -> options#@18 +value_call options#withOr -> options#defaultOnError diff --git a/graph/test/typescript/expected/78-logical-default-callback.fields b/graph/test/typescript/expected/78-logical-default-callback.fields new file mode 100644 index 000000000..3aaedddd2 --- /dev/null +++ b/graph/test/typescript/expected/78-logical-default-callback.fields @@ -0,0 +1,7 @@ +known_edge read options#guarded(boolean,Options) -> Options#onError +known_edge read options#plain(Options) -> Options#onError +known_edge read options#reportWith(Callable) -> Callable#report +known_edge read options#tagList(Options) -> Options#tags +known_edge read options#withLet(Options) -> Options#onError +known_edge read options#withNullish(Options) -> Options#format +known_edge read options#withOr(Options) -> Options#onError diff --git a/graph/test/typescript/expected/78-logical-default-callback.fields-oracle b/graph/test/typescript/expected/78-logical-default-callback.fields-oracle new file mode 100644 index 000000000..49aa2be44 --- /dev/null +++ b/graph/test/typescript/expected/78-logical-default-callback.fields-oracle @@ -0,0 +1,7 @@ +78-logical-default-callback [fields] + precision 1.0000 (7 correct, 0 wrong) + recall 1.0000 (7 of 7 the compiler resolved) + sites 7 resolved 7 (100.0%) + tiers known_edge=7 + access read=7 + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/78-logical-default-callback.oracle b/graph/test/typescript/expected/78-logical-default-callback.oracle new file mode 100644 index 000000000..4794eb8a3 --- /dev/null +++ b/graph/test/typescript/expected/78-logical-default-callback.oracle @@ -0,0 +1 @@ +oracle=6 engine=6 agree=6 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/typescript/expected/78-logical-default-callback.type-use b/graph/test/typescript/expected/78-logical-default-callback.type-use new file mode 100644 index 000000000..874a49357 --- /dev/null +++ b/graph/test/typescript/expected/78-logical-default-callback.type-use @@ -0,0 +1,5 @@ +known_edge FIELD_TYPE 0 Callable [FIELD] -> Reporter +known_edge FIELD_TYPE 0 Options [FIELD] -> ErrorHandler +known_edge FIELD_TYPE 0 Options [FIELD] -> Formatter +known_edge METHOD_PARAM 0 options [METHOD_PARAM] -> Callable +known_edge METHOD_PARAM 0 options [METHOD_PARAM] -> Options diff --git a/graph/test/typescript/expected/78-logical-default-callback.types-oracle b/graph/test/typescript/expected/78-logical-default-callback.types-oracle new file mode 100644 index 000000000..c99e933e4 --- /dev/null +++ b/graph/test/typescript/expected/78-logical-default-callback.types-oracle @@ -0,0 +1,7 @@ +78-logical-default-callback [types] + precision 1.0000 (5 correct, 0 wrong) + recall 1.0000 (5 of 5 the compiler resolved) + sites 10 resolved 10 (100.0%) + tiers known_edge=10 + contexts FIELD_TYPE=3 METHOD_PARAM=7 + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/78-merged-interface-later-block-first.edges b/graph/test/typescript/expected/78-merged-interface-later-block-first.edges new file mode 100644 index 000000000..0090fca3b --- /dev/null +++ b/graph/test/typescript/expected/78-merged-interface-later-block-first.edges @@ -0,0 +1,8 @@ +ambiguous_unknown METHOD_CALL repro#use(Text,string,Plain) @L5 -> - +known_edge METHOD_CALL repro#strict(Strict,Bare) @L14 -> Strict#hold(Bare) +known_edge METHOD_CALL repro#typed(Typed,Rx) @L10 -> Typed#take(Matcher) +known_edge METHOD_CALL repro#use(Text,string,Plain) @L6 -> Plain#pick(RegExp) +known_edge METHOD_CALL twice#run(Twice,Once,Rx,Bare) @L10 -> Twice#go(Matcher) +known_edge METHOD_CALL twice#run(Twice,Once,Rx,Bare) @L11 -> Once#keep(Bare) +multi_inferred METHOD_CALL repro#use(Text,string,Plain) @L4 -> Text#find(Matcher) +multi_inferred METHOD_CALL repro#use(Text,string,Plain) @L4 -> Text#find(RegExp) diff --git a/graph/test/typescript/expected/78-merged-interface-later-block-first.entries b/graph/test/typescript/expected/78-merged-interface-later-block-first.entries new file mode 100644 index 000000000..5b6b2fc87 --- /dev/null +++ b/graph/test/typescript/expected/78-merged-interface-later-block-first.entries @@ -0,0 +1,7 @@ +── entry_point (6) ── + exported_from_entry_module repro#strict repro.ts:13 + exported_from_entry_module repro#typed repro.ts:9 + exported_from_entry_module repro#use repro.ts:3 + exported_from_entry_module twice#run twice.ts:9 + unimported_module repro# repro.ts:1 + unimported_module twice# twice.ts:1 diff --git a/graph/test/typescript/expected/78-merged-interface-later-block-first.envelope b/graph/test/typescript/expected/78-merged-interface-later-block-first.envelope new file mode 100644 index 000000000..dcced6240 --- /dev/null +++ b/graph/test/typescript/expected/78-merged-interface-later-block-first.envelope @@ -0,0 +1 @@ +structural text#Matcher.exec -> text#Rx.exec diff --git a/graph/test/typescript/expected/78-merged-interface-later-block-first.fields b/graph/test/typescript/expected/78-merged-interface-later-block-first.fields new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/typescript/expected/78-merged-interface-later-block-first.fields-oracle b/graph/test/typescript/expected/78-merged-interface-later-block-first.fields-oracle new file mode 100644 index 000000000..c938f6100 --- /dev/null +++ b/graph/test/typescript/expected/78-merged-interface-later-block-first.fields-oracle @@ -0,0 +1,7 @@ +78-merged-interface-later-block-first [fields] + precision 0.0000 (0 correct, 0 wrong) + recall 0.0000 (0 of 0 the compiler resolved) + sites 0 resolved 0 + tiers + access + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/78-merged-interface-later-block-first.oracle b/graph/test/typescript/expected/78-merged-interface-later-block-first.oracle new file mode 100644 index 000000000..911a5160e --- /dev/null +++ b/graph/test/typescript/expected/78-merged-interface-later-block-first.oracle @@ -0,0 +1,2 @@ +oracle=6 engine=7 agree=6 missing=0 (known 0, NEW 0) extra=1 + extra repro#use(Text,string,Plain) -> Text#find(RegExp) diff --git a/graph/test/typescript/expected/78-merged-interface-later-block-first.type-use b/graph/test/typescript/expected/78-merged-interface-later-block-first.type-use new file mode 100644 index 000000000..e900db3ef --- /dev/null +++ b/graph/test/typescript/expected/78-merged-interface-later-block-first.type-use @@ -0,0 +1,21 @@ +ambiguous_unknown METHOD_PARAM 0 Plain [METHOD_PARAM] -> - +ambiguous_unknown METHOD_PARAM 0 Text [METHOD_PARAM] -> - +known_edge METHOD_PARAM 0 Once [METHOD_PARAM] -> Bare +known_edge METHOD_PARAM 0 Once [METHOD_PARAM] -> Matcher +known_edge METHOD_PARAM 0 Strict [METHOD_PARAM] -> Bare +known_edge METHOD_PARAM 0 Strict [METHOD_PARAM] -> Matcher +known_edge METHOD_PARAM 0 Text [METHOD_PARAM] -> Matcher +known_edge METHOD_PARAM 0 Twice [METHOD_PARAM] -> Matcher +known_edge METHOD_PARAM 0 Twice [METHOD_PARAM] -> Rx +known_edge METHOD_PARAM 0 Typed [METHOD_PARAM] -> Matcher +known_edge METHOD_PARAM 0 Typed [METHOD_PARAM] -> Rx +known_edge METHOD_PARAM 0 repro [METHOD_PARAM] -> Bare +known_edge METHOD_PARAM 0 repro [METHOD_PARAM] -> Plain +known_edge METHOD_PARAM 0 repro [METHOD_PARAM] -> Rx +known_edge METHOD_PARAM 0 repro [METHOD_PARAM] -> Strict +known_edge METHOD_PARAM 0 repro [METHOD_PARAM] -> Text +known_edge METHOD_PARAM 0 repro [METHOD_PARAM] -> Typed +known_edge METHOD_PARAM 0 twice [METHOD_PARAM] -> Bare +known_edge METHOD_PARAM 0 twice [METHOD_PARAM] -> Rx +multi_inferred METHOD_PARAM 0 twice [METHOD_PARAM] -> Once +multi_inferred METHOD_PARAM 0 twice [METHOD_PARAM] -> Twice diff --git a/graph/test/typescript/expected/78-merged-interface-later-block-first.types-oracle b/graph/test/typescript/expected/78-merged-interface-later-block-first.types-oracle new file mode 100644 index 000000000..3001c866d --- /dev/null +++ b/graph/test/typescript/expected/78-merged-interface-later-block-first.types-oracle @@ -0,0 +1,7 @@ +78-merged-interface-later-block-first [types] + precision 1.0000 (19 correct, 0 wrong) + recall 1.0000 (19 of 19 the compiler resolved) + sites 21 resolved 19 (90.5%) + tiers ambiguous_unknown=2 known_edge=17 multi_inferred=4 + contexts METHOD_PARAM=23 + not scored: 2 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/79-function-typed-parameter-dispatch.edges b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.edges new file mode 100644 index 000000000..72ba632ed --- /dev/null +++ b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.edges @@ -0,0 +1,17 @@ +ambiguous_unknown FUNCTION_CALL logger#controlUnionKey(StoreApi['getState' | 'getInitialState'],Pick1) @L30 -> - +ambiguous_unknown FUNCTION_CALL logger#controlUnionKey(StoreApi['getState' | 'getInitialState'],Pick1) @L31 -> - +ambiguous_unknown METHOD_CALL logger#(?,?,?) @L12 -> - +ambiguous_unknown METHOD_CALL logger#logged(any,any) @L10 -> - +ambiguous_unknown METHOD_CALL store#setState(T | Partial,boolean) @L36 -> - +callback_registered FUNCTION_CALL logger#(?,?,?) @L13 -> logger#logged(any,any) +callback_registered FUNCTION_CALL store#createStore(StateCreator) @L42 -> store#getState() +callback_registered FUNCTION_CALL store#createStore(StateCreator) @L42 -> store#setState(T | Partial,boolean) +known_edge FUNCTION_CALL logger#(?,?,?) @L13 -> store#(Get,Get,Mutate) +known_edge FUNCTION_CALL logger#explicitlyTyped(StoreApi['setState'],StoreApi['getState']) @L19 -> store#_(T | Partial,false) +known_edge FUNCTION_CALL logger#explicitlyTyped(StoreApi['setState'],StoreApi['getState']) @L20 -> StoreApi#() +known_edge FUNCTION_CALL logger#logged(any,any) @L10 -> StoreApi#() +known_edge FUNCTION_CALL store#() @L39 -> StoreApi#(T,T) +known_edge FUNCTION_CALL store#createStore(StateCreator) @L42 -> store#(Get,Get,Mutate) +known_edge METHOD_CALL logger#(?,?,?) @L12 -> StoreApi#() +multi_inferred FUNCTION_CALL logger#logged(any,any) @L9 -> store#_(T | Partial,false) +multi_inferred FUNCTION_CALL logger#logged(any,any) @L9 -> store#_(T,true) diff --git a/graph/test/typescript/expected/79-function-typed-parameter-dispatch.entries b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.entries new file mode 100644 index 000000000..2c5650045 --- /dev/null +++ b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.entries @@ -0,0 +1,4 @@ +── entry_point (3) ── + exported_from_entry_module logger#controlUnionKey logger.ts:26 + exported_from_entry_module logger#explicitlyTyped logger.ts:18 + unimported_module logger# logger.ts:1 diff --git a/graph/test/typescript/expected/79-function-typed-parameter-dispatch.envelope b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.envelope new file mode 100644 index 000000000..871cfca1c --- /dev/null +++ b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.envelope @@ -0,0 +1,7 @@ +value logger#@5:19 -> logger#@7 +value store#@14:13 -> store#@38 +value store#@15:20 -> store#@40 +value store#@16:14 -> store#@39 +value store#_@8:3 -> store#@35 +value store#_@9:3 -> store#@35 +value_call logger#@7 -> store#@38 diff --git a/graph/test/typescript/expected/79-function-typed-parameter-dispatch.fields b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.fields new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/typescript/expected/79-function-typed-parameter-dispatch.fields-oracle b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.fields-oracle new file mode 100644 index 000000000..049975801 --- /dev/null +++ b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.fields-oracle @@ -0,0 +1,8 @@ +79-function-typed-parameter-dispatch [fields] + precision 0.0000 (0 correct, 0 wrong) + recall 0.0000 (0 of 1 the compiler resolved) + sites 0 resolved 0 + tiers + access + not scored: 0 rows whose target is not a client declaration + MISSING logger#(?,?,?) READ StoreApi#getState diff --git a/graph/test/typescript/expected/79-function-typed-parameter-dispatch.known-missing b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.known-missing new file mode 100644 index 000000000..391cbfd5a --- /dev/null +++ b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.known-missing @@ -0,0 +1,7 @@ +# Accepted gaps for 79-function-typed-parameter-dispatch — each line is an edge the TypeScript compiler +# resolves and this engine does not. A NEW missing edge fails the suite; +# a line here that STARTS working also fails, so the debt cannot rot. +# +# CONTROL: an indexed access whose key is a UNION names several members. The compiler merges the two +# identical signatures into one; the engine declines to pick a member for a non-literal key. +logger#controlUnionKey(StoreApi['getState' | 'getInitialState'],Pick1) -> StoreApi#() diff --git a/graph/test/typescript/expected/79-function-typed-parameter-dispatch.oracle b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.oracle new file mode 100644 index 000000000..1c5f6e451 --- /dev/null +++ b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.oracle @@ -0,0 +1,3 @@ +oracle=9 engine=9 agree=8 missing=1 (known 1, NEW 0) extra=1 + known logger#controlUnionKey(StoreApi['getState' | 'getInitialState'],Pick1) -> StoreApi#() + extra logger#logged(any,any) -> store#_(T,true) diff --git a/graph/test/typescript/expected/79-function-typed-parameter-dispatch.type-use b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.type-use new file mode 100644 index 000000000..342764e7b --- /dev/null +++ b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.type-use @@ -0,0 +1,22 @@ +ambiguous_unknown AS_TARGET 0 store [EXPRESSION] -> - +ambiguous_unknown TYPE_ARGUMENT 2 LoggerImpl [TYPE] -> - +ambiguous_unknown TYPE_ELEMENT 1 store [METHOD_PARAM] -> - +known_edge CONDITIONAL_TRUE 2 Mutate [TYPE] -> Mutate +known_edge FIELD_TYPE 0 StoreApi [FIELD] -> SetStateInternal +known_edge METHOD_PARAM 0 logger [METHOD_PARAM] -> Pick1 +known_edge METHOD_PARAM 0 store [METHOD_PARAM] -> StateCreator +known_edge METHOD_PARAM 1 LoggerImpl [TYPE] -> StateCreator +known_edge METHOD_PARAM 3 StateCreator [TYPE] -> Get +known_edge METHOD_PARAM 3 StateCreator [TYPE] -> Mutate +known_edge METHOD_RETURN 0 store [METHOD] -> StoreApi +known_edge METHOD_RETURN 1 LoggerImpl [TYPE] -> StateCreator +known_edge TYPE_ARGUMENT 1 logger [METHOD_PARAM] -> StoreApi +known_edge TYPE_ARGUMENT 4 StateCreator [TYPE] -> Mutate +known_edge TYPE_ARGUMENT 4 StateCreator [TYPE] -> StoreApi +known_edge TYPE_ARGUMENT 5 StateCreator [TYPE] -> StoreApi +known_edge TYPE_ELEMENT 1 logger [METHOD_PARAM] -> StoreApi +known_edge TYPE_ELEMENT 1 store [VARIABLE] -> StoreApi +known_edge TYPE_ELEMENT 4 Mutate [TYPE] -> StoreMutators +known_edge TYPE_ELEMENT 6 Mutate [TYPE] -> StoreMutators +known_edge VARIABLE_TYPE 0 logger [VARIABLE] -> LoggerImpl +known_edge VARIABLE_TYPE 0 store [VARIABLE] -> StoreApi diff --git a/graph/test/typescript/expected/79-function-typed-parameter-dispatch.types-oracle b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.types-oracle new file mode 100644 index 000000000..2c07f6cbd --- /dev/null +++ b/graph/test/typescript/expected/79-function-typed-parameter-dispatch.types-oracle @@ -0,0 +1,7 @@ +79-function-typed-parameter-dispatch [types] + precision 1.0000 (12 correct, 0 wrong) + recall 1.0000 (12 of 12 the compiler resolved) + sites 32 resolved 27 (84.4%) + tiers ambiguous_unknown=5 known_edge=27 + contexts AS_TARGET=1 CONDITIONAL_TRUE=1 FIELD_TYPE=1 METHOD_PARAM=6 METHOD_RETURN=2 TYPE_ARGUMENT=8 TYPE_ELEMENT=11 VARIABLE_TYPE=2 + not scored: 5 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/79-package-published-members.edges b/graph/test/typescript/expected/79-package-published-members.edges new file mode 100644 index 000000000..d06a307fe --- /dev/null +++ b/graph/test/typescript/expected/79-package-published-members.edges @@ -0,0 +1,4 @@ +ambiguous_unknown METHOD_CALL src/bound#() @L11 -> - +known_edge CONSTRUCTOR_CALL Engine#static make() @L5 -> Engine#() +known_edge CONSTRUCTOR_CALL Store#static create() @L4 -> Store#() +known_edge CONSTRUCTOR_CALL src/bound#() @L9 -> Engine#() diff --git a/graph/test/typescript/expected/79-package-published-members.entries b/graph/test/typescript/expected/79-package-published-members.entries new file mode 100644 index 000000000..90dc4e907 --- /dev/null +++ b/graph/test/typescript/expected/79-package-published-members.entries @@ -0,0 +1,12 @@ +── entry_point (11) ── + package_export src/anon# anon.ts:2 + package_export src/api# api.ts:4 + package_export src/api#method api.ts:3 + package_export src/bound#Engine.finish bound.ts:4 + package_export src/bound#Engine.make bound.ts:5 + package_export src/bound#Engine.produce bound.ts:3 + package_export src/cls#Store. cls.ts:2 + package_export src/cls#Store.create cls.ts:4 + package_export src/cls#Store.get cls.ts:3 + package_export src/ns#Result.ok ns.ts:3 + unimported_module src/index.test# index.test.ts:1 diff --git a/graph/test/typescript/expected/79-package-published-members.fields b/graph/test/typescript/expected/79-package-published-members.fields new file mode 100644 index 000000000..e69de29bb diff --git a/graph/test/typescript/expected/79-package-published-members.fields-oracle b/graph/test/typescript/expected/79-package-published-members.fields-oracle new file mode 100644 index 000000000..bf1e0d9b3 --- /dev/null +++ b/graph/test/typescript/expected/79-package-published-members.fields-oracle @@ -0,0 +1,7 @@ +79-package-published-members [fields] + precision 0.0000 (0 correct, 0 wrong) + recall 0.0000 (0 of 0 the compiler resolved) + sites 0 resolved 0 + tiers + access + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/79-package-published-members.oracle b/graph/test/typescript/expected/79-package-published-members.oracle new file mode 100644 index 000000000..a43ea4f88 --- /dev/null +++ b/graph/test/typescript/expected/79-package-published-members.oracle @@ -0,0 +1 @@ +oracle=3 engine=3 agree=3 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/typescript/expected/79-package-published-members.type-use b/graph/test/typescript/expected/79-package-published-members.type-use new file mode 100644 index 000000000..51ae065ce --- /dev/null +++ b/graph/test/typescript/expected/79-package-published-members.type-use @@ -0,0 +1,3 @@ +known_edge OBJECT_CREATION_TYPE 0 Engine [EXPRESSION] -> Engine +known_edge OBJECT_CREATION_TYPE 0 Store [EXPRESSION] -> Store +known_edge OBJECT_CREATION_TYPE 0 src/bound [EXPRESSION] -> Engine diff --git a/graph/test/typescript/expected/79-package-published-members.types-oracle b/graph/test/typescript/expected/79-package-published-members.types-oracle new file mode 100644 index 000000000..1485da222 --- /dev/null +++ b/graph/test/typescript/expected/79-package-published-members.types-oracle @@ -0,0 +1,7 @@ +79-package-published-members [types] + precision 1.0000 (3 correct, 0 wrong) + recall 1.0000 (3 of 3 the compiler resolved) + sites 3 resolved 3 (100.0%) + tiers known_edge=3 + contexts OBJECT_CREATION_TYPE=3 + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/run-tests.sh b/graph/test/typescript/run-tests.sh index c3af71d29..ae67e28e8 100755 --- a/graph/test/typescript/run-tests.sh +++ b/graph/test/typescript/run-tests.sh @@ -32,6 +32,7 @@ # # Environment: # AXIOM_PARSER path to the parser entrypoint (default parser/dist/index.js — the parser in this repository) +# AXIOM_SUITE_JOBS cases run at once (default: the CPU count; 1 = one at a time, output uncaptured) # ───────────────────────────────────────────────────────────────────────────── set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" @@ -215,6 +216,9 @@ fi # A `.source-root` the reader cannot resolve keys every staged declaration under a path # nothing else names, and the empty join that follows is reported as a plausible rate # over the client's own files rather than as a failure (#342). +# (The counters start here, before the first check that adds to them: they were initialised +# after it, so a failure here was counted and then reset to zero.) +pass=0; fail=0; failed=() if ! python3 "$HERE/tools/source_root_test.py"; then echo "source-root: FAILED" fail=$((fail+1)); failed+=("source-root") @@ -244,6 +248,8 @@ if ! bash "$HERE/tools/envelope-merge-test.sh"; then fi PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" WORK="$HERE/.work" +# shellcheck source=../tools/case-pool.sh +. "$ROOT/graph/test/tools/case-pool.sh" BLESS=0; KEEP=0; ORACLE=0; FILTERS=() for a in "$@"; do case "$a" in --bless) BLESS=1;; --keep) KEEP=1;; --oracle) ORACLE=1;; @@ -283,8 +289,6 @@ mkdir -p "$WORK" # library that declares nothing, so no client->lib edge can exist. EMPTY_LIB="$WORK/.empty-library"; mkdir -p "$EMPTY_LIB" -pass=0; fail=0; failed=() - # solve ; leaves edges in /out solve() { bash "$ROOT/graph/pipeline/run-souffle.sh" --debug --language typescript \ @@ -306,7 +310,11 @@ check_golden() { echo "FAIL ($label changed)"; diff -u "$exp" "$actual" | sed 's/^/ /' | head -40; return 1 } -for dir in "$HERE"/cases/*/; do +# ── ONE CASE ──────────────────────────────────────────────────────────────── +# The body of what was the case loop, unchanged, inside a one-case `for` so its `continue`s +# still mean "next case". pool_run (graph/test/tools/case-pool.sh) runs several at once. +case_body() { +for dir in "$@"; do name="$(basename "$dir")" if [ ${#FILTERS[@]} -gt 0 ]; then match=0; for f in "${FILTERS[@]}"; do [[ "$name" == *"$f"* ]] && match=1; done @@ -478,94 +486,110 @@ for dir in "$HERE"/cases/*/; do n2=0; [ "$HAS_LIB" = "1" ] && n2=$(wc -l < "$w/actual.lib.edges" | tr -d ' ') echo "ok (${n1} edges, ${n2} with lib)${orc}"; pass=$((pass+1)) done +} -# ── the LINKING gate ───────────────────────────────────────────────────────── -# Run here rather than left to be remembered. The golden cases above parse a case's -# own `lib/` directory; they never install a PACKAGE, so nothing in them exercises -# module resolution, staging discovery, or a non-flat node_modules — the whole -# client->library boundary. That gate lived in fixtures/linking/run.sh and was not -# invoked by anything, so this suite could report 20/20 green while every linking -# mechanism was broken. A gate nobody runs is not a gate. -# +# Asked once, up front, and passed explicitly. The fixture's own default used to be an +# absolute path inside one developer's home directory and this call never passed the +# argument, so anywhere else `typescript/lib` was never staged, the global scope was +# empty, and the MANDATORY baseline failed with three assertions that read as engine +# defects in native resolution rather than as a missing argument (#331). +NODE_MODULES="" +[ "$BLESS" = "1" ] || NODE_MODULES="$(bash "$HERE/tools/find-node-modules.sh" || true)" + +# ── ONE FIXTURE ───────────────────────────────────────────────────────────── +# Each fixture is a job like a case: its own work directory ($WORK-) and log, so +# they run beside the cases and each other, and print in the order below. +fixture_job() { + local fx="$1" rc + case "$fx" in + linking) + echo + [ -n "$NODE_MODULES" ] || echo " ! no node_modules with a typescript found — fixtures will say so" + echo "── linking fixture ──" + if bash "$HERE/fixtures/linking/run.sh" "${WORK:-/tmp/ts-linking-fixture}-linking" "$NODE_MODULES" >"$WORK-linking.log" 2>&1; then + echo "linking fixture: ok" + else + echo "linking fixture: FAILED" + grep -E '^FAIL' "$WORK-linking.log" | sed 's/^/ /' || tail -5 "$WORK-linking.log" | sed 's/^/ /' + fail=$((fail+1)); failed+=("linking-fixture") + fi;; + dispatch|typeflow|overloads|specificity) + echo + echo "── $fx fixture ──" + bash "$HERE/fixtures/$fx/run.sh" "${WORK:-/tmp/ts-$fx}-$fx" "$NODE_MODULES" >"$WORK-$fx.log" 2>&1 + rc=$? + if [ "$rc" -eq 0 ]; then + echo "$fx fixture: ok" + elif [ "$rc" -eq 77 ]; then + echo "$fx fixture: SKIPPED ($(tail -1 "$WORK-$fx.log"))" + else + echo "$fx fixture: FAILED" + grep -E '^FAIL|^ *!' "$WORK-$fx.log" | sed 's/^/ /' || tail -5 "$WORK-$fx.log" | sed 's/^/ /' + fail=$((fail+1)); failed+=("$fx-fixture") + fi;; + tsconfig-chain|multi-program) + echo + echo "── $fx fixture ──" + bash "$HERE/fixtures/$fx/run.sh" "${WORK:-/tmp/ts-$fx}-$fx" "$PARSER" \ + >"$WORK-$fx.log" 2>&1 + rc=$? + if [ "$rc" -eq 0 ]; then + echo "$fx fixture: ok" + elif [ "$rc" -eq 77 ]; then + echo "$fx fixture: SKIPPED ($(tail -1 "$WORK-$fx.log"))" + else + echo "$fx fixture: FAILED" + grep -E '^FAIL' "$WORK-$fx.log" | sed 's/^/ /' || tail -5 "$WORK-$fx.log" | sed 's/^/ /' + fail=$((fail+1)); failed+=("$fx-fixture") + fi;; + esac +} + +suite_job() { case "$1" in fixture:*) fixture_job "${1#fixture:}";; *) case_body "$1";; esac; } + +# ── THE FIXTURES ──────────────────────────────────────────────────────────── +# `linking`: the golden cases parse a case's own `lib/` directory; they never install a +# PACKAGE, so nothing in them exercises module resolution, staging discovery, or a +# non-flat node_modules — the whole client->library boundary. That gate lived in +# fixtures/linking/run.sh and was not invoked by anything, so this suite could report +# 20/20 green while every linking mechanism was broken. A gate nobody runs is not a gate. # Skipped, loudly, when the fixture cannot build (it needs a real `typescript` to # symlink); never silently passed. +# +# THE FOUR GATES NOTHING RAN. The same reasoning was acted on for `linking` and these four +# were left, so the suite could report green while any of the four mechanisms was broken. +# All four pass today and take about six seconds each — they are unrun, not rotten (#332). +# Each isolates a question the per-case goldens cannot ask: +# dispatch an interface-typed receiver, where the compiler names the SIGNATURE +# and the engine emits the reachable BODIES — two different right +# answers, deliberately kept apart +# typeflow every way a receiver acquires a type other than being annotated, +# with member names shared on purpose so a lucky name match cannot pass +# overloads one overload set reached through a barrel re-export and by direct +# import, so a difference between the two consumers is the module graph +# rather than the overload logic +# specificity generic-first overload sets in both directions, built because the +# obvious fix for the largest corpus failure class is wrong +# Their second argument is a node_modules to stage a `typescript` from, discovered rather +# than defaulted to one developer's home directory (#331). Empty means the fixture falls +# back to its own discovery and says so. +# +# tsconfig-chain and multi-program are harness gates that take a parser and assert a +# property of the pipeline itself. Neither is expressible as a case: a case carries its own +# src/tsconfig.json with nothing to extend (#240), and none installs a package with several +# programs (#230). Exit 77 is a fixture declining for want of a parser — not a pass and not +# a failure. +# +# Not under --bless: they have no goldens to regenerate. +JOBS=("$HERE"/cases/*/) if [ "$BLESS" != "1" ]; then - echo - # Asked once, here, and passed explicitly. The fixture's own default used to be an - # absolute path inside one developer's home directory and this call never passed the - # argument, so anywhere else `typescript/lib` was never staged, the global scope was - # empty, and the MANDATORY baseline failed with three assertions that read as engine - # defects in native resolution rather than as a missing argument (#331). - NODE_MODULES="$(bash "$HERE/tools/find-node-modules.sh" || true)" - [ -n "$NODE_MODULES" ] || echo " ! no node_modules with a typescript found — fixtures will say so" - - echo "── linking fixture ──" - if bash "$HERE/fixtures/linking/run.sh" "${WORK:-/tmp/ts-linking-fixture}-linking" "$NODE_MODULES" >"$WORK-linking.log" 2>&1; then - echo "linking fixture: ok" - else - echo "linking fixture: FAILED" - grep -E '^FAIL' "$WORK-linking.log" | sed 's/^/ /' || tail -5 "$WORK-linking.log" | sed 's/^/ /' - fail=$((fail+1)); failed+=("linking-fixture") - fi - - # ── THE FOUR GATES NOTHING RAN ────────────────────────────────────────────── - # The comment above says it for the fifth: a gate nobody runs is not a gate. That - # reasoning was acted on for `linking` and these four were left, so the suite could - # report green while any of the four mechanisms was broken. All four pass today and - # take about six seconds each — they are unrun, not rotten (#332). - # - # Each isolates a question the per-case goldens cannot ask: - # dispatch an interface-typed receiver, where the compiler names the SIGNATURE - # and the engine emits the reachable BODIES — two different right - # answers, deliberately kept apart - # typeflow every way a receiver acquires a type other than being annotated, - # with member names shared on purpose so a lucky name match cannot pass - # overloads one overload set reached through a barrel re-export and by direct - # import, so a difference between the two consumers is the module graph - # rather than the overload logic - # specificity generic-first overload sets in both directions, built because the - # obvious fix for the largest corpus failure class is wrong - # - # Second argument is a node_modules to stage a `typescript` from, discovered rather - # than defaulted to one developer's home directory (#331). Empty means the fixture - # falls back to its own discovery and says so. - for fx in dispatch typeflow overloads specificity; do - echo - echo "── $fx fixture ──" - bash "$HERE/fixtures/$fx/run.sh" "${WORK:-/tmp/ts-$fx}-$fx" "$NODE_MODULES" >"$WORK-$fx.log" 2>&1 - rc=$? - if [ "$rc" -eq 0 ]; then - echo "$fx fixture: ok" - elif [ "$rc" -eq 77 ]; then - echo "$fx fixture: SKIPPED ($(tail -1 "$WORK-$fx.log"))" - else - echo "$fx fixture: FAILED" - grep -E '^FAIL|^ *!' "$WORK-$fx.log" | sed 's/^/ /' || tail -5 "$WORK-$fx.log" | sed 's/^/ /' - fail=$((fail+1)); failed+=("$fx-fixture") - fi - done - - # Harness gates that take a parser and assert a property of the pipeline itself. - # Neither is expressible as a case: a case carries its own src/tsconfig.json with - # nothing to extend (#240), and none installs a package with several programs (#230). - # Exit 77 is a fixture declining for want of a parser — not a pass and not a failure. - for fx in tsconfig-chain multi-program; do - echo - echo "── $fx fixture ──" - bash "$HERE/fixtures/$fx/run.sh" "${WORK:-/tmp/ts-$fx}-$fx" "$PARSER" \ - >"$WORK-$fx.log" 2>&1 - rc=$? - if [ "$rc" -eq 0 ]; then - echo "$fx fixture: ok" - elif [ "$rc" -eq 77 ]; then - echo "$fx fixture: SKIPPED ($(tail -1 "$WORK-$fx.log"))" - else - echo "$fx fixture: FAILED" - grep -E '^FAIL' "$WORK-$fx.log" | sed 's/^/ /' || tail -5 "$WORK-$fx.log" | sed 's/^/ /' - fail=$((fail+1)); failed+=("$fx-fixture") - fi + for fx in linking dispatch typeflow overloads specificity tsconfig-chain multi-program; do + JOBS+=("fixture:$fx") done fi +echo "running with up to $(pool_jobs) job(s) at once (AXIOM_SUITE_JOBS; 1 = one at a time)" +POOL_INTS="pass fail" POOL_ARRAYS="failed" +pool_run suite_job "${JOBS[@]}" [ "$KEEP" = "1" ] || rm -rf "$WORK" echo diff --git a/graph/test/typescript/tools/entry_report.py b/graph/test/typescript/tools/entry_report.py index 3d16bd964..a9308c2e7 100755 --- a/graph/test/typescript/tools/entry_report.py +++ b/graph/test/typescript/tools/entry_report.py @@ -69,6 +69,33 @@ def main(): print(f"── entry_point ({len(seen)}) ──") for reason, who in sorted(seen): print(f" {reason:10s} {who}") + + # di_edge is the other half of a container-owned class: WHO the container hands + # each object to. Like an entry point it has no call site, so no other golden sees + # it. Printed only when the case has a slot, so a case without DI keeps its golden. + tname = {} + for t in rows(os.path.join(ir, 'all-typescript-types.csv')): + h = t.get('tsTypeUniqueHash') + if h: + tname[h] = t.get('name') or h + pname = {} + for p in rows(os.path.join(ir, 'all-typescript-method-parameters.csv')): + h = p.get('tsMethodParameterUniqueHash') + if h: + pname[h] = (p.get('paramName') or h, p.get('tsMethodLinkHash') or '') + di = set() + p = os.path.join(out, 'config-di-edge.csv') + if os.path.exists(p): + with open(p, newline='') as fh: + for r in csv.reader(fh, delimiter='\t'): + if len(r) >= 6: + pn, m = pname.get(r[0], (r[0], '')) + who = name.get(m, m).split(' ')[0] + di.add((r[5], f"{who}({pn}: {tname.get(r[2], r[2])})", tname.get(r[4], r[4]))) + if di: + print(f"── di_edge ({len(di)}) ──") + for status, slot, bean in sorted(di): + print(f" {status:11s} {slot} <- {bean}") return 0 diff --git a/graph/test/typescript/tools/normalize_edges.py b/graph/test/typescript/tools/normalize_edges.py index e4cdb0361..291ccaa44 100644 --- a/graph/test/typescript/tools/normalize_edges.py +++ b/graph/test/typescript/tools/normalize_edges.py @@ -282,6 +282,10 @@ def main(): if pairs_only: if f[1] not in n.m or f[3] not in n.m: continue + # a function the site HANDS OVER (`xs.map(cb)`) is not a call the compiler lists at that + # site: the pairs are scored against the compiler, the .edges golden still carries it + if f[5] == 'callback_registered': + continue seen.add(f"{n.label(f[1])} -> {n.label(f[3])}") else: ln = site_line.get(f[0], '?') diff --git a/graph/typescript/engine/call-edge-generation/call_chain.dl b/graph/typescript/engine/call-edge-generation/call_chain.dl index 212c7fadc..565187993 100644 --- a/graph/typescript/engine/call-edge-generation/call_chain.dl +++ b/graph/typescript/engine/call-edge-generation/call_chain.dl @@ -124,6 +124,16 @@ call_chain_edge(ce, caller, "-", "-", "-", cls, kind) :- class_is_terminal("ambiguous_unknown"). class_is_terminal("intrinsic_terminal"). +// A function the site HANDS to a callee with no client body (resolution/value-flow.dl, +// handed_function): `xs.map(cb)`, `p.then(cb)`. Not the site's own callee, so its own +// tier, the one the JavaScript engine gives the same hand-over. +call_chain_edge(ce, caller, "-", m, "client", "callback_registered", kind) :- + handed_function(ce, m), + method_prov(m, "client"), + call_from(ce, caller), + caller != m, + invocation_site(ce, kind). + // ── call_runs_edge(FromMethod, ToMethod, Prov) ────────────────────────────── // The chain a consumer should WALK: overload signatures re-pointed to the body that // actually executes (resolution/overload-sets.dl). Kept separate from diff --git a/graph/typescript/engine/config-resolution/di.dl b/graph/typescript/engine/config-resolution/di.dl new file mode 100644 index 000000000..32834f4d8 --- /dev/null +++ b/graph/typescript/engine/config-resolution/di.dl @@ -0,0 +1,52 @@ +// ============================================================================ +// Config-resolution · DEPENDENCY INJECTION (Nest, Angular) +// +// entry-points.dl makes the constructor of a container-owned class a root. What it +// cannot say is WHERE the container sends the objects it builds: in +// `@Controller() class C { constructor(private svc: OrderService) {} }` nothing in the +// repository passes an OrderService to C, the container does, and so a change to +// OrderService looked as if no class received it. +// +// Both containers wire by CLASS TOKEN: a constructor parameter's declared type is the +// key the container looks up, and the object it hands over is the provider registered +// under exactly that class. So a slot is satisfied by a provider whose type IS the +// declared type, never by a subtype. An abstract class used as a key is bound to its +// implementation in a module's providers array (`{ provide: A, useClass: B }`), which +// the parser delivers as unsplit text (see decorator_arg_class_ref), so that slot is +// reported unsatisfied rather than guessed. +// +// The exports are Java's relation names and column orders, deliberately: the query +// layer reads ext_inject_point for every language, so a TypeScript slot shows up in +// impact as "receives it by dependency injection" with no change there. bean_def is +// NOT exported. The query layer reads it as a proxying container (a Spring bean call +// crosses a proxy), which neither of these containers is. +// ============================================================================ + +// ── bean_def(Name, Type, Kind, Site) — a class the container hands out ─────── +// Name is the class's own name: both containers key a class provider by the class. +bean_def(name, t, "injectable", h) :- annotation_on(_, dn, _, "CLASS_DECLARATION", t, h), + ts_provider_class_decorator(dn), type_decl(_, name, _, _, _, _, t). + +// ── inject_point(SiteKind, Site, DeclaredType, OwnerType, Method) ──────────── +// A constructor parameter of a container-owned class. The class decorator is what +// makes the container emit the parameter types it reads at runtime; the same +// constructor on a plain class is an ordinary constructor whose caller is in the code. +ts_di_ctor(m, t) :- annotation_on(_, cn, _, "CLASS_DECLARATION", t, _), + ts_framework_class_decorator(cn), + method_owner(_, t, m), method_kind(_, "CONSTRUCTOR", _, m). +ts_di_token_param(p) :- annotation_on(_, dn, _, "PARAMETER_DECLARATION", p, _), + ts_token_param_decorator(dn). +ts_di_token_param(p) :- annotation_on(_, dn, _, "PARAMETER_DECLARATION", p, _), + match("Inject[A-Z].*", dn). +inject_point("ctor_param", p, dt, t, m) :- ts_di_ctor(m, t), + param_decl(_, _, _, _, m, p), param_type(p, _, dt), !ts_di_token_param(p). + +// ── di_edge(Site, SiteKind, DeclaredType, BeanName, BeanType, Status) ──────── +// Status ∈ { known_bean, unsatisfied }. There is no multi_bean: the key is exact, so +// at most one provider type answers it. +ts_di_fits(site, name, dt) :- inject_point(_, site, dt, _, _), bean_def(name, dt, _, _). +ts_di_satisfied(site) :- ts_di_fits(site, _, _). +di_edge(site, kind, dt, name, dt, "known_bean") :- inject_point(kind, site, dt, _, _), + ts_di_fits(site, name, dt). +di_edge(site, kind, dt, "-", "-", "unsatisfied") :- inject_point(kind, site, dt, _, _), + !ts_di_satisfied(site). diff --git a/graph/typescript/engine/config-resolution/entry-points.dl b/graph/typescript/engine/config-resolution/entry-points.dl index 8aa02e1d2..168ff32a0 100644 --- a/graph/typescript/engine/config-resolution/entry-points.dl +++ b/graph/typescript/engine/config-resolution/entry-points.dl @@ -152,6 +152,85 @@ entry_point(m, "package_export") :- cfg_published_module(mod), en != "", !cfg_placeholder_name(en). +// ── …and the other ways a library publishes callable code (#1626) ─────────── +// An exported function or arrow const is one shape of a published API. The rest are just +// as common, and each left its code reading as safe to delete: +// * `export class Store { get() {} static create() {} }` — the consumer calls the +// class's members, constructor included, and every member it inherits; +// * `export const produce = engine.produce`, `= engine.finish.bind(engine)`, +// `= Engine.make` — a const bound to a method REFERENCE, the headline API of +// libraries that publish one configured instance; +// * `export const api = { get() {}, put: () => {} }` — an object literal of callables; +// * `export namespace Result { export function ok() {} }` — a namespace's exported +// functions; +// * `export default (x) => …` — an anonymous default arrow or function expression. +// +// Declined, rather than guessed: `private` and `#name` members (no consumer can name +// them), a namespace function without `export`, a method no published value names, and a +// published const holding anything that is not a function. +cfg_package_module(mod) :- cfg_published_module(mod), !entry_module(mod). + +cfg_published_export(mod, en, k, e) :- cfg_package_module(mod), + module_export_any("client", mod, en, k, e), + en != "", + !cfg_placeholder_name(en). + +// a published class, and every class it inherits from: `store.inherited()` is a call too +cfg_published_class(t) :- cfg_published_export(_, _, "TYPE", t), class_type(t). +cfg_published_class(anc) :- cfg_published_class(t), type_ancestor(t, anc), class_type(anc). + +cfg_member_hidden("PRIVATE_ACCESS"). +cfg_member_hidden("PRIVATE_NAME_ACCESS"). + +entry_point(m, "package_export") :- cfg_published_class(t), + method_owner("client", t, m), + method_access("client", acc, m), + !cfg_member_hidden(acc). + +entry_point(m, "package_export") :- cfg_published_export(_, _, "TYPE", ns), + type_decl("client", _, _, _, "NAMESPACE_TYPE", _, ns), + method_owner("client", ns, m), + method_access("client", "EXPORTED_ACCESS", m). + +// A value the package hands out: a published const's initializer (and each branch it may +// evaluate to), the expression of `export default `, and the receiver of a `.bind`. +cfg_published_value(x) :- cfg_published_export(_, _, "VARIABLE", v), + var_initializer("client", _, e, v), + e != "", + value_branch(e, x). +cfg_published_value(e) :- cfg_package_module(mod), + export_decl(_, "default", _, "DEFAULT_EXPRESSION", mod, h), + export_expression("client", e, h). +cfg_published_value(r) :- cfg_published_value(x), + expr_kind("client", "CALL_EXPRESSION", _, x), + call_callee_expr(_, x, ne), + property_access_name(ne, "bind"), + expr_child("client", ne, "RECEIVER", _, r). + +// the function it is: a written arrow or function expression, a named function, a static +// method read as a value (`Engine.make`) — a call node carries its CALLEE as its +// referenced entity, so a call is not the function it names +entry_point(m, "package_export") :- cfg_published_value(x), + !expr_kind("client", "CALL_EXPRESSION", _, x), + expr_callable(x, m), + method_prov(m, "client"). +// an instance method read as a value: `engine.produce`, typed through its receiver +entry_point(m, "package_export") :- cfg_published_value(x), + expr_kind("client", "PROPERTY_ACCESS", _, x), + property_access_name(x, n), + expr_child("client", x, "RECEIVER", _, q), + expr_type(q, _, t), + method_in_scope(t, n, _, m), + method_prov(m, "client"). +// the callables of a published object literal +entry_point(m, "package_export") :- cfg_published_value(x), + objlit_member_fn(x, _, m). +// and what a published const holds by any route the holder rules follow: a function +// another holder hands over, or one a call returns (`export const store = create()`) +entry_point(m, "package_export") :- cfg_published_export(_, _, "VARIABLE", v), + holder_holds_function(v, m), + method_prov(m, "client"). + // ── DECORATOR-DRIVEN ROOTS (Nest, Angular) ────────────────────────────────── // The gap the section above described, now closed. projections/decorators.dl gives // annotation_on the same shape Java and Python already carry, so these three rules diff --git a/graph/typescript/engine/config-resolution/knobs.dl b/graph/typescript/engine/config-resolution/knobs.dl index 7ab8f5e44..944fa0171 100644 --- a/graph/typescript/engine/config-resolution/knobs.dl +++ b/graph/typescript/engine/config-resolution/knobs.dl @@ -97,6 +97,23 @@ ts_framework_class_decorator("Pipe"). ts_framework_class_decorator("NgMod ts_framework_class_decorator("Module"). ts_framework_class_decorator("Catch"). ts_framework_class_decorator("Resolver"). +// ── ts_provider_class_decorator(Name) — the container HANDS this class out ── +// A narrower set than the one above. A controller or a component is constructed by +// the container but never injected into anything; a provider is what a constructor +// parameter receives. Both containers spell it the same way. +.decl ts_provider_class_decorator(c0:symbol) +ts_provider_class_decorator("Injectable"). + +// ── ts_token_param_decorator(Name) — the slot is wired by TOKEN, not by type ── +// `@Inject(TOKEN) x: Foo` asks the container for whatever TOKEN names, and Foo is +// only the static type of the result, often an interface. Matching the declared type +// there would invent a wiring, so a parameter carrying one is not an injection point. +// The integrations spell their tokens the same way (`@InjectRepository(User)`, +// `@InjectModel(Cat.name)`, `@InjectQueue('mail')`), so di.dl also treats any +// `Inject…` name as a token decorator rather than listing each package. +.decl ts_token_param_decorator(c0:symbol) +ts_token_param_decorator("Inject"). + // ── ts_framework_lifecycle_method(Name) — the container CALLS this by name ── // A hook is invoked by the framework through an interface it checks for at runtime, // so no call site exists anywhere in the repository. The name is the whole contract, diff --git a/graph/typescript/engine/expression-resolution/expr-type.dl b/graph/typescript/engine/expression-resolution/expr-type.dl index 670004b80..f7251ef11 100644 --- a/graph/typescript/engine/expression-resolution/expr-type.dl +++ b/graph/typescript/engine/expression-resolution/expr-type.dl @@ -280,6 +280,26 @@ expr_type(e, prov, t) :- expr_kind("client", "TERNARY_EXPRESSION", _, e), edge_role_is_ternary_branch("TERNARY_THEN"). edge_role_is_ternary_branch("TERNARY_ELSE"). +// ── a `||` / `??` / `&&` DEFAULT — the call signature of the operand the compiler names ── +// `const onError = options.onError || defaultOnError; onError(m)` is how a TypeScript API +// takes an optional callback. The compiler types the call by the LEFT operand's function +// type (the property's), and by the RIGHT one for `flag && handler`; the other operand is a +// value that may RUN, which is value-flow.dl's question (value_branch), not the call site's. +// So only that operand's SHAPE flows, and nothing else: +// - not expr_type: typing `opts.tags || []` as the empty array literal too sends `.map` +// to ReadonlyArray's declaration where the compiler names Array's; +// - not the other operand: when both carry a call signature (a property holding a +// function, defaulted to an object with its own call signature) the compiler still +// resolves to the left one, and a second candidate is committed to wrongly downstream. +expr_shape(e, s) :- expr_kind("client", "BINARY_EXPRESSION", _, e), + expr_operator("client", op, e), + binary_op_names_operand(op, r), + expr_child("client", e, r, _, side), + expr_shape(side, s). +binary_op_names_operand("||", "LEFT_OPERAND"). +binary_op_names_operand("??", "LEFT_OPERAND"). +binary_op_names_operand("&&", "RIGHT_OPERAND"). + // ── an ARROW / FUNCTION expression — the value IS the function ────────────── // `anonymousDeclarationHash` gives the ts_method the expression introduces. Java // matches lambdas to their bodies by source POSITION; here it is a column, so an diff --git a/graph/typescript/engine/expression-resolution/overload.dl b/graph/typescript/engine/expression-resolution/overload.dl index 1fbafef19..486fd5f08 100644 --- a/graph/typescript/engine/expression-resolution/overload.dl +++ b/graph/typescript/engine/expression-resolution/overload.dl @@ -273,6 +273,39 @@ arg_compatible_at(ce, m, pos) :- expr_call_candidate(ce, m), arg_type(ce, pos, at), param_type_at(m, pos, pt), type_satisfies(at, pt). +// AN ARGUMENT THAT HAS EVERY MEMBER THE INTERFACE REQUIRES. type_satisfies only tests +// client classes, so a LIBRARY type passed where a client interface is expected — a +// RegExp for `interface Matcher { exec(s: string): unknown }` — had no route here and +// was rejected, although the compiler accepts it structurally (#422). The same name-based +// cover test, asked only of the (argument, parameter) pairs that nothing above accepted. +// Prune-only: it can only keep a candidate, never remove one. +arg_param_uncovered(ce, pos, at, pt) :- expr_call_candidate(ce, m), + arg_type(ce, pos, at), + param_type_at(m, pos, pt), + interface_type(pt), + at != pt, + !same_type_entity(at, pt), + !type_subtype_star(at, pt). +compat_required(pt, name) :- arg_param_uncovered(_, _, _, pt), + scope_sibling(pt, sib), + declared_method(sib, name, "false", _). +compat_required(pt, name) :- arg_param_uncovered(_, _, _, pt), + scope_sibling(pt, sib), + declared_field(sib, name, "false", f), + field_kind(_, _, _, "false", f). +compat_required_count(pt, n) :- arg_param_uncovered(_, _, _, pt), + n = count : { compat_required(pt, _) }. +compat_covers_member(at, pt, name) :- arg_param_uncovered(_, _, at, pt), + compat_required(pt, name), + member_name_declared(at, name). +arg_type_covers(at, pt) :- arg_param_uncovered(_, _, at, pt), + compat_required_count(pt, k), + k > 0, + k = count : { compat_covers_member(at, pt, _) }. +arg_compatible_at(ce, m, pos) :- expr_call_candidate(ce, m), + arg_type(ce, pos, at), + param_type_at(m, pos, pt), + arg_type_covers(at, pt). arg_compatible_at(ce, m, pos) :- arg_is_target_typed(ce, pos), expr_call_candidate(ce, m), param_type_at(m, pos, _). @@ -686,6 +719,62 @@ arg_matches_concretely(ce, m, pos) :- expr_call_candidate(ce, m), param_decl(_, _, pos, tn, m, _), tn = cat("\"", cat(v, "\"")). +// A STRING LITERAL THAT IS A KEY OF THE CONSTRAINT — `(kind: K)`. +// The typed-registry shape: `create(kind: K)` declared before +// `create(kind: string)`, as `document.createElement` and every typed event map declare +// it. The compiler picks the generic for `create('video')`, because the literal IS a +// `keyof WidgetMap`, and the widening one for `create(k)` with `k: string`, because a +// general string is not. +// +// Without this the parameter's type is `K`, which matches nothing concretely, while +// `string` matches the literal — so specificity removed the generic at BOTH sites. That +// stayed hidden while an interface's overloads carried no declarationGroupKey and no +// group rule could fire; once the parser grouped them it became a missing edge (#334). +// Evaluating the constraint against the literal separates the two sites on evidence: +// at `create('video')` both signatures now match concretely and declaration order +// decides, as the compiler does; at `create(k)` and at a literal that is NOT a key the +// generic still does not match and `string` wins, exactly as before. +// +// Keyed on the exact variable link first and on the name in the method's own scope +// where the link is absent. The key set is the constraint object's DECLARED members, +// fields and methods both: a literal naming neither never matches, so an unresolved +// `keyof` object leaves the generic as unmatched as it always was. +param_keyof_object(m, pos, obj) :- expr_call_candidate(_, m), + param_decl(_, _, pos, _, m, p), + param_type_ref(_, r, p), + type_ref(_, "TYPE_VARIABLE", _, _, _, _, _, r), + type_ref_type_param(_, tp, r), + type_param_constraint(_, cref, tp), + keyof_operand_target(cref, obj). +param_keyof_object(m, pos, obj) :- expr_call_candidate(_, m), + param_decl(_, _, pos, _, m, p), + param_type_ref(_, r, p), + type_ref(_, "TYPE_VARIABLE", _, tn, _, _, _, r), + !type_ref_type_param(_, _, r), + type_param(_, tn, _, _, m, tp), + type_param_constraint(_, cref, tp), + keyof_operand_target(cref, obj). +// `keyof X` is a TYPE_OPERATOR whose written text starts with the operator; `readonly` +// and `unique` share the kind and are told apart by that text, not assumed away. +keyof_operand_target(cref, obj) :- type_ref(_, "TYPE_OPERATOR", _, _, ctn, _, _, cref), + strlen(ctn) > 6, + substr(ctn, 0, 6) = "keyof ", + type_ref_parent(_, cref, _, oref), + ref_type_target(oref, _, obj). + +arg_matches_concretely(ce, m, pos) :- expr_call_candidate(ce, m), + expr_child("client", ce, "ARGUMENT", pos, a), + expr_literal_type("client", "STRING", a), + expr_literal_value("client", v, a), + param_keyof_object(m, pos, obj), + declared_field(obj, v, _, _). +arg_matches_concretely(ce, m, pos) :- expr_call_candidate(ce, m), + expr_child("client", ce, "ARGUMENT", pos, a), + expr_literal_type("client", "STRING", a), + expr_literal_value("client", v, a), + param_keyof_object(m, pos, obj), + declared_method(obj, v, _, _). + // BETTER SOMEWHERE AND WORSE NOWHERE — both halves, which is the discipline // java/overload.dl states as `win_strictly_better` AND `!win_worse_anywhere`. Firing on a // single position alone is not domination: a candidate matching at argument 0 would beat @@ -704,7 +793,11 @@ specificity_beaten(ce, loser) :- overload_selectable(ce, winner), method_group_of(loser, g), arg_matches_concretely(ce, winner, pos), !arg_matches_concretely(ce, loser, pos), - !specificity_worse_somewhere(ce, winner, loser). + !specificity_worse_somewhere(ce, winner, loser), + // Not against a later declaration block the argument is shown to fit: the compiler + // tries that block FIRST, so a concrete match in an earlier block is never reached + // (#422). See LATER DECLARATION BLOCKS FIRST below. + !block_order_wins(ce, loser, winner). // A SPECIALIZED SIGNATURE BEATS A GENERAL ONE AT THE SAME POSITION, which is a // specificity fact rather than an ordering one. Both `on(event: "close")` and @@ -749,7 +842,116 @@ overload_beaten(ce, m) :- overload_selectable(ce, m), method_group_of(m2, g), overload_index_of(m2, j), j < i, - !specificity_beaten(ce, m2). + !specificity_beaten(ce, m2), + // An index counts within its own declaration block only: it restarts at 0 in every + // block, so across two blocks whose order is known it compares nothing. + !block_tried_before(m, m2). + +// ── LATER DECLARATION BLOCKS FIRST (#422) ─────────────────────────────────── +// A merged interface lists its members block by block, and the compiler's candidate +// order (`reorderCandidates`) puts each LATER block ahead of the earlier ones while +// keeping source order inside a block. So a module augmentation's `find(m: Matcher)` +// is tried before the original `find(m: RegExp)`, and `lib.es2015.symbol.wellknown`'s +// `match(matcher: { [Symbol.match](…) })` before `lib.es5`'s `match(regexp: string | +// RegExp)`: with a RegExp argument both apply and the later one is what tsc selects. +// The engine committed to the earlier one, because the concrete-match specificity rule +// above preferred the exact `RegExp` parameter. +// +// Block order is only claimed where the IR states it: +// - an augmentation (`declare module "./x"`, `declare global`) is merged after the +// declarations it augments; +// - two blocks in one file merge in source order; +// - `lib.es5.d.ts` is the root every other `lib.es*.d.ts` references, so it comes first. +// Two ordinary files reopening one interface are in program order, which the IR does not +// carry; nothing is claimed there and the answer stays the set it was. +method_block(m, b) :- method_owner(_, b, m), b != "". +method_in_augmentation(m) :- method_module(_, mh, m), + module_decl(_, _, _, _, k, mh), + module_kind_is_augmentation(k). +module_kind_is_augmentation("MODULE_AUGMENTATION"). +module_kind_is_augmentation("GLOBAL_AUGMENTATION"). + +// Only pairs that are overloads of one another in different blocks are ever compared. +cross_block_pair(late, early) :- method_group_of(late, g), + method_group_of(early, g), + late != early, + method_block(late, bl), + method_block(early, be), + bl != be. + +// block_tried_before(Late, Early): the compiler tries Late's block before Early's. +block_tried_before(late, early) :- cross_block_pair(late, early), + method_in_augmentation(late), + !method_in_augmentation(early). +block_tried_before(late, early) :- cross_block_pair(late, early), + method_lines(_, fp, ll, _, late), + method_lines(_, fp, le, _, early), + ll > le, + !augmentation_differs(late, early). +block_tried_before(late, early) :- cross_block_pair(late, early), + method_lines(_, fl, _, _, late), + method_lines(_, fe, _, _, early), + match(".*lib\\.es5\\.d\\.ts", fe), + match(".*lib\\.es[0-9]+\\.[a-z.]*d\\.ts", fl). +augmentation_differs(a, b) :- cross_block_pair(a, b), method_in_augmentation(a), !method_in_augmentation(b). +augmentation_differs(a, b) :- cross_block_pair(a, b), method_in_augmentation(b), !method_in_augmentation(a). + +// ── The later candidate wins only where the argument is SHOWN to fit it ────── +// Order on top of a weak applicability test is the trade the note below refuses. So the +// later block's candidate wins only where every argument has positive evidence: a +// concrete match, a callback against a callable parameter, or an argument type that +// declares every required member of the parameter's type. `s.match("x")` keeps +// `lib.es5`: `String` declares no `[Symbol.match]`, so the wellknown signature has no +// evidence and the earlier block is still the answer. +block_order_candidate(ce, late, early) :- overload_selectable(ce, late), + overload_selectable(ce, early), + block_tried_before(late, early). + +param_target_at(m, pos, t) :- block_order_candidate(_, m, _), + param_decl(_, _, pos, _, m, p), + param_type(p, _, t). +param_target_at(m, pos, s) :- block_order_candidate(_, m, _), + param_decl(_, _, pos, _, m, p), + param_shape_target(p, s). +param_target_required(t, name) :- param_target_at(_, _, t), + scope_sibling(t, sib), + declared_method(sib, name, "false", _). +param_target_required(t, name) :- param_target_at(_, _, t), + scope_sibling(t, sib), + declared_field(sib, name, "false", f), + field_kind(_, _, _, "false", f). +param_target_required_count(t, n) :- param_target_at(_, _, t), + n = count : { param_target_required(t, _) }. + +arg_param_pair(ce, pos, at, pt) :- block_order_candidate(ce, m, _), + arg_type(ce, pos, at), + param_target_at(m, pos, pt). +arg_covers_member(at, pt, name) :- arg_param_pair(_, _, at, pt), + param_target_required(pt, name), + member_name_declared(at, name). +arg_covers_param(ce, m, pos) :- block_order_candidate(ce, m, _), + arg_type(ce, pos, at), + param_target_at(m, pos, pt), + param_target_required_count(pt, k), + k > 0, + k = count : { arg_covers_member(at, pt, _) }. + +arg_evidenced(ce, m, pos) :- block_order_candidate(ce, m, _), + arg_matches_concretely(ce, m, pos). +arg_evidenced(ce, m, pos) :- block_order_candidate(ce, m, _), + arg_covers_param(ce, m, pos). +arg_evidenced(ce, m, pos) :- block_order_candidate(ce, m, _), + arg_is_function_value(ce, pos), + param_call_signature(m, pos). +arg_evidence_gap(ce, m) :- block_order_candidate(ce, m, _), + expr_child("client", ce, "ARGUMENT", pos, _), + !arg_evidenced(ce, m, pos). + +block_order_wins(ce, late, early) :- block_order_candidate(ce, late, early), + !arg_evidence_gap(ce, late). + +overload_beaten(ce, early) :- block_order_wins(ce, late, early), + !specificity_beaten(ce, late). // ── WHY THERE IS NO "FIRST APPLICABLE" TIE-BREAK ─────────────────────────── // TypeScript's own rule is that the first APPLICABLE overload wins, and implementing diff --git a/graph/typescript/engine/projections/methods.dl b/graph/typescript/engine/projections/methods.dl index 90997dac9..789734b7b 100644 --- a/graph/typescript/engine/projections/methods.dl +++ b/graph/typescript/engine/projections/methods.dl @@ -50,6 +50,11 @@ method_decl("lib", name, en, ret, h) :- lib_ts_method(name, _, _, _, _, _, _, _, method_owner("client", owner, h) :- ts_method(_, _, _, _, _, _, _, owner, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, h). method_owner("lib", owner, h) :- lib_ts_method(_, _, _, _, _, _, _, owner, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, h). +// ── method_access(Prov, Access, MethodHash) ───────────────────────────────── +// Col 10, TsMethodAccess: `private` and `#name` on a class member, EXPORTED_ACCESS or +// MODULE_LOCAL_ACCESS on a function (inside a namespace too), PUBLIC_ACCESS otherwise. +method_access("client", acc, h) :- ts_method(_, _, _, _, _, _, _, _, _, _, acc, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, h). + // ── method_module(Prov, ModuleHash, MethodHash) ───────────────────────────── // A top-level `function f()` has NO owning type, so col 7 is empty and the module // is the only scope it belongs to. In Java every method has an owner; here the diff --git a/graph/typescript/engine/resolution/contextual-params.dl b/graph/typescript/engine/resolution/contextual-params.dl index 4f3aa4a5f..b596d41f2 100644 --- a/graph/typescript/engine/resolution/contextual-params.dl +++ b/graph/typescript/engine/resolution/contextual-params.dl @@ -532,6 +532,31 @@ fn_written_as_fnref(lm, fref) :- var_initializer("client", _, e, v), var_type_ref(_, r, v), ref_shape_target(r, fref). +// …and an arrow RETURNED by a function whose return type is a function type: the middleware +// shape, `const logger: Impl = (fn) => (set, get, api) => { … }` or `function logger(fn): +// StateCreator { return (set, get, api) => … }` (#838). The inner arrow is written where +// the outer function's RETURN type is expected, so it takes its parameter types from there, +// as the compiler does. The return is the FUNCTION_TYPE's METHOD_RETURN child, or the +// function's own declared return type. +fn_returns_fn(outer, inner) :- method_returns_expr(outer, e), + expr_kind("client", _, "ROOT", e), + expr_anon_decl("client", inner, e). +fn_returns_fn(outer, inner) :- expr_root_context("client", "ARROW_BODY_EXPRESSION", e), + expr_kind("client", _, "ROOT", e), + expr_anon_decl("client", inner, e), + method_enclosing_member(inner, outer). +fn_type_return_ref(fref, rr) :- type_ref_parent(_, fref, _, rr), + type_ref(_, _, "METHOD_RETURN", _, _, _, _, rr). +fn_written_as_fnref(inner, fref2) :- fn_written_as_fnref(outer, fref1), + fn_returns_fn(outer, inner), + fn_type_return_ref(fref1, rr), + ref_shape_target(rr, fref2), + type_ref(_, "FUNCTION_TYPE", _, _, _, _, _, fref2). +fn_written_as_fnref(inner, fref) :- fn_returns_fn(outer, inner), + method_return_ref("client", r, outer), + ref_shape_target(r, fref), + type_ref(_, "FUNCTION_TYPE", _, _, _, _, _, fref). + // an unannotated parameter only; one the author annotated keeps its own type lambda_param_type(lp, prov, t) :- fn_written_as_fnref(lm, fref), fn_param_type(fref, k, prov, t), diff --git a/graph/typescript/engine/resolution/member-lookup.dl b/graph/typescript/engine/resolution/member-lookup.dl index bfd2d733f..f0e99b8e4 100644 --- a/graph/typescript/engine/resolution/member-lookup.dl +++ b/graph/typescript/engine/resolution/member-lookup.dl @@ -189,6 +189,9 @@ call_signature_in_scope(scope, m) :- lookup_scope(scope), method_owner(prov, asib, m), method_kind(prov, k, _, m), method_kind_is_call_signature(k). +// An indexed access that names a METHOD — `{ _(p: P): void }['_']`, `Api['run']` — is called +// with that method's signatures. The reference is its own shape (type-resolution.dl, #838). +call_signature_in_scope(ref, m) :- ref_member_method(ref, m). method_kind_is_call_signature("CALL_SIGNATURE"). method_kind_is_call_signature("TYPE_LITERAL_CALL_SIGNATURE"). method_kind_is_call_signature("FUNCTION_TYPE_SIGNATURE"). diff --git a/graph/typescript/engine/resolution/name-resolution.dl b/graph/typescript/engine/resolution/name-resolution.dl index 8366246bb..535226291 100644 --- a/graph/typescript/engine/resolution/name-resolution.dl +++ b/graph/typescript/engine/resolution/name-resolution.dl @@ -49,12 +49,40 @@ name_binds_any(mod, name, prov, k, ent) :- type_module("client", mod, ns), namespace_member("client", ns, name, k, ent), prov = "client". +// (2c) inside an AUGMENTATION block — `declare module "./text" { … }` or `declare +// global { … }` — the names of the FILE around it. The block is its own module row, so +// a reference written in it is keyed to that row, while the file's imports and +// top-level declarations belong to the file's row. The compiler nests the block's scope +// inside the file's: `import type { Matcher } from "./text"` above the block is what +// `find(m: Matcher)` inside it means. Without this every such annotation resolved to +// nothing, and the augmented member lost its parameter types (#422). +augmentation_host(aug, host) :- module_decl("client", _, _, fp, k, aug), + module_kind_is_augmentation(k), + module_decl("client", _, _, fp, hk, host), + host != aug, + module_kind_is_file(hk). +module_kind_is_file("SOURCE_MODULE"). +module_kind_is_file("DECLARATION_FILE"). +module_kind_is_file("SCRIPT_GLOBAL"). +name_binds_any(aug, name, prov, k, ent) :- augmentation_host(aug, host), + import_binding("client", _, name, _, host, ih), + name != "", + !module_local_decl(_, aug, name, _, _), + import_binds_any(ih, prov, k, ent). +name_binds_any(aug, name, prov, k, ent) :- augmentation_host(aug, host), + module_local_decl(prov, host, name, k, ent), + !module_local_decl(_, aug, name, _, _). + // (3) the global scope, ONLY when the name is not bound in the module itself. This // is the one precedence rule the language needs: a module that declares its own // `Buffer` means its own, and admitting the global as a second answer would leave // every call on it multi_inferred across two unrelated declarations. name_bound_locally(mod, name) :- import_binding("client", _, name, _, mod, _), name != "". name_bound_locally(mod, name) :- module_local_decl(_, mod, name, _, _). +name_bound_locally(aug, name) :- augmentation_host(aug, host), + import_binding("client", _, name, _, host, _), name != "". +name_bound_locally(aug, name) :- augmentation_host(aug, host), + module_local_decl(_, host, name, _, _). // DEMAND-GATED. Ungated, this rule is |client modules| x |global declarations| — // measured on this corpus, 418 modules against 5,268 globals produced 2.1 MILLION // name_binds_any rows, almost none of which any rule reads. Gating on the names the diff --git a/graph/typescript/engine/resolution/type-resolution.dl b/graph/typescript/engine/resolution/type-resolution.dl index b28091d3e..7f4b94038 100644 --- a/graph/typescript/engine/resolution/type-resolution.dl +++ b/graph/typescript/engine/resolution/type-resolution.dl @@ -323,6 +323,16 @@ tvar_under(p, n) :- type_ref(rp, "INDEXED_ACCESS", _, _, _, _, _, p), type_ref_parent(rp, p, "0", c), tvar_under(c, n). +// A CONDITIONAL alias yields its BRANCHES (#838), the same over-approximation +// ref_conditional_branch makes for a conditional written in place: `type Mutate = Ms +// extends [] ? S : …` read at `Mutate, Mis>` is StoreApi, so `api.getState()` on a +// middleware's `api` parameter has a receiver. A branch that is an INDEXED ACCESS is not +// descended: `K extends keyof T ? T[K] : F` denotes a MEMBER of T, never T itself, and +// ref_member answers it. +tvar_under(p, n) :- ref_conditional_branch(p, c), + !type_ref(_, "INDEXED_ACCESS", _, _, _, _, _, c), + tvar_under(c, n). + ref_type_target(ref, prov, t) :- alias_rhs(ref, _, rhs), tvar_under(rhs, pname), type_arg_binding(ref, pname, argRef), @@ -378,6 +388,91 @@ ref_type_target(ia, prov, t) :- type_ref(_, "INDEXED_ACCESS", _, _, _, _, _, ia) field_type_ref(_, fr, f), ref_type_target(fr, prov, t). +// ── …AND WHAT THE MEMBER IS WHEN IT IS A FUNCTION (#838) ─────────────────── +// The rule above names a member's type only when that type is a DECLARATION. The commonest +// member an indexed access picks out is a FUNCTION: `const setState: StoreApi['setState'] +// = …`, a middleware parameter declared `StoreApi['getState']`. Its type is a FUNCTION_TYPE +// (a shape), or a METHOD of the object, and neither is anything ref_type_target can name, so +// every call through such a value resolved to nothing. On a state-management library 43 of 115 +// executed production call sites were this shape, each reaching exactly one function at run +// time. +// +// ref_member(Ref, Obj, Name): the reference denotes member Name of Obj. Written in place +// (`Obj["name"]`), or under an ALIAS whose arguments supply the object, the key, or both: +// `type Get = K extends keyof T ? T[K] : F` read at `Get, 'setState', +// never>`. The key is always ONE string literal — written, or bound to a type argument that is +// one. A union key or a `keyof` names several members and stays unresolved, as the rule above +// already decides for its own case. +ia_literal_key(ia, n) :- type_ref(_, "INDEXED_ACCESS", _, _, _, _, _, ia), + type_ref_parent(_, ia, "1", iref), + ref_string_literal(iref, n). +ref_string_literal(r, n) :- type_ref(_, "LITERAL", _, _, lit, _, _, r), + strlen(lit) > 2, + q = substr(lit, 0, 1), + string_quote(q), + substr(lit, strlen(lit) - 1, 1) = q, + n = substr(lit, 1, strlen(lit) - 2). +string_quote("'"). +string_quote("\""). + +ia_object(ia, obj) :- type_ref(_, "INDEXED_ACCESS", _, _, _, _, _, ia), + type_ref_parent(_, ia, "0", oref), + ref_type_target(oref, _, obj). +ia_object(ia, obj) :- type_ref(_, "INDEXED_ACCESS", _, _, _, _, _, ia), + type_ref_parent(_, ia, "0", oref), + ref_shape_target(oref, obj). + +ref_member(ia, obj, n) :- ia_literal_key(ia, n), ia_object(ia, obj). + +// Under an alias: the indexed access sits in the alias's right-hand side, directly, inside a +// parenthesis, or as a branch of a conditional (the branches only, as ref_conditional_branch +// decides). A child that is one of the ALIAS's type variables is read at the reference. +ia_under(rhs, rhs) :- type_alias_target(_, rhs, _). +ia_under(rhs, c) :- ia_under(rhs, p), ref_conditional_branch(p, c). +ia_under(rhs, c) :- ia_under(rhs, p), + type_ref(rp, k, _, _, _, _, _, p), + ref_kind_is_transparent(k), + type_ref_parent(rp, p, _, c). +alias_ia(ref, ia) :- alias_rhs(ref, _, rhs), + ia_under(rhs, ia), + type_ref(_, "INDEXED_ACCESS", _, _, _, _, _, ia). +alias_ia_key(ref, ia, n) :- alias_ia(ref, ia), + type_ref_parent(_, ia, "1", iref), + type_ref(_, "TYPE_VARIABLE", _, kn, _, _, _, iref), + type_arg_binding(ref, kn, arg), + ref_string_literal(arg, n). +alias_ia_object(ref, ia, obj) :- alias_ia(ref, ia), + type_ref_parent(_, ia, "0", oref), + type_ref(_, "TYPE_VARIABLE", _, tn, _, _, _, oref), + type_arg_binding(ref, tn, arg), + ref_type_target(arg, _, obj). +alias_ia_object(ref, ia, obj) :- alias_ia(ref, ia), + type_ref_parent(_, ia, "0", oref), + type_ref(_, "TYPE_VARIABLE", _, tn, _, _, _, oref), + type_arg_binding(ref, tn, arg), + ref_shape_target(arg, obj). +// the key bound at the reference, the object written or bound +ref_member(ref, obj, n) :- alias_ia_key(ref, ia, n), ia_object(ia, obj). +ref_member(ref, obj, n) :- alias_ia_key(ref, ia, n), alias_ia_object(ref, ia, obj). +// the key written, the object bound at the reference +ref_member(ref, obj, n) :- alias_ia(ref, ia), ia_literal_key(ia, n), alias_ia_object(ref, ia, obj). + +// What the member is. A FIELD contributes its declared type (named or a shape); a METHOD makes +// the reference callable with that method's signatures, so the reference is its own shape and +// call_signature_in_scope (member-lookup.dl) reads ref_member_method for it. Same positive-only +// route as the rule above: declared_field / declared_method, never the lookup relations. +ref_type_target(ref, prov, t) :- ref_member(ref, obj, n), + declared_field(obj, n, _, f), + field_type_ref(_, fr, f), + ref_type_target(fr, prov, t). +ref_shape_target(ref, s) :- ref_member(ref, obj, n), + declared_field(obj, n, _, f), + field_type_ref(_, fr, f), + ref_shape_target(fr, s). +ref_member_method(ref, m) :- ref_member(ref, obj, n), + declared_method(obj, n, _, m). +ref_shape_target(ref, ref) :- ref_member_method(ref, _). + // BY NAME, and only where the exact link is absent. Keeping both unguarded would put // the shadowed declaration's constraint alongside the right one at every such site, // which is a superset the exact link exists to remove. diff --git a/graph/typescript/engine/resolution/value-flow.dl b/graph/typescript/engine/resolution/value-flow.dl index d6c16d6a6..1989b9460 100644 --- a/graph/typescript/engine/resolution/value-flow.dl +++ b/graph/typescript/engine/resolution/value-flow.dl @@ -188,7 +188,50 @@ holder_value(v, e) :- var_initializer("client", _, e, v), e != "". holder_value(v, e) :- var_reassigned_value(v, e). holder_value(p, a) :- expr_child("client", ce, "ARGUMENT", pos, a), expr_resolves_to_method(ce, callee), # the overload the call SELECTS, not every candidate: - param_decl("client", _, pos, _, callee, p). # an arrow passed to on("close", …) is not a listener of every on + param_decl("client", _, pos, _, callee, p), # an arrow passed to on("close", …) is not a listener of every on + !hof_boundary_site(ce). # …and a parameter no body reads holds nothing (below) + +// ============================================================================ +// A FUNCTION HANDED TO A HIGHER-ORDER FUNCTION WITH NO BODY (the library boundary) +// +// `xs.map((x) => f(x))` where `map` is a declaration with no body in the client: a `.d.ts` +// interface member, an ambient `declare function`, an interface method nothing implements. +// Stored in the parameter, the arrow became a `value` candidate of the parameter's function +// type, and that ONE signature is shared by every `.map` call in the repository: a walk up +// from any map callback reached every caller of `.map`, and a walk down from any caller of +// `.map` reached every map callback. One constant read inside one arrow reached 1,078 +// callables in 198 files. No body can call through the parameter, so the envelope row +// said nothing true that the call site does not say better. +// +// The call SITE is what hands the function over, so the edge is the site's own: the +// function that writes `xs.map(cb)` reaches `cb` (call_chain.dl, `callback_registered`), +// and a callback passed at one site is never reached from another. +// +// Only where no CLIENT body runs at the site. A project function that calls its parameter +// keeps the holder rule: its body is what runs the callback, and that is a real call. +// ============================================================================ +call_runs_client_body(ce) :- call_runs_method(ce, m), method_prov(m, "client"). +hof_boundary_site(ce) :- call_site("client", _, _, _, _, ce, _), !call_runs_client_body(ce). + +value_branch(a, a) :- hof_boundary_site(ce), expr_child("client", ce, "ARGUMENT", _, a). +handed_function(ce, m) :- hof_boundary_site(ce), expr_child("client", ce, "ARGUMENT", _, a), + value_branch(a, x), + !expr_kind("client", "CALL_EXPRESSION", _, x), # `xs.map(make())` hands over what make RETURNS + expr_callable(x, m). +handed_function(ce, m) :- hof_boundary_site(ce), expr_child("client", ce, "ARGUMENT", _, a), + value_branch(a, x), + expr_referenced("client", k, h, x), + holder_ref_kind(k), + holder_holds_function(h, m). +handed_function(ce, m) :- hof_boundary_site(ce), expr_child("client", ce, "ARGUMENT", _, a), + value_branch(a, x), + ts_field_access_target(x, f), + holder_holds_function(f, m). +handed_function(ce, m) :- hof_boundary_site(ce), expr_child("client", ce, "ARGUMENT", _, a), + value_branch(a, x), + expr_kind("client", "CALL_EXPRESSION", _, x), + call_runs(x, f), + holder_holds_function(f, m). // ── value_branch(Root, Expr): Expr is a value Root can evaluate to ── value_branch(e, e) :- holder_value(_, e). @@ -438,3 +481,138 @@ called_through(ce, sig) :- call_callee_expr(_, ce, ne), member_sig_access(ne, si method_dispatch_candidate(sig, m, "value") :- member_sig_access(_, sig), holder_holds_function(sig, m), m != sig. + +// ============================================================================ +// A FUNCTION HELD AS AN ELEMENT OF A COLLECTION, AND THE CALL THAT RUNS IT (#967) +// +// `const steps = [widen, narrow]; for (const s of steps) s(node)`. A pipeline registers +// its steps as DATA and calls them by position: the registration is a reference, not a +// call, and the invocation names an element, not a declaration. Neither line was an edge, +// so `widen` had no caller and every declaration it reaches inherited that silence: impact +// on a helper three hops down answered "0 tests" while the tests were all indexed. +// +// The holder rules above follow a function into a variable, field or parameter. This adds +// the one step they lacked: a holder whose value is an ARRAY or OBJECT LITERAL holds each +// callable ELEMENT of it, and a read of an element runs what the holder's elements hold: +// +// * `for (const s of h)`, `for (const s of Object.values(h))` the loop binding holds them; +// * `h[i](x)`, `h[kind](x)`, `const s = h[i]` the element access is one; +// * `h.forEach((s) => s(x))`, `h.reduce((acc, s) => s(acc), x)` the callback's element parameter holds them; +// * `h.run(x)` over `{ run: runImpl }` only the property of that name. +// +// A literal reaches a holder by every route holder_value already has (an initializer, an +// argument a resolved call passes, a field an options literal fills), through `as const` +// and `satisfies`, and `push`/`unshift` and a spread `[...base, f]` add to it. +// +// Envelope only, like every rule in this file: the call-site answer does not move, and a +// call run this way is a `fn_value_call` — a dispatch-rung route, not a resolved edge, +// which is the certainty it has: `steps[0]` runs ONE of the elements. +// ============================================================================ + +// ── holder_elem_value(Holder, Expr): Expr is an element of a collection the holder holds ── +holder_elem_value(h, x) :- holder_value(h, e), + value_branch(e, lit), + expr_kind("client", k, _, lit), + collection_literal_child(k, r), + expr_child("client", lit, r, _, x). +collection_literal_child("ARRAY_LITERAL", "ARRAY_ELEMENT"). +collection_literal_child("OBJECT_LITERAL", "OBJECT_PROPERTY_VALUE"). +// `h.push(f)`, `h.unshift(f)` — a registry filled after it is declared +holder_elem_value(h, a) :- call_site("client", "METHOD_CALL", n, _, recv, ce, _), + array_method_adds(n), + expr_holder(recv, h), + expr_child("client", ce, "ARGUMENT", _, a). +array_method_adds("push"). +array_method_adds("unshift"). + +// ── expr_holder(Expr, Holder): the expression names the holder ── +expr_holder(x, h) :- expr_referenced("client", k, h, x), holder_ref_kind(k). +expr_holder(x, f) :- ts_field_access_target(x, f). +// an imported `const` is the exporting module's variable +expr_holder(x, v) :- expr_referenced("client", "IMPORT_BINDING", ih, x), + import_binds(ih, _, "VARIABLE", v). + +// ── holder_elem_fn(Holder, Fn): an element of the holder's collection is, or holds, Fn ── +holder_elem_fn(h, m) :- holder_elem_value(h, x), + !expr_kind("client", "CALL_EXPRESSION", _, x), + expr_callable(x, m). +holder_elem_fn(h, m) :- holder_elem_value(h, x), + expr_holder(x, h2), + holder_holds_function(h2, m). +// an object literal's own method: `{ run() {…} }` +holder_elem_fn(h, m) :- holder_value(h, e), + value_branch(e, l), + objlit_member_fn(l, _, m). +// `[...base, f]` — the spread array's elements are this one's +holder_elem_fn(h, m) :- holder_elem_value(h, x), + expr_kind("client", "SPREAD_ELEMENT", _, x), + expr_child("client", x, "SPREAD_OPERAND", _, y), + expr_holder(y, h2), + holder_elem_fn(h2, m). +// `const all = steps` — a holder handed another holder's collection holds its elements +holder_elem_fn(h, m) :- holder_value(h, e), + value_branch(e, x), + expr_holder(x, h2), + h2 != h, + holder_elem_fn(h2, m). + +// ── the reads ─────────────────────────────────────────────────────────────── +// `h[i]`, `h[kind]`: any element (an index or a computed key picks one we cannot) +elem_read_fn(x, m) :- expr_kind("client", "ELEMENT_ACCESS", _, x), + expr_child("client", x, "RECEIVER", _, r), + expr_holder(r, h), + holder_elem_fn(h, m). +// `h.run`: the property of that name, when the holder holds an object literal +elem_read_fn(x, m) :- property_access_name(x, n), + expr_child("client", x, "RECEIVER", _, r), + expr_holder(r, h), + holder_value(h, e), + value_branch(e, l), + objlit_prop(l, n, val), + expr_callable(val, m). +elem_read_fn(x, m) :- property_access_name(x, n), + expr_child("client", x, "RECEIVER", _, r), + expr_holder(r, h), + holder_value(h, e), + value_branch(e, l), + objlit_prop(l, n, val), + expr_holder(val, h2), + holder_holds_function(h2, m). + +// the collection an expression iterates: a holder, or `Object.values(holder)` +iterated_elem_fn(e, m) :- expr_holder(e, h), holder_elem_fn(h, m). +iterated_elem_fn(ce, m) :- call_site("client", "METHOD_CALL", "values", _, recv, ce, _), + expr_literal_value("client", "Object", recv), + expr_referenced("client", "AMBIENT_GLOBAL", _, recv), + expr_child("client", ce, "ARGUMENT", "0", a), + expr_holder(a, h), + holder_elem_fn(h, m). + +// `for (const s of h)` — a plain binding; a destructuring pattern names a PART of the element +holder_holds_function(v, m) :- for_binding_iterable(v, e), + !var_binding("client", _, _, _, _, v), + iterated_elem_fn(e, m). +// `const s = h[i]` +holder_holds_function(h, m) :- holder_value(h, e), + value_branch(e, x), + elem_read_fn(x, m). +// `h.forEach((s) => …)`: the callback parameter that receives an element +holder_holds_function(p, m) :- call_site("client", "METHOD_CALL", n, _, recv, ce, _), + array_method_element_param(n, pos), + iterated_elem_fn(recv, m), + expr_child("client", ce, "ARGUMENT", "0", a), + expr_anon_decl("client", cb, a), + param_decl("client", _, pos, _, cb, p). +array_method_element_param("forEach", "0"). +array_method_element_param("map", "0"). +array_method_element_param("flatMap", "0"). +array_method_element_param("filter", "0"). +array_method_element_param("find", "0"). +array_method_element_param("findLast", "0"). +array_method_element_param("some", "0"). +array_method_element_param("every", "0"). +array_method_element_param("reduce", "1"). +array_method_element_param("reduceRight", "1"). + +// `h[i](x)`, `h.run(x)` +call_runs(ce, m) :- call_callee_expr(_, ce, ne), elem_read_fn(ne, m). diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 88668c603..d4bd890dd 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -3,6 +3,7 @@ .decl ancestor_of_merged_group(c0:symbol,c1:symbol,c2:symbol) .decl arg_compatible_at(c0:symbol,c1:symbol,c2:symbol) .decl arg_matches_concretely(c0:symbol,c1:symbol,c2:symbol) +.decl keyof_operand_target(c0:symbol,c1:symbol) .decl specificity_beaten(c0:symbol,c1:symbol) .decl specificity_worse_somewhere(c0:symbol,c1:symbol,c2:symbol) .decl arg_is_function_value(c0:symbol,c1:symbol) @@ -195,12 +196,24 @@ .decl compound_op_keeps_value(c0:symbol) .decl value_branch(c0:symbol,c1:symbol) .decl binary_op_yields_operand(c0:symbol,c1:symbol) +.decl binary_op_names_operand(c0:symbol,c1:symbol) .decl holder_holds_function(c0:symbol,c1:symbol) +.decl call_runs_client_body(c0:symbol) +.decl hof_boundary_site(c0:symbol) +.decl handed_function(c0:symbol,c1:symbol) .decl called_through(c0:symbol,c1:symbol) .decl method_enclosing_member(c0:symbol,c1:symbol) .decl call_runs(c0:symbol,c1:symbol) .decl fn_value_call(c0:symbol,c1:symbol) // (caller, fn): caller makes a call that runs fn, a function a holder holds or a call returned .decl holder_ref_kind(c0:symbol) +.decl holder_elem_value(c0:symbol,c1:symbol) // (holder, expr): expr is an element of a collection literal the holder holds (#967) +.decl collection_literal_child(c0:symbol,c1:symbol) +.decl array_method_adds(c0:symbol) +.decl expr_holder(c0:symbol,c1:symbol) +.decl holder_elem_fn(c0:symbol,c1:symbol) // (holder, fn): an element of the holder's collection is, or holds, fn +.decl elem_read_fn(c0:symbol,c1:symbol) +.decl iterated_elem_fn(c0:symbol,c1:symbol) +.decl array_method_element_param(c0:symbol,c1:symbol) .decl field_in_scope(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl field_initializer(c0:symbol,c1:symbol,c2:symbol) .decl objlit_prop_arrow(c0:symbol,c1:symbol,c2:symbol) @@ -317,6 +330,7 @@ .decl method_lines(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) .decl method_max_arity(c0:symbol,c1:number) .decl method_min_arity(c0:symbol,c1:number) +.decl method_access(c0:symbol,c1:symbol,c2:symbol) .decl method_module(c0:symbol,c1:symbol,c2:symbol) .decl method_module_scope(c0:symbol,c1:symbol) .decl method_owner(c0:symbol,c1:symbol,c2:symbol) @@ -365,6 +379,29 @@ .decl new_expression_type(c0:symbol,c1:symbol,c2:symbol) .decl object_prototype_member(c0:symbol,c1:symbol) .decl overload_beaten(c0:symbol,c1:symbol) +.decl method_block(c0:symbol,c1:symbol) +.decl method_in_augmentation(c0:symbol) +.decl module_kind_is_augmentation(c0:symbol) +.decl cross_block_pair(c0:symbol,c1:symbol) +.decl block_tried_before(c0:symbol,c1:symbol) +.decl augmentation_differs(c0:symbol,c1:symbol) +.decl block_order_candidate(c0:symbol,c1:symbol,c2:symbol) +.decl param_target_at(c0:symbol,c1:symbol,c2:symbol) +.decl param_target_required(c0:symbol,c1:symbol) +.decl param_target_required_count(c0:symbol,c1:number) +.decl arg_param_pair(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl arg_covers_member(c0:symbol,c1:symbol,c2:symbol) +.decl arg_covers_param(c0:symbol,c1:symbol,c2:symbol) +.decl arg_evidenced(c0:symbol,c1:symbol,c2:symbol) +.decl arg_evidence_gap(c0:symbol,c1:symbol) +.decl block_order_wins(c0:symbol,c1:symbol,c2:symbol) +.decl augmentation_host(c0:symbol,c1:symbol) +.decl module_kind_is_file(c0:symbol) +.decl arg_param_uncovered(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl compat_required(c0:symbol,c1:symbol) +.decl compat_required_count(c0:symbol,c1:number) +.decl compat_covers_member(c0:symbol,c1:symbol,c2:symbol) +.decl arg_type_covers(c0:symbol,c1:symbol) .decl overload_index_of(c0:symbol,c1:number) .decl overload_selectable(c0:symbol,c1:symbol) .decl owner_declares_param(c0:symbol,c1:symbol) @@ -374,6 +411,7 @@ .decl binding_pattern_type(c0:symbol,c1:symbol,c2:symbol) .decl param_binding_method(c0:symbol,c1:symbol) .decl param_binding(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol) +.decl param_keyof_object(c0:symbol,c1:symbol,c2:symbol) .decl param_kind_is_positional(c0:symbol) .decl param_decl(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol) .decl param_is_string_literal(c0:symbol,c1:symbol) @@ -592,3 +630,27 @@ .decl method_kind_is_assignable_signature(c0:symbol) .decl package_entry(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol,c6:symbol,c7:symbol) .decl cfg_published_module(c0:symbol) +.decl cfg_package_module(c0:symbol) +.decl cfg_published_export(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl cfg_published_class(c0:symbol) +.decl cfg_member_hidden(c0:symbol) +.decl cfg_published_value(c0:symbol) +.decl bean_def(c0:symbol,c1:symbol,c2:symbol,c3:symbol) +.decl inject_point(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol) +.decl di_edge(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol) +.decl ts_di_ctor(c0:symbol,c1:symbol) +.decl ts_di_token_param(c0:symbol) +.decl ts_di_fits(c0:symbol,c1:symbol,c2:symbol) +.decl ts_di_satisfied(c0:symbol) +.decl ia_literal_key(c0:symbol,c1:symbol) +.decl ref_string_literal(c0:symbol,c1:symbol) +.decl string_quote(c0:symbol) +.decl ia_object(c0:symbol,c1:symbol) +.decl ref_member(c0:symbol,c1:symbol,c2:symbol) +.decl ia_under(c0:symbol,c1:symbol) +.decl alias_ia(c0:symbol,c1:symbol) +.decl alias_ia_key(c0:symbol,c1:symbol,c2:symbol) +.decl alias_ia_object(c0:symbol,c1:symbol,c2:symbol) +.decl ref_member_method(c0:symbol,c1:symbol) +.decl fn_returns_fn(c0:symbol,c1:symbol) +.decl fn_type_return_ref(c0:symbol,c1:symbol) diff --git a/graph/typescript/souffle/export_manifest.tsv b/graph/typescript/souffle/export_manifest.tsv index 06425c8b7..fb938b8fa 100644 --- a/graph/typescript/souffle/export_manifest.tsv +++ b/graph/typescript/souffle/export_manifest.tsv @@ -48,3 +48,5 @@ type_instantiated resolution-type-instantiated.csv field_access field-access.csv type_use type-use.csv field_site_excluded field-site-excluded.csv +inject_point config-inject-point.csv +di_edge config-di-edge.csv diff --git a/package.json b/package.json index 33e5e5bfb..db70f7a16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@axiomcode/code-graph", - "version": "0.1.4", + "version": "0.1.6", "description": "AxiomCode Graph: a resolved call graph of your codebase grounded in formal methods, so you and your coding agents can see who calls what, what a change breaks, and which tests it reaches.", "repository": { "type": "git", @@ -30,11 +30,11 @@ ], "license": "FSL-1.1-Apache-2.0", "optionalDependencies": { - "@axiomcode/engine-darwin-arm64": "0.1.4", - "@axiomcode/engine-darwin-x64": "0.1.4", - "@axiomcode/engine-linux-x64": "0.1.4", - "@axiomcode/engine-linux-arm64": "0.1.4", - "@axiomcode/engine-win32-x64": "0.1.4" + "@axiomcode/engine-darwin-arm64": "0.1.6", + "@axiomcode/engine-darwin-x64": "0.1.6", + "@axiomcode/engine-linux-x64": "0.1.6", + "@axiomcode/engine-linux-arm64": "0.1.6", + "@axiomcode/engine-win32-x64": "0.1.6" }, "bin": { "axiomcode": "bin/axiomcode.js" diff --git a/parser/src/analysis-types/services/ServiceDescriptor.ts b/parser/src/analysis-types/services/ServiceDescriptor.ts index b4e28f65a..5a992646a 100644 --- a/parser/src/analysis-types/services/ServiceDescriptor.ts +++ b/parser/src/analysis-types/services/ServiceDescriptor.ts @@ -46,6 +46,7 @@ export class ServiceDescriptor implements EntityIdentifiable { private filePath: string; private baseMservPath: string; private serviceVersionLinkHash: string; + private entryKey: string; private serviceDescriptorUniqueHash: string = ''; private constructor(builder: ServiceDescriptorBuilder) { @@ -62,6 +63,7 @@ export class ServiceDescriptor implements EntityIdentifiable { this.filePath = builder.filePath; this.baseMservPath = builder.baseMservPath; this.serviceVersionLinkHash = builder.serviceVersionLinkHash; + this.entryKey = builder.entryKey; this.generateHash(); } @@ -110,7 +112,11 @@ export class ServiceDescriptor implements EntityIdentifiable { const content = this.filePath + '||' + this.baseMservPath + - '||' + this.serviceVersionLinkHash; + '||' + this.serviceVersionLinkHash + + // One file, several descriptors: `spring.factories` configures one service + // per KEY. Appended only when set, so a one-descriptor file keeps the key + // it always had. + (this.entryKey ? '||' + this.entryKey : ''); this.serviceDescriptorUniqueHash = EntityUtils.generateEntityHash( ENTITY_IDENTIFIERS.SERVICE_DESCRIPTOR, @@ -192,6 +198,7 @@ class ServiceDescriptorBuilder { filePath: string; baseMservPath: string; serviceVersionLinkHash: string; + entryKey: string = ''; constructor( serviceInterfaceBinaryName: string, @@ -251,6 +258,12 @@ class ServiceDescriptorBuilder { return this; } + /** The key inside a file that configures several services (`spring.factories`). */ + withEntryKey(entryKey: string): ServiceDescriptorBuilder { + this.entryKey = entryKey; + return this; + } + build(): ServiceDescriptor { return new (ServiceDescriptor as any)(this); } diff --git a/parser/src/constants/consts.ts b/parser/src/constants/consts.ts index 000999d09..77bbeb883 100644 --- a/parser/src/constants/consts.ts +++ b/parser/src/constants/consts.ts @@ -185,3 +185,8 @@ export const GRADLE_DEFAULT_VERSION_CATALOG = 'gradle/libs.versions.toml'; */ export const META_INF_DIR = 'META-INF'; export const SERVICES_DIR = 'services'; +/** `META-INF/spring/.imports`: Spring Boot's registration files (auto-configurations). */ +export const SPRING_DIR = 'spring'; +export const SPRING_IMPORTS_SUFFIX = '.imports'; +/** `META-INF/spring.factories`: Spring's `SpringFactoriesLoader` registry, a properties file. */ +export const SPRING_FACTORIES_FILE = 'spring.factories'; diff --git a/parser/src/parsers/javascript/extractors/js-declaration-extractor.ts b/parser/src/parsers/javascript/extractors/js-declaration-extractor.ts index 718bb4dd5..e1794d7e8 100644 --- a/parser/src/parsers/javascript/extractors/js-declaration-extractor.ts +++ b/parser/src/parsers/javascript/extractors/js-declaration-extractor.ts @@ -800,8 +800,10 @@ export class JsDeclarationExtractor { * A declared constructor is NOT reused as the owner even though it is where the * initializer runs: a field written above the constructor would then be an expression * outside its owner's span, which is the containment invariant that catches context - * leaking down the traversal. The synthetic row spans the class, so it contains every - * initializer in it whatever the member order. + * leaking down the traversal. The synthetic row spans the fields it runs, from the first + * to the last, so it contains every initializer it owns whatever the member order. It + * does NOT span the class: a callable's span is what it defines, and the methods written + * beside the fields are its siblings (#1663). */ private attributeFieldInitializers(node: ts.ClassLikeDeclaration, context: WalkContext): void { if (context.ownerType === undefined) { @@ -812,27 +814,26 @@ export class JsDeclarationExtractor { if (first === undefined) { continue; } + const owned = node.members.filter((member): member is ts.PropertyDeclaration => + ts.isPropertyDeclaration(member) && member.initializer !== undefined + && !isCallableExpression(member.initializer) && initializerRunsCode(member.initializer) + && hasModifier(member, ts.SyntaxKind.StaticKeyword) === isStatic); // One callable per class per staticness, hung off the same field the scope builder // opened the scope on, so the row's body scope is a scope that exists. - const owner = this.synthesizeInitializerOwner(node, first, context, isStatic); - for (const member of node.members) { - if (!ts.isPropertyDeclaration(member) || member.initializer === undefined - || isCallableExpression(member.initializer) || !initializerRunsCode(member.initializer)) { - continue; - } - if (hasModifier(member, ts.SyntaxKind.StaticKeyword) === isStatic) { - this.fieldInitOwnerByNode.set(nodeKey(member), owner); - } + const owner = this.synthesizeInitializerOwner(first, owned[owned.length - 1] ?? first, context, isStatic); + for (const member of owned) { + this.fieldInitOwnerByNode.set(nodeKey(member), owner); } } } - /** The synthetic callable a class's field initializers run inside. */ + /** The synthetic callable a class's field initializers run inside, spanning `first` to `last`. */ private synthesizeInitializerOwner( - node: ts.ClassLikeDeclaration, first: ts.PropertyDeclaration, + first: ts.PropertyDeclaration, last: ts.PropertyDeclaration, context: WalkContext, isStatic: boolean, ): string { - const at = this.positionOf(node); + const start = this.positionOf(first); + const at = { ...start, endLine: this.positionOf(last).endLine }; const bodyScope = this.options.binder.scopeOpenedBy.get(nodeKey(first)) ?? context.scope; const ownerType = context.ownerType; const name = isStatic ? '' : ''; diff --git a/parser/src/parsers/python/extractors/python-field-extractor.ts b/parser/src/parsers/python/extractors/python-field-extractor.ts index ef4374779..08b87a908 100644 --- a/parser/src/parsers/python/extractors/python-field-extractor.ts +++ b/parser/src/parsers/python/extractors/python-field-extractor.ts @@ -83,6 +83,11 @@ interface WriteObservation { */ writtenBuiltinType: string; origin: PythonFieldOrigin; + /** + * 1-based, like every other Python relation. tree-sitter rows are 0-based, so + * each site adds 1; without it every field sat one line above its write, and a + * nested class's first field landed on the `class Meta:` line itself. + */ line: number; endLine: number; methodHash: string; @@ -416,8 +421,8 @@ export class PythonFieldExtractor { collection.observations.push({ name, origin, - line: target.startPosition.row, - endLine: assignment.endPosition.row, + line: target.startPosition.row + 1, + endLine: assignment.endPosition.row + 1, methodHash: '', methodName: '', receiverName: '', @@ -448,8 +453,8 @@ export class PythonFieldExtractor { collection.observations.push({ name: this.mangle(collection.typeName, literal), origin: PythonFieldOrigin.SLOTS_ENTRY, - line: node.startPosition.row, - endLine: node.endPosition.row, + line: node.startPosition.row + 1, + endLine: node.endPosition.row + 1, methodHash: '', methodName: '', receiverName: '', @@ -728,8 +733,8 @@ export class PythonFieldExtractor { collection.observations.push({ name: this.mangle(collection.typeName, literal), origin: PythonFieldOrigin.SETATTR_DYNAMIC, - line: call.startPosition.row, - endLine: call.endPosition.row, + line: call.startPosition.row + 1, + endLine: call.endPosition.row + 1, methodHash, methodName, receiverName, @@ -764,8 +769,8 @@ export class PythonFieldExtractor { collection.observations.push({ name: this.mangle(collection.typeName, nameOverride || attributeName!.text), origin, - line: target.startPosition.row, - endLine: statement.endPosition.row, + line: target.startPosition.row + 1, + endLine: statement.endPosition.row + 1, methodHash, methodName, receiverName, diff --git a/parser/src/parsers/services/services-parser.ts b/parser/src/parsers/services/services-parser.ts index 89794016d..6fdd2186d 100644 --- a/parser/src/parsers/services/services-parser.ts +++ b/parser/src/parsers/services/services-parser.ts @@ -84,15 +84,19 @@ export class ServicesParser { * @param filePath Absolute path to the file * @param baseMservPath Project root path * @param serviceVersionLinkHash Service version hash + * @param serviceName The service being configured, when the file name is not + * it verbatim: Spring's `META-INF/spring/.imports` has the same + * one-class-per-line, `#`-comment format and names its service `` * @returns Tuple of [ServiceDescriptor, ServiceProvider[]] */ parse( content: string, filePath: string, baseMservPath: string, - serviceVersionLinkHash: string + serviceVersionLinkHash: string, + serviceName?: string ): [ServiceDescriptor, ServiceProvider[]] { - const fileName = path.basename(filePath); + const fileName = serviceName ?? path.basename(filePath); const service = this.resolveBinaryName(fileName); const lines = this.split(content); const parsedLines = this.parseProviderLines(lines); @@ -113,6 +117,130 @@ export class ServicesParser { .withRelativePath(this.toPosix(path.relative(baseMservPath, filePath)) || fileName) .build(); + const providers = this.buildProviders(parsedLines, descriptor, filePath, baseMservPath, serviceVersionLinkHash); + descriptor.setProviderCounts( + providers.length, + providers.filter((p) => p.getIsWellFormedName()).length + ); + + return [descriptor, providers]; + } + + /** + * Parses `META-INF/spring.factories`: one descriptor per KEY, one provider per + * comma-separated class in its value. + * + * The file is a `java.util.Properties` file, which `SpringFactoriesLoader` + * reads as such, so its clauses are the Properties ones and not the + * ServiceLoader ones: a comment is a LINE whose first non-blank character is + * `#` or `!` (a `#` inside a value is text); a line ending in an odd number of + * backslashes continues on the next, whose leading blanks are dropped; the key + * ends at the first unescaped `=`, `:` or blank. Each provider keeps the + * physical line and columns it was written on, so a continued value still + * points at the line that names the class. + */ + parseSpringFactories( + content: string, + filePath: string, + baseMservPath: string, + serviceVersionLinkHash: string + ): Array<[ServiceDescriptor, ServiceProvider[]]> { + const lines = this.split(content); + const relativePath = this.toPosix(path.relative(baseMservPath, filePath)) || path.basename(filePath); + const byKey = new Map(); + + for (const logical of this.logicalLines(lines)) { + const { text, at } = logical; + let i = 0; + while (i < text.length && (text[i] === ' ' || text[i] === '\t' || text[i] === '\f')) i++; + if (i >= text.length || text[i] === '#' || text[i] === '!') continue; + const keyStart = i; + while (i < text.length && !'=: \t\f'.includes(text[i]!)) { + i += text[i] === '\\' ? 2 : 1; + } + const key = text.slice(keyStart, i); + while (i < text.length && (text[i] === ' ' || text[i] === '\t' || text[i] === '\f')) i++; + if (i < text.length && (text[i] === '=' || text[i] === ':')) i++; + + const parsed = byKey.get(key) ?? []; + let start = i; + for (let j = i; j <= text.length; j++) { + if (j < text.length && text[j] !== ',') continue; + const raw = text.slice(start, j); + const lead = raw.length - raw.trimStart().length; + const name = raw.trim(); + if (name.length > 0) { + const [line, col] = at[start + lead]!; + parsed.push({ binaryName: name, line, startCol: col, endCol: col + name.length, hasInlineComment: false }); + } + start = j + 1; + } + byKey.set(key, parsed); + } + + const out: Array<[ServiceDescriptor, ServiceProvider[]]> = []; + for (const [key, parsedLines] of byKey) { + const service = this.resolveBinaryName(key); + const descriptor = ServiceDescriptor.builder(key, filePath, baseMservPath, serviceVersionLinkHash) + .withServiceInterface(service.qualifiedName) + .withSimpleName(service.simpleName) + .withPackageName(service.packageName) + .withIsNestedServiceName(service.isNested) + .withIsWellFormedServiceName(service.isWellFormed) + .withLineCount(lines.length) + .withRelativePath(relativePath) + .withEntryKey(key) + .build(); + const providers = this.buildProviders(parsedLines, descriptor, filePath, baseMservPath, serviceVersionLinkHash); + descriptor.setProviderCounts( + providers.length, + providers.filter((p) => p.getIsWellFormedName()).length + ); + out.push([descriptor, providers]); + } + return out; + } + + /** + * Joins backslash-continued physical lines into logical ones, keeping for each + * character the 1-based line and 0-based column it came from. + */ + private logicalLines(lines: string[]): Array<{ text: string; at: Array<[number, number]> }> { + const out: Array<{ text: string; at: Array<[number, number]> }> = []; + let text = ''; + let at: Array<[number, number]> = []; + let continuing = false; + lines.forEach((raw, index) => { + let from = 0; + if (continuing) { + while (from < raw.length && (raw[from] === ' ' || raw[from] === '\t' || raw[from] === '\f')) from++; + } + let trailing = 0; + for (let k = raw.length - 1; k >= from && raw[k] === '\\'; k--) trailing++; + const continues = trailing % 2 === 1; + const to = continues ? raw.length - 1 : raw.length; + for (let k = from; k < to; k++) { + text += raw[k]; + at.push([index + 1, k]); + } + continuing = continues; + if (!continues) { + out.push({ text, at }); + text = ''; + at = []; + } + }); + if (continuing) out.push({ text, at }); + return out; + } + + private buildProviders( + parsedLines: ParsedProviderLine[], + descriptor: ServiceDescriptor, + filePath: string, + baseMservPath: string, + serviceVersionLinkHash: string + ): ServiceProvider[] { const seen = new Set(); const providers: ServiceProvider[] = []; @@ -145,12 +273,7 @@ export class ServicesParser { ); }); - descriptor.setProviderCounts( - providers.length, - providers.filter((p) => p.getIsWellFormedName()).length - ); - - return [descriptor, providers]; + return providers; } /** diff --git a/parser/src/parsers/typescript/extractors/ts-resolution-linker.ts b/parser/src/parsers/typescript/extractors/ts-resolution-linker.ts index f08089ccd..e7bd514dc 100644 --- a/parser/src/parsers/typescript/extractors/ts-resolution-linker.ts +++ b/parser/src/parsers/typescript/extractors/ts-resolution-linker.ts @@ -544,6 +544,14 @@ export class TsLocalResolver { if (binding.kind !== TsBoundKind.VariableDeclaration) { return; } + // An annotation this pass cannot read -- an indexed access such as + // `StoreApi['setState']`, a named interface, an alias to an alias -- + // still decides the call for tsc, and it may declare an overload set. The + // initialiser is not what `getResolvedSignature` names, and `1` would be a + // count nobody measured (#536). Left unresolved for the engine. + if (declared.length === 0 && (binding.node as { type?: ts.TypeNode }).type) { + return; + } const variableHash = this.input.variableHashByNode.get(nodeId(binding.node, this.sf)); const variable = variableHash ? this.variableByHash.get(variableHash) : undefined; // THE arrow-function path, for an UNANNOTATED binding: `const f = () => {}; diff --git a/parser/src/test-data/python/verified/_golden/fields.golden b/parser/src/test-data/python/verified/_golden/fields.golden index 6f1d24d56..d5cb2e89c 100644 --- a/parser/src/test-data/python/verified/_golden/fields.golden +++ b/parser/src/test-data/python/verified/_golden/fields.golden @@ -1,49 +1,49 @@ -name=GREEN fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=30 endLine=30 pyTypeLinkHash=PY_TYPE_9e523515e44d6cfd9bc7abbd870a39fc ownerTypeName=Colour ownerQualifiedName=class_forms.Colour fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,ENUM_MEMBER,READ_ONLY fieldOrigin=ENUM_MEMBER declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=30 hasAnnotation=false annotationIsString=false initializerText=2 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash=PY_BINDING_57ddbcd9c75cb175fbf6a58f0413624c pyExpressionLinkHash=PY_EXPRESSION_6b100833fc3d59ad93d115d8db3d7889 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_354cf9941bff4d01e1a412286829e26f -name=KIND fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=5 endLine=5 pyTypeLinkHash=PY_TYPE_bf6354f36ebb9ff7d338d07707f4d6c9 ownerTypeName=Builder ownerQualifiedName=core.factory.Builder fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=5 hasAnnotation=false annotationIsString=false initializerText="""builder""" initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_7908beb43f8c739558eabfcf93ebca84 bindingLinkHash=PY_BINDING_8f17cce2244c83d9f0b22c927181e57c pyExpressionLinkHash=PY_EXPRESSION_6b2dc1f944007de6dc6ad962a10c4d68 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_da9d8e5a0a6fb23461a6d67eebf0b406 -name=RED fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=29 endLine=29 pyTypeLinkHash=PY_TYPE_9e523515e44d6cfd9bc7abbd870a39fc ownerTypeName=Colour ownerQualifiedName=class_forms.Colour fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,ENUM_MEMBER,READ_ONLY fieldOrigin=ENUM_MEMBER declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=29 hasAnnotation=false annotationIsString=false initializerText=1 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash=PY_BINDING_788efc874ee5c9fe8de3cf8d313ccb08 pyExpressionLinkHash=PY_EXPRESSION_08330318b58d2f5435334c19e44e1bad serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_b63b80d192db3ec3d30504b33ab5a7d5 -name=_Inner__secret fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=26 endLine=26 pyTypeLinkHash=PY_TYPE_e0b00a82da98016f61db58a6de64ca3a ownerTypeName=Inner ownerQualifiedName=name_mangling_edge_cases.Outer.Inner fieldAccess=PRIVATE_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=26 hasAnnotation=false annotationIsString=false initializerText="""inner secret""" initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_75dd800d24b53dd3ca1d826ca1525876 bindingLinkHash=PY_BINDING_5f168c8d5c239011cb96ae52de48a359 pyExpressionLinkHash=PY_EXPRESSION_8d9a61772e886da7597f48f8d04adb1a serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_2ee87edc1495cf4099a701d55e5e6c46 -name=_Outer__secret fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=23 endLine=23 pyTypeLinkHash=PY_TYPE_fd26c3cd94bd9663ee01dd2cfe94159d ownerTypeName=Outer ownerQualifiedName=name_mangling_edge_cases.Outer fieldAccess=PRIVATE_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=23 hasAnnotation=false annotationIsString=false initializerText="""outer secret""" initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_75dd800d24b53dd3ca1d826ca1525876 bindingLinkHash=PY_BINDING_49c59ee9461329034a85a0296cef5067 pyExpressionLinkHash=PY_EXPRESSION_37ac9136831ba123510a02a1f4e8a855 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_f2cd30b8381aef7fb269f0afaa8e87df -name=_v fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=23 endLine=23 pyTypeLinkHash=PY_TYPE_a1bd5124fede6d4f3504a0dd00092899 ownerTypeName=Kinds ownerQualifiedName=method_kinds.Kinds fieldAccess=PROTECTED_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_43ebe179bc6f5e46ee12ceb6ef8f8371 receiverName=self isDeclaredInInit=false writeCount=1 writtenInMethodCount=1 firstWriteLine=23 hasAnnotation=false annotationIsString=false initializerText=value initializerKind=NAME pyModuleLinkHash=PY_MODULE_7f80b0a69e6ccd5fc2e3932cc9e5dc10 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_6c7b177707416257783b9ec57e0e0db7 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_dcf0d30e2575c32c0165dde58ab421cd -name=a fieldTypeName=int fieldBaseType=int potentialQualifiedName=field_origins.Named.a isAmbiguous=false startLine=26 endLine=26 pyTypeLinkHash=PY_TYPE_57e2471ba226031232617e9a2c039483 ownerTypeName=Named ownerQualifiedName=field_origins.Named fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=NAMEDTUPLE_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=26 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_9e2b1a44d2cdbd153e83899e6455543a pyExpressionLinkHash=PY_EXPRESSION_655c42272094ed05fdc74a8ceca41e9a serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_f5cf13ceb805ace0621794c47b0edd1c -name=amount fieldTypeName=int fieldBaseType=int potentialQualifiedName=service_layer.Record.amount isAmbiguous=false startLine=8 endLine=8 pyTypeLinkHash=PY_TYPE_8ffa51c20183b9069bc165293685e3fe ownerTypeName=Record ownerQualifiedName=service_layer.Record fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=8 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_87ff823ca7cda9220c2fe03210807591 bindingLinkHash=PY_BINDING_57bc7c03bfbb607dd13cde9fc0c4fa82 pyExpressionLinkHash=PY_EXPRESSION_0e91a6686acdabf7ba17b29df0ed45ef serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_1b8b80b6ac20cbea90df5020685db27e -name=args fieldTypeName=tuple fieldBaseType=tuple potentialQualifiedName=match_nested_patterns_and_guards.Command.args isAmbiguous=false startLine=17 endLine=17 pyTypeLinkHash=PY_TYPE_0d090a4533bb97a6729a4b05d91e8042 ownerTypeName=Command ownerQualifiedName=match_nested_patterns_and_guards.Command fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=17 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_fcb43aba22913355ac115ae9c4556240 bindingLinkHash=PY_BINDING_9a435ed4c1ece9c11135dbd0160b4df5 pyExpressionLinkHash=PY_EXPRESSION_0ee6d1d4f1d073dd98461650dcad5106 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_3957058ce75e0648a430fbe98178267e -name=attribute fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=13 endLine=13 pyTypeLinkHash=PY_TYPE_ba2fbe8e09ca96c2ac317281255eb0d6 ownerTypeName=Documented ownerQualifiedName=comment_forms.Documented fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=13 hasAnnotation=false annotationIsString=false initializerText=1 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_898ba18a31075a2ba7818f72c287b8d1 bindingLinkHash=PY_BINDING_2424a859b65550cd9888982deae03d22 pyExpressionLinkHash=PY_EXPRESSION_ffabf59979fb14780a4b3608e603c0d1 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_385e7b366a08ca3716f29841e22cc22f -name=b fieldTypeName=str fieldBaseType=str potentialQualifiedName=field_origins.Named.b isAmbiguous=false startLine=27 endLine=27 pyTypeLinkHash=PY_TYPE_57e2471ba226031232617e9a2c039483 ownerTypeName=Named ownerQualifiedName=field_origins.Named fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=NAMEDTUPLE_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=27 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_9557301ac4f28d860f8ea85d7d5f12c3 pyExpressionLinkHash=PY_EXPRESSION_3ef8970040c01d72c72ad800129733ee serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_310894d9c629bedb0d3af75ed37d2926 -name=children fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=6 endLine=6 pyTypeLinkHash=PY_TYPE_114cb72d42012abcb4ffcbce3bc6a345 ownerTypeName=Node ownerQualifiedName=core.base.Node fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_1800af667e7076b626bd654b5e3dbebd receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=6 hasAnnotation=false annotationIsString=false initializerText=[] initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_0af7a9df251376bafe876afe4a32f46e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_2b5c5d4e2ede82425c6cc08afb31af7e serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_379b43102358abde65f30ca1de0bcd84 -name=class_body_annotation fieldTypeName=int fieldBaseType=int potentialQualifiedName=field_origins.Origins.class_body_annotation isAmbiguous=false startLine=7 endLine=7 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ANNOTATION_ONLY declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=7 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_b06aa81bc5c9cd99d90ba02007b150fe pyExpressionLinkHash=PY_EXPRESSION_545963f8035cf0bdb65e61c5d4e613c6 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_95475ba063df3c93c21f1c308805edd9 -name=class_body_assign fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=6 endLine=6 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=6 hasAnnotation=false annotationIsString=false initializerText=1 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_1d3d16a7ecf67dc4f185bb6856b55ce1 pyExpressionLinkHash=PY_EXPRESSION_4c58afc15bc350db5af24aafaf24281c serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_56d654562f909d6501f78a7efbec6215 -name=class_var fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=52 endLine=52 pyTypeLinkHash=PY_TYPE_e61b2b74f7302f1be447bd28221d2310 ownerTypeName=ClassScope ownerQualifiedName=scope_forms.ClassScope fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=52 hasAnnotation=false annotationIsString=false initializerText=1 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_f56326f63a4c5bef9294b28a4acb9abc bindingLinkHash=PY_BINDING_4bcd55d471aae0bad8d158832c4e4dca pyExpressionLinkHash=PY_EXPRESSION_6068c5654402f0188994f976ddff8750 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_395310756cb1d1d83c7b698fea57e4dc -name=count fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=66 endLine=66 pyTypeLinkHash=PY_TYPE_35883ba9ad396e2203b42abef9e1d7c9 ownerTypeName=Chained ownerQualifiedName=resolution_edges.Chained fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_994dce81c215c9114b56d78e93cdf732 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=66 hasAnnotation=false annotationIsString=false initializerText=0 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_daef721b73c8bfbcc0348299f2f7bf9a bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_7b0cae40fc8c704752083b3ee0fceb16 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_21ead56058d3d069ccf562ee8f4901d4 -name=count fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=69 endLine=69 pyTypeLinkHash=PY_TYPE_35883ba9ad396e2203b42abef9e1d7c9 ownerTypeName=Chained ownerQualifiedName=resolution_edges.Chained fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR fieldOrigin=SELF_AUGASSIGN declaringMethodLinkHash=PY_METHOD_b561ec322633cc1dd8bcac47c5b8c82b receiverName=self isDeclaredInInit=false writeCount=1 writtenInMethodCount=1 firstWriteLine=69 hasAnnotation=false annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_daef721b73c8bfbcc0348299f2f7bf9a bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_76e084392d2dd97ca35228a147447c5e serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_cbc349dfa7dba6a7f967589d4623516d -name=data fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=22 endLine=22 pyTypeLinkHash=PY_TYPE_b4aa287abde8dbb60cac1736e458094f ownerTypeName=ClassScopeIsInvisibleToComprehensions ownerQualifiedName=scoping_edge_cases.ClassScopeIsInvisibleToComprehensions fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=22 hasAnnotation=false annotationIsString=false initializerText=[1, 2, 3] initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_7c5670e72548f0c21d5e8710e5a91903 bindingLinkHash=PY_BINDING_2979d1fb44aef0644d73caeef4e16875 pyExpressionLinkHash=PY_EXPRESSION_9d502d9e8053c97698b1fd82a5f1ef02 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_bebc562b5a7574ce8ac9e36ada3ef18c -name=doubled fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=27 endLine=27 pyTypeLinkHash=PY_TYPE_b4aa287abde8dbb60cac1736e458094f ownerTypeName=ClassScopeIsInvisibleToComprehensions ownerQualifiedName=scoping_edge_cases.ClassScopeIsInvisibleToComprehensions fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=27 hasAnnotation=false annotationIsString=false initializerText=[item * 2 for item in data] initializerKind=COMPREHENSION pyModuleLinkHash=PY_MODULE_7c5670e72548f0c21d5e8710e5a91903 bindingLinkHash=PY_BINDING_463a0938fe7b9c7d0b98f8a91a5fa9b1 pyExpressionLinkHash=PY_EXPRESSION_7a1c506cbc933dcebc9cff34cfeb9098 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_f84a1f38e2ec605e0f7371d09882a819 -name=key fieldTypeName=int fieldBaseType=int potentialQualifiedName=field_origins.Typed.key isAmbiguous=false startLine=31 endLine=31 pyTypeLinkHash=PY_TYPE_ab0f66955e01d63bfcdb185cba8220c9 ownerTypeName=Typed ownerQualifiedName=field_origins.Typed fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=TYPEDDICT_KEY declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=31 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_e2bb803db7c6d1eb22f6564f99c728a6 pyExpressionLinkHash=PY_EXPRESSION_cf8c36787653df27e29b2e8d273a91a4 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_cfe099bdbc0b4a62a3d6135ca67cf7c0 -name=key fieldTypeName=str fieldBaseType=str potentialQualifiedName=service_layer.Record.key isAmbiguous=false startLine=7 endLine=7 pyTypeLinkHash=PY_TYPE_8ffa51c20183b9069bc165293685e3fe ownerTypeName=Record ownerQualifiedName=service_layer.Record fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=7 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_87ff823ca7cda9220c2fe03210807591 bindingLinkHash=PY_BINDING_8c140f9ae98b25bc5c0a4168c96dc0dc pyExpressionLinkHash=PY_EXPRESSION_02ca24b3af6950ca1b62257fad48588b serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_27261db33e56b96f29132dfaa71d3f4e -name=kind fieldTypeName=str fieldBaseType=str potentialQualifiedName=match_nested_patterns_and_guards.Event.kind isAmbiguous=false startLine=10 endLine=10 pyTypeLinkHash=PY_TYPE_2ce58f5e8c9675c1e0fd5c50ffba85b9 ownerTypeName=Event ownerQualifiedName=match_nested_patterns_and_guards.Event fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=10 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_fcb43aba22913355ac115ae9c4556240 bindingLinkHash=PY_BINDING_a71edea3725a72a19e6afe34df82df70 pyExpressionLinkHash=PY_EXPRESSION_24ae5eca16a253b79944f8cf60d1f4cc serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_adf2e9134c1d3b66aedefcfe1ecbe569 -name=label fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=40 endLine=40 pyTypeLinkHash=PY_TYPE_cb9da9964bc3c43761a4e1269506effe ownerTypeName=ClassScopeIsInvisibleToMethods ownerQualifiedName=scoping_edge_cases.ClassScopeIsInvisibleToMethods fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=40 hasAnnotation=false annotationIsString=false initializerText="""class attribute""" initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_7c5670e72548f0c21d5e8710e5a91903 bindingLinkHash=PY_BINDING_98ec45593e2cb658d620508b81299faa pyExpressionLinkHash=PY_EXPRESSION_42fe92a294647ed36376fb8eb05bcab7 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_f1fd14ac02e06017fd967c9e6fed6fb3 -name=n fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=5 endLine=5 pyTypeLinkHash=PY_TYPE_b2f97d5a7e48f40b008fc644ae603fdf ownerTypeName=Holder ownerQualifiedName=expression_kinds.Holder fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_9949f650301345b4e4deaca03bd84aa2 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=5 hasAnnotation=false annotationIsString=false initializerText=n initializerKind=NAME pyModuleLinkHash=PY_MODULE_02d561b0acde0974e99e75b89b4bdab0 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_8d749d70dcd6b628deac2048e53d55e0 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_9df925d8945d2bf31f49db8111a0dc69 -name=name fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=17 endLine=17 pyTypeLinkHash=PY_TYPE_bf6354f36ebb9ff7d338d07707f4d6c9 ownerTypeName=Builder ownerQualifiedName=core.factory.Builder fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_bd7c847866b0674467ec65fee212e98d receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=17 hasAnnotation=false annotationIsString=false initializerText=name initializerKind=NAME pyModuleLinkHash=PY_MODULE_7908beb43f8c739558eabfcf93ebca84 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_585104a79ffa32b57b756846339f5739 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_e099ac70f91cfbdc5ff7c13e73233075 -name=name fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=5 endLine=5 pyTypeLinkHash=PY_TYPE_114cb72d42012abcb4ffcbce3bc6a345 ownerTypeName=Node ownerQualifiedName=core.base.Node fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_1800af667e7076b626bd654b5e3dbebd receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=5 hasAnnotation=false annotationIsString=false initializerText=name initializerKind=NAME pyModuleLinkHash=PY_MODULE_0af7a9df251376bafe876afe4a32f46e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_105c90e8c0edac7578788345181e8b7f serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_4892cbb3eae7d00e22619a4bee9e4477 -name=name fieldTypeName=str fieldBaseType=str potentialQualifiedName=match_nested_patterns_and_guards.Command.name isAmbiguous=false startLine=16 endLine=16 pyTypeLinkHash=PY_TYPE_0d090a4533bb97a6729a4b05d91e8042 ownerTypeName=Command ownerQualifiedName=match_nested_patterns_and_guards.Command fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=16 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_fcb43aba22913355ac115ae9c4556240 bindingLinkHash=PY_BINDING_cd0d311bf325ef8c8ae6238a63dcad6f pyExpressionLinkHash=PY_EXPRESSION_eac151d5d6927868317023b3e1775a9d serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_bdf77c8fc8ad7a8203a0dbfdd9def840 -name=node fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=18 endLine=18 pyTypeLinkHash=PY_TYPE_bf6354f36ebb9ff7d338d07707f4d6c9 ownerTypeName=Builder ownerQualifiedName=core.factory.Builder fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_bd7c847866b0674467ec65fee212e98d receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=18 hasAnnotation=false annotationIsString=false initializerText=Node(name) initializerKind=CALL pyModuleLinkHash=PY_MODULE_7908beb43f8c739558eabfcf93ebca84 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_e901fe2384634e118a14e9746dc8d29a serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_49ef51dcaf6157efde16ddf34306974e -name=nodes fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=27 endLine=27 pyTypeLinkHash=PY_TYPE_f511a7eca38906948dce8170bb4e5495 ownerTypeName=Registry ownerQualifiedName=core.base.Registry fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_d7a26baec95db1ac47ae906b256b56b6 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=27 hasAnnotation=false annotationIsString=false initializerText=[] initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_0af7a9df251376bafe876afe4a32f46e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_37663bc3f580f7f4b5e952c3b83bbb53 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_2a7e11b6b84f1f30e344016dbc9c423d -name=payload fieldTypeName=dict fieldBaseType=dict potentialQualifiedName=match_nested_patterns_and_guards.Event.payload isAmbiguous=false startLine=11 endLine=11 pyTypeLinkHash=PY_TYPE_2ce58f5e8c9675c1e0fd5c50ffba85b9 ownerTypeName=Event ownerQualifiedName=match_nested_patterns_and_guards.Event fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=11 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_fcb43aba22913355ac115ae9c4556240 bindingLinkHash=PY_BINDING_13c4c5fdb58a9e4c8b2de72989a22932 pyExpressionLinkHash=PY_EXPRESSION_1586dc4c0c9ec70cb3d13c84f1727e77 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_01ef6b233822f48581ac56724f516c07 -name=registered fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=35 endLine=35 pyTypeLinkHash=PY_TYPE_a1bd5124fede6d4f3504a0dd00092899 ownerTypeName=Kinds ownerQualifiedName=method_kinds.Kinds fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_5c50c5642d36a230fa6b6e9f7ed1d793 receiverName=cls isDeclaredInInit=false writeCount=1 writtenInMethodCount=1 firstWriteLine=35 hasAnnotation=false annotationIsString=false initializerText=True initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_7f80b0a69e6ccd5fc2e3932cc9e5dc10 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_770096a696c823a0acb4d74638579069 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_5eee83bd12ab74a7e50ee0ca4f615196 -name=repository fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=43 endLine=43 pyTypeLinkHash=PY_TYPE_c4ea4c823c48edb338a6cd78787cae9b ownerTypeName=Service ownerQualifiedName=service_layer.Service fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_c2679ef7c7ff76fd0d905dfb8219e815 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=43 hasAnnotation=false annotationIsString=false initializerText=repository initializerKind=NAME pyModuleLinkHash=PY_MODULE_87ff823ca7cda9220c2fe03210807591 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_39117b39cc41afe8b691c23f84675db1 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_ba06c6ab6360c63d00ccbdfb193ee986 -name=rows fieldTypeName=typing.List[Record] fieldBaseType=List potentialQualifiedName=service_layer.Store.rows isAmbiguous=false startLine=16 endLine=16 pyTypeLinkHash=PY_TYPE_2cf22ea62c0a0c4b7621a04b81853658 ownerTypeName=Store ownerQualifiedName=service_layer.Store fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_1b1abae80728c42e4cc6b0dbc18b5ec2 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=16 hasAnnotation=true annotationIsString=false initializerText=[] initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_87ff823ca7cda9220c2fe03210807591 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_ef6124b63d25a3882170fe90d7e7aa10 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_982e6f2f24bd41d8e0dcef33a86a3a6a -name=scaled fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=36 endLine=36 pyTypeLinkHash=PY_TYPE_b4aa287abde8dbb60cac1736e458094f ownerTypeName=ClassScopeIsInvisibleToComprehensions ownerQualifiedName=scoping_edge_cases.ClassScopeIsInvisibleToComprehensions fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=36 hasAnnotation=false annotationIsString=false initializerText=[item * data[0] for item in range(3)] initializerKind=COMPREHENSION pyModuleLinkHash=PY_MODULE_7c5670e72548f0c21d5e8710e5a91903 bindingLinkHash=PY_BINDING_b6ba5ccaaf2812fdfe59e082e4220ac8 pyExpressionLinkHash=PY_EXPRESSION_96eea804d6ac4509c9232efc3d58a9bf serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_38c6f01897875da284ae4ed20953ccf5 -name=self_assign fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=11 endLine=11 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_0392ee9e7a4bc56bb004a6e7afba21d3 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=11 hasAnnotation=false annotationIsString=false initializerText=2 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_50aab41b2a79282580adb17ecd509bf4 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_9dd57248346cd4c94d18e3ae6a54e092 -name=self_augassign fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=12 endLine=12 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_0392ee9e7a4bc56bb004a6e7afba21d3 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=12 hasAnnotation=false annotationIsString=false initializerText=0 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_c848ec2b334a0c0c95ecf82516738045 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_a08b36eb3272a0f66469bbe7d307dd42 -name=self_augassign fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=13 endLine=13 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR fieldOrigin=SELF_AUGASSIGN declaringMethodLinkHash=PY_METHOD_0392ee9e7a4bc56bb004a6e7afba21d3 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=13 hasAnnotation=false annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_6e8a8a980cbdfe31ca1f70cf5768aa54 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_e63bf9a63c8f71ab184065a04e212c41 -name=slot_entry fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=8 endLine=8 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=SLOT,INSTANCE_VAR,READ_ONLY fieldOrigin=SLOTS_ENTRY declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=8 hasAnnotation=false annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_c3dd5c9baa37a65645ba641deaf75370 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_3b691937578677fc25973ac19b74dd84 -name=store fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=27 endLine=27 pyTypeLinkHash=PY_TYPE_2dfd4389681449004ff326855f81c683 ownerTypeName=Repository ownerQualifiedName=service_layer.Repository fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_e9a9fd209ad674fca757b55f0d59046a receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=27 hasAnnotation=false annotationIsString=false initializerText=store initializerKind=NAME pyModuleLinkHash=PY_MODULE_87ff823ca7cda9220c2fe03210807591 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_761a7f788c9fcafe8fe54d441cd94d55 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_6de6365a260cb8ca2a2f41c13e057e22 -name=tag fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=53 endLine=53 pyTypeLinkHash=PY_TYPE_bd7d395e13e5c973a6e8538aced32fab ownerTypeName=ClassMethodFactory ownerQualifiedName=resolution_edges.ClassMethodFactory fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_eb5681c8ddcf04929815457a1e598d59 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=53 hasAnnotation=false annotationIsString=false initializerText=tag initializerKind=NAME pyModuleLinkHash=PY_MODULE_daef721b73c8bfbcc0348299f2f7bf9a bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_0f7fa5219483899dc1b1880a6f9d494a serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_77575b7f5a7219c8590bc9a37754d3d0 -name=values fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=36 endLine=36 pyTypeLinkHash=PY_TYPE_1e732b162f5deaead4b2e1751fdc8b5f ownerTypeName=InClassBody ownerQualifiedName=comprehension_forms.InClassBody fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=36 hasAnnotation=false annotationIsString=false initializerText=[i for i in range(3)] initializerKind=COMPREHENSION pyModuleLinkHash=PY_MODULE_69c6710ffe12b2fc51e03f770319af1b bindingLinkHash=PY_BINDING_086207b12b3601fd4624743e01cd845a pyExpressionLinkHash=PY_EXPRESSION_5d743a1c689a266929bda307ac93ebce serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_0098386942688fc6a884e12e9f6911e3 -name=x fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=21 endLine=21 pyTypeLinkHash=PY_TYPE_834b419437b855738443676b9b3cada0 ownerTypeName=WithSlots ownerQualifiedName=class_forms.WithSlots fieldAccess=PUBLIC_ACCESS fieldModifier=SLOT,INSTANCE_VAR,READ_ONLY fieldOrigin=SLOTS_ENTRY declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=21 hasAnnotation=false annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_bf49d94b1b5b1d615224bbcede193fd6 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_410e9424d9f85c7f01069a6333206741 -name=x fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=24 endLine=24 pyTypeLinkHash=PY_TYPE_834b419437b855738443676b9b3cada0 ownerTypeName=WithSlots ownerQualifiedName=class_forms.WithSlots fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,SLOT,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_1c60534bccbeaabf71d83fbb629164fb receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=24 hasAnnotation=false annotationIsString=false initializerText=1 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_0862eff6ff0a1adb1f8aedc4c19d268f serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_23dc9f7e711905b8433e9f2ce8b4811e -name=x fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=9 endLine=9 pyTypeLinkHash=PY_TYPE_6f4e90e4697b76a388566c9bd84e0d4d ownerTypeName=Point ownerQualifiedName=match_class_and_sequence_patterns.Point fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_ed898c7ba31b1d5a88a7ed47da6a477b receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=9 hasAnnotation=false annotationIsString=false initializerText=x initializerKind=NAME pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_d8d7077f45710e9120c3e6398cc0986e serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_92a0068d85f354f19ad6d582e045d7df -name=x fieldTypeName=int fieldBaseType=int potentialQualifiedName=field_origins.Point.x isAmbiguous=false startLine=18 endLine=18 pyTypeLinkHash=PY_TYPE_6b71a387bb09f3559bd7785e8741463e ownerTypeName=Point ownerQualifiedName=field_origins.Point fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=18 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_faff6556d980286b5caffa3120d0a328 pyExpressionLinkHash=PY_EXPRESSION_d3a3294efad0cccf2cebd3a749b421ca serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_7d18be1a9fe29d0bed13a216fb06f410 -name=y fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=10 endLine=10 pyTypeLinkHash=PY_TYPE_6f4e90e4697b76a388566c9bd84e0d4d ownerTypeName=Point ownerQualifiedName=match_class_and_sequence_patterns.Point fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_ed898c7ba31b1d5a88a7ed47da6a477b receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=10 hasAnnotation=false annotationIsString=false initializerText=y initializerKind=NAME pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_0b2011f5a35e76274b3622f5fcb378c2 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_39c1e4363707f3951248f99912d45ae9 -name=y fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=21 endLine=21 pyTypeLinkHash=PY_TYPE_834b419437b855738443676b9b3cada0 ownerTypeName=WithSlots ownerQualifiedName=class_forms.WithSlots fieldAccess=PUBLIC_ACCESS fieldModifier=SLOT,INSTANCE_VAR,READ_ONLY fieldOrigin=SLOTS_ENTRY declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=21 hasAnnotation=false annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_259ffe8cc6332d9477a5ec7ca24bb921 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_11cbc9360f3ced40836b98405f95cda3 -name=y fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=25 endLine=25 pyTypeLinkHash=PY_TYPE_834b419437b855738443676b9b3cada0 ownerTypeName=WithSlots ownerQualifiedName=class_forms.WithSlots fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,SLOT,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_1c60534bccbeaabf71d83fbb629164fb receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=25 hasAnnotation=false annotationIsString=false initializerText=2 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_341ada2d54c00d80488099254b019ebe serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_0a33242ff4f96e4f7da2aaf37f7390af -name=y fieldTypeName=int fieldBaseType=int potentialQualifiedName=field_origins.Point.y isAmbiguous=false startLine=19 endLine=19 pyTypeLinkHash=PY_TYPE_6b71a387bb09f3559bd7785e8741463e ownerTypeName=Point ownerQualifiedName=field_origins.Point fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=19 hasAnnotation=true annotationIsString=false initializerText=0 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_16132b28e5f39ba60433d84427b51e50 pyExpressionLinkHash=PY_EXPRESSION_4c779c40db426accfc5948670ab22620 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_54dec3f2ca5dacd7edb5e5868dfee299 +name=GREEN fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=31 endLine=31 pyTypeLinkHash=PY_TYPE_9e523515e44d6cfd9bc7abbd870a39fc ownerTypeName=Colour ownerQualifiedName=class_forms.Colour fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,ENUM_MEMBER,READ_ONLY fieldOrigin=ENUM_MEMBER declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=31 hasAnnotation=false annotationIsString=false initializerText=2 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash=PY_BINDING_57ddbcd9c75cb175fbf6a58f0413624c pyExpressionLinkHash=PY_EXPRESSION_6b100833fc3d59ad93d115d8db3d7889 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_354cf9941bff4d01e1a412286829e26f +name=KIND fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=6 endLine=6 pyTypeLinkHash=PY_TYPE_bf6354f36ebb9ff7d338d07707f4d6c9 ownerTypeName=Builder ownerQualifiedName=core.factory.Builder fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=6 hasAnnotation=false annotationIsString=false initializerText="""builder""" initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_7908beb43f8c739558eabfcf93ebca84 bindingLinkHash=PY_BINDING_8f17cce2244c83d9f0b22c927181e57c pyExpressionLinkHash=PY_EXPRESSION_6b2dc1f944007de6dc6ad962a10c4d68 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_da9d8e5a0a6fb23461a6d67eebf0b406 +name=RED fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=30 endLine=30 pyTypeLinkHash=PY_TYPE_9e523515e44d6cfd9bc7abbd870a39fc ownerTypeName=Colour ownerQualifiedName=class_forms.Colour fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,ENUM_MEMBER,READ_ONLY fieldOrigin=ENUM_MEMBER declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=30 hasAnnotation=false annotationIsString=false initializerText=1 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash=PY_BINDING_788efc874ee5c9fe8de3cf8d313ccb08 pyExpressionLinkHash=PY_EXPRESSION_08330318b58d2f5435334c19e44e1bad serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_b63b80d192db3ec3d30504b33ab5a7d5 +name=_Inner__secret fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=27 endLine=27 pyTypeLinkHash=PY_TYPE_e0b00a82da98016f61db58a6de64ca3a ownerTypeName=Inner ownerQualifiedName=name_mangling_edge_cases.Outer.Inner fieldAccess=PRIVATE_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=27 hasAnnotation=false annotationIsString=false initializerText="""inner secret""" initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_75dd800d24b53dd3ca1d826ca1525876 bindingLinkHash=PY_BINDING_5f168c8d5c239011cb96ae52de48a359 pyExpressionLinkHash=PY_EXPRESSION_8d9a61772e886da7597f48f8d04adb1a serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_2ee87edc1495cf4099a701d55e5e6c46 +name=_Outer__secret fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=24 endLine=24 pyTypeLinkHash=PY_TYPE_fd26c3cd94bd9663ee01dd2cfe94159d ownerTypeName=Outer ownerQualifiedName=name_mangling_edge_cases.Outer fieldAccess=PRIVATE_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=24 hasAnnotation=false annotationIsString=false initializerText="""outer secret""" initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_75dd800d24b53dd3ca1d826ca1525876 bindingLinkHash=PY_BINDING_49c59ee9461329034a85a0296cef5067 pyExpressionLinkHash=PY_EXPRESSION_37ac9136831ba123510a02a1f4e8a855 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_f2cd30b8381aef7fb269f0afaa8e87df +name=_v fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=24 endLine=24 pyTypeLinkHash=PY_TYPE_a1bd5124fede6d4f3504a0dd00092899 ownerTypeName=Kinds ownerQualifiedName=method_kinds.Kinds fieldAccess=PROTECTED_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_43ebe179bc6f5e46ee12ceb6ef8f8371 receiverName=self isDeclaredInInit=false writeCount=1 writtenInMethodCount=1 firstWriteLine=24 hasAnnotation=false annotationIsString=false initializerText=value initializerKind=NAME pyModuleLinkHash=PY_MODULE_7f80b0a69e6ccd5fc2e3932cc9e5dc10 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_6c7b177707416257783b9ec57e0e0db7 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_dcf0d30e2575c32c0165dde58ab421cd +name=a fieldTypeName=int fieldBaseType=int potentialQualifiedName=field_origins.Named.a isAmbiguous=false startLine=27 endLine=27 pyTypeLinkHash=PY_TYPE_57e2471ba226031232617e9a2c039483 ownerTypeName=Named ownerQualifiedName=field_origins.Named fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=NAMEDTUPLE_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=27 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_9e2b1a44d2cdbd153e83899e6455543a pyExpressionLinkHash=PY_EXPRESSION_655c42272094ed05fdc74a8ceca41e9a serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_f5cf13ceb805ace0621794c47b0edd1c +name=amount fieldTypeName=int fieldBaseType=int potentialQualifiedName=service_layer.Record.amount isAmbiguous=false startLine=9 endLine=9 pyTypeLinkHash=PY_TYPE_8ffa51c20183b9069bc165293685e3fe ownerTypeName=Record ownerQualifiedName=service_layer.Record fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=9 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_87ff823ca7cda9220c2fe03210807591 bindingLinkHash=PY_BINDING_57bc7c03bfbb607dd13cde9fc0c4fa82 pyExpressionLinkHash=PY_EXPRESSION_0e91a6686acdabf7ba17b29df0ed45ef serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_1b8b80b6ac20cbea90df5020685db27e +name=args fieldTypeName=tuple fieldBaseType=tuple potentialQualifiedName=match_nested_patterns_and_guards.Command.args isAmbiguous=false startLine=18 endLine=18 pyTypeLinkHash=PY_TYPE_0d090a4533bb97a6729a4b05d91e8042 ownerTypeName=Command ownerQualifiedName=match_nested_patterns_and_guards.Command fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=18 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_fcb43aba22913355ac115ae9c4556240 bindingLinkHash=PY_BINDING_9a435ed4c1ece9c11135dbd0160b4df5 pyExpressionLinkHash=PY_EXPRESSION_0ee6d1d4f1d073dd98461650dcad5106 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_3957058ce75e0648a430fbe98178267e +name=attribute fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=14 endLine=14 pyTypeLinkHash=PY_TYPE_ba2fbe8e09ca96c2ac317281255eb0d6 ownerTypeName=Documented ownerQualifiedName=comment_forms.Documented fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=14 hasAnnotation=false annotationIsString=false initializerText=1 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_898ba18a31075a2ba7818f72c287b8d1 bindingLinkHash=PY_BINDING_2424a859b65550cd9888982deae03d22 pyExpressionLinkHash=PY_EXPRESSION_ffabf59979fb14780a4b3608e603c0d1 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_385e7b366a08ca3716f29841e22cc22f +name=b fieldTypeName=str fieldBaseType=str potentialQualifiedName=field_origins.Named.b isAmbiguous=false startLine=28 endLine=28 pyTypeLinkHash=PY_TYPE_57e2471ba226031232617e9a2c039483 ownerTypeName=Named ownerQualifiedName=field_origins.Named fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=NAMEDTUPLE_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=28 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_9557301ac4f28d860f8ea85d7d5f12c3 pyExpressionLinkHash=PY_EXPRESSION_3ef8970040c01d72c72ad800129733ee serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_310894d9c629bedb0d3af75ed37d2926 +name=children fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=7 endLine=7 pyTypeLinkHash=PY_TYPE_114cb72d42012abcb4ffcbce3bc6a345 ownerTypeName=Node ownerQualifiedName=core.base.Node fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_1800af667e7076b626bd654b5e3dbebd receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=7 hasAnnotation=false annotationIsString=false initializerText=[] initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_0af7a9df251376bafe876afe4a32f46e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_2b5c5d4e2ede82425c6cc08afb31af7e serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_379b43102358abde65f30ca1de0bcd84 +name=class_body_annotation fieldTypeName=int fieldBaseType=int potentialQualifiedName=field_origins.Origins.class_body_annotation isAmbiguous=false startLine=8 endLine=8 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ANNOTATION_ONLY declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=8 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_b06aa81bc5c9cd99d90ba02007b150fe pyExpressionLinkHash=PY_EXPRESSION_545963f8035cf0bdb65e61c5d4e613c6 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_95475ba063df3c93c21f1c308805edd9 +name=class_body_assign fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=7 endLine=7 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=7 hasAnnotation=false annotationIsString=false initializerText=1 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_1d3d16a7ecf67dc4f185bb6856b55ce1 pyExpressionLinkHash=PY_EXPRESSION_4c58afc15bc350db5af24aafaf24281c serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_56d654562f909d6501f78a7efbec6215 +name=class_var fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=53 endLine=53 pyTypeLinkHash=PY_TYPE_e61b2b74f7302f1be447bd28221d2310 ownerTypeName=ClassScope ownerQualifiedName=scope_forms.ClassScope fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=53 hasAnnotation=false annotationIsString=false initializerText=1 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_f56326f63a4c5bef9294b28a4acb9abc bindingLinkHash=PY_BINDING_4bcd55d471aae0bad8d158832c4e4dca pyExpressionLinkHash=PY_EXPRESSION_6068c5654402f0188994f976ddff8750 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_395310756cb1d1d83c7b698fea57e4dc +name=count fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=67 endLine=67 pyTypeLinkHash=PY_TYPE_35883ba9ad396e2203b42abef9e1d7c9 ownerTypeName=Chained ownerQualifiedName=resolution_edges.Chained fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_994dce81c215c9114b56d78e93cdf732 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=67 hasAnnotation=false annotationIsString=false initializerText=0 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_daef721b73c8bfbcc0348299f2f7bf9a bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_7b0cae40fc8c704752083b3ee0fceb16 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_21ead56058d3d069ccf562ee8f4901d4 +name=count fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=70 endLine=70 pyTypeLinkHash=PY_TYPE_35883ba9ad396e2203b42abef9e1d7c9 ownerTypeName=Chained ownerQualifiedName=resolution_edges.Chained fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR fieldOrigin=SELF_AUGASSIGN declaringMethodLinkHash=PY_METHOD_b561ec322633cc1dd8bcac47c5b8c82b receiverName=self isDeclaredInInit=false writeCount=1 writtenInMethodCount=1 firstWriteLine=70 hasAnnotation=false annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_daef721b73c8bfbcc0348299f2f7bf9a bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_76e084392d2dd97ca35228a147447c5e serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_cbc349dfa7dba6a7f967589d4623516d +name=data fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=23 endLine=23 pyTypeLinkHash=PY_TYPE_b4aa287abde8dbb60cac1736e458094f ownerTypeName=ClassScopeIsInvisibleToComprehensions ownerQualifiedName=scoping_edge_cases.ClassScopeIsInvisibleToComprehensions fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=23 hasAnnotation=false annotationIsString=false initializerText=[1, 2, 3] initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_7c5670e72548f0c21d5e8710e5a91903 bindingLinkHash=PY_BINDING_2979d1fb44aef0644d73caeef4e16875 pyExpressionLinkHash=PY_EXPRESSION_9d502d9e8053c97698b1fd82a5f1ef02 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_bebc562b5a7574ce8ac9e36ada3ef18c +name=doubled fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=28 endLine=28 pyTypeLinkHash=PY_TYPE_b4aa287abde8dbb60cac1736e458094f ownerTypeName=ClassScopeIsInvisibleToComprehensions ownerQualifiedName=scoping_edge_cases.ClassScopeIsInvisibleToComprehensions fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=28 hasAnnotation=false annotationIsString=false initializerText=[item * 2 for item in data] initializerKind=COMPREHENSION pyModuleLinkHash=PY_MODULE_7c5670e72548f0c21d5e8710e5a91903 bindingLinkHash=PY_BINDING_463a0938fe7b9c7d0b98f8a91a5fa9b1 pyExpressionLinkHash=PY_EXPRESSION_7a1c506cbc933dcebc9cff34cfeb9098 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_f84a1f38e2ec605e0f7371d09882a819 +name=key fieldTypeName=int fieldBaseType=int potentialQualifiedName=field_origins.Typed.key isAmbiguous=false startLine=32 endLine=32 pyTypeLinkHash=PY_TYPE_ab0f66955e01d63bfcdb185cba8220c9 ownerTypeName=Typed ownerQualifiedName=field_origins.Typed fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=TYPEDDICT_KEY declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=32 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_e2bb803db7c6d1eb22f6564f99c728a6 pyExpressionLinkHash=PY_EXPRESSION_cf8c36787653df27e29b2e8d273a91a4 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_cfe099bdbc0b4a62a3d6135ca67cf7c0 +name=key fieldTypeName=str fieldBaseType=str potentialQualifiedName=service_layer.Record.key isAmbiguous=false startLine=8 endLine=8 pyTypeLinkHash=PY_TYPE_8ffa51c20183b9069bc165293685e3fe ownerTypeName=Record ownerQualifiedName=service_layer.Record fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=8 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_87ff823ca7cda9220c2fe03210807591 bindingLinkHash=PY_BINDING_8c140f9ae98b25bc5c0a4168c96dc0dc pyExpressionLinkHash=PY_EXPRESSION_02ca24b3af6950ca1b62257fad48588b serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_27261db33e56b96f29132dfaa71d3f4e +name=kind fieldTypeName=str fieldBaseType=str potentialQualifiedName=match_nested_patterns_and_guards.Event.kind isAmbiguous=false startLine=11 endLine=11 pyTypeLinkHash=PY_TYPE_2ce58f5e8c9675c1e0fd5c50ffba85b9 ownerTypeName=Event ownerQualifiedName=match_nested_patterns_and_guards.Event fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=11 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_fcb43aba22913355ac115ae9c4556240 bindingLinkHash=PY_BINDING_a71edea3725a72a19e6afe34df82df70 pyExpressionLinkHash=PY_EXPRESSION_24ae5eca16a253b79944f8cf60d1f4cc serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_adf2e9134c1d3b66aedefcfe1ecbe569 +name=label fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=41 endLine=41 pyTypeLinkHash=PY_TYPE_cb9da9964bc3c43761a4e1269506effe ownerTypeName=ClassScopeIsInvisibleToMethods ownerQualifiedName=scoping_edge_cases.ClassScopeIsInvisibleToMethods fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=41 hasAnnotation=false annotationIsString=false initializerText="""class attribute""" initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_7c5670e72548f0c21d5e8710e5a91903 bindingLinkHash=PY_BINDING_98ec45593e2cb658d620508b81299faa pyExpressionLinkHash=PY_EXPRESSION_42fe92a294647ed36376fb8eb05bcab7 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_f1fd14ac02e06017fd967c9e6fed6fb3 +name=n fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=6 endLine=6 pyTypeLinkHash=PY_TYPE_b2f97d5a7e48f40b008fc644ae603fdf ownerTypeName=Holder ownerQualifiedName=expression_kinds.Holder fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_9949f650301345b4e4deaca03bd84aa2 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=6 hasAnnotation=false annotationIsString=false initializerText=n initializerKind=NAME pyModuleLinkHash=PY_MODULE_02d561b0acde0974e99e75b89b4bdab0 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_8d749d70dcd6b628deac2048e53d55e0 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_9df925d8945d2bf31f49db8111a0dc69 +name=name fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=18 endLine=18 pyTypeLinkHash=PY_TYPE_bf6354f36ebb9ff7d338d07707f4d6c9 ownerTypeName=Builder ownerQualifiedName=core.factory.Builder fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_bd7c847866b0674467ec65fee212e98d receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=18 hasAnnotation=false annotationIsString=false initializerText=name initializerKind=NAME pyModuleLinkHash=PY_MODULE_7908beb43f8c739558eabfcf93ebca84 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_585104a79ffa32b57b756846339f5739 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_e099ac70f91cfbdc5ff7c13e73233075 +name=name fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=6 endLine=6 pyTypeLinkHash=PY_TYPE_114cb72d42012abcb4ffcbce3bc6a345 ownerTypeName=Node ownerQualifiedName=core.base.Node fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_1800af667e7076b626bd654b5e3dbebd receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=6 hasAnnotation=false annotationIsString=false initializerText=name initializerKind=NAME pyModuleLinkHash=PY_MODULE_0af7a9df251376bafe876afe4a32f46e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_105c90e8c0edac7578788345181e8b7f serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_4892cbb3eae7d00e22619a4bee9e4477 +name=name fieldTypeName=str fieldBaseType=str potentialQualifiedName=match_nested_patterns_and_guards.Command.name isAmbiguous=false startLine=17 endLine=17 pyTypeLinkHash=PY_TYPE_0d090a4533bb97a6729a4b05d91e8042 ownerTypeName=Command ownerQualifiedName=match_nested_patterns_and_guards.Command fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=17 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_fcb43aba22913355ac115ae9c4556240 bindingLinkHash=PY_BINDING_cd0d311bf325ef8c8ae6238a63dcad6f pyExpressionLinkHash=PY_EXPRESSION_eac151d5d6927868317023b3e1775a9d serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_bdf77c8fc8ad7a8203a0dbfdd9def840 +name=node fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=19 endLine=19 pyTypeLinkHash=PY_TYPE_bf6354f36ebb9ff7d338d07707f4d6c9 ownerTypeName=Builder ownerQualifiedName=core.factory.Builder fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_bd7c847866b0674467ec65fee212e98d receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=19 hasAnnotation=false annotationIsString=false initializerText=Node(name) initializerKind=CALL pyModuleLinkHash=PY_MODULE_7908beb43f8c739558eabfcf93ebca84 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_e901fe2384634e118a14e9746dc8d29a serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_49ef51dcaf6157efde16ddf34306974e +name=nodes fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=28 endLine=28 pyTypeLinkHash=PY_TYPE_f511a7eca38906948dce8170bb4e5495 ownerTypeName=Registry ownerQualifiedName=core.base.Registry fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_d7a26baec95db1ac47ae906b256b56b6 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=28 hasAnnotation=false annotationIsString=false initializerText=[] initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_0af7a9df251376bafe876afe4a32f46e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_37663bc3f580f7f4b5e952c3b83bbb53 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_2a7e11b6b84f1f30e344016dbc9c423d +name=payload fieldTypeName=dict fieldBaseType=dict potentialQualifiedName=match_nested_patterns_and_guards.Event.payload isAmbiguous=false startLine=12 endLine=12 pyTypeLinkHash=PY_TYPE_2ce58f5e8c9675c1e0fd5c50ffba85b9 ownerTypeName=Event ownerQualifiedName=match_nested_patterns_and_guards.Event fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=12 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_fcb43aba22913355ac115ae9c4556240 bindingLinkHash=PY_BINDING_13c4c5fdb58a9e4c8b2de72989a22932 pyExpressionLinkHash=PY_EXPRESSION_1586dc4c0c9ec70cb3d13c84f1727e77 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_01ef6b233822f48581ac56724f516c07 +name=registered fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=36 endLine=36 pyTypeLinkHash=PY_TYPE_a1bd5124fede6d4f3504a0dd00092899 ownerTypeName=Kinds ownerQualifiedName=method_kinds.Kinds fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_5c50c5642d36a230fa6b6e9f7ed1d793 receiverName=cls isDeclaredInInit=false writeCount=1 writtenInMethodCount=1 firstWriteLine=36 hasAnnotation=false annotationIsString=false initializerText=True initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_7f80b0a69e6ccd5fc2e3932cc9e5dc10 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_770096a696c823a0acb4d74638579069 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_5eee83bd12ab74a7e50ee0ca4f615196 +name=repository fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=44 endLine=44 pyTypeLinkHash=PY_TYPE_c4ea4c823c48edb338a6cd78787cae9b ownerTypeName=Service ownerQualifiedName=service_layer.Service fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_c2679ef7c7ff76fd0d905dfb8219e815 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=44 hasAnnotation=false annotationIsString=false initializerText=repository initializerKind=NAME pyModuleLinkHash=PY_MODULE_87ff823ca7cda9220c2fe03210807591 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_39117b39cc41afe8b691c23f84675db1 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_ba06c6ab6360c63d00ccbdfb193ee986 +name=rows fieldTypeName=typing.List[Record] fieldBaseType=List potentialQualifiedName=service_layer.Store.rows isAmbiguous=false startLine=17 endLine=17 pyTypeLinkHash=PY_TYPE_2cf22ea62c0a0c4b7621a04b81853658 ownerTypeName=Store ownerQualifiedName=service_layer.Store fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_1b1abae80728c42e4cc6b0dbc18b5ec2 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=17 hasAnnotation=true annotationIsString=false initializerText=[] initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_87ff823ca7cda9220c2fe03210807591 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_ef6124b63d25a3882170fe90d7e7aa10 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_982e6f2f24bd41d8e0dcef33a86a3a6a +name=scaled fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=37 endLine=37 pyTypeLinkHash=PY_TYPE_b4aa287abde8dbb60cac1736e458094f ownerTypeName=ClassScopeIsInvisibleToComprehensions ownerQualifiedName=scoping_edge_cases.ClassScopeIsInvisibleToComprehensions fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=37 hasAnnotation=false annotationIsString=false initializerText=[item * data[0] for item in range(3)] initializerKind=COMPREHENSION pyModuleLinkHash=PY_MODULE_7c5670e72548f0c21d5e8710e5a91903 bindingLinkHash=PY_BINDING_b6ba5ccaaf2812fdfe59e082e4220ac8 pyExpressionLinkHash=PY_EXPRESSION_96eea804d6ac4509c9232efc3d58a9bf serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_38c6f01897875da284ae4ed20953ccf5 +name=self_assign fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=12 endLine=12 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_0392ee9e7a4bc56bb004a6e7afba21d3 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=12 hasAnnotation=false annotationIsString=false initializerText=2 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_50aab41b2a79282580adb17ecd509bf4 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_9dd57248346cd4c94d18e3ae6a54e092 +name=self_augassign fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=13 endLine=13 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_0392ee9e7a4bc56bb004a6e7afba21d3 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=13 hasAnnotation=false annotationIsString=false initializerText=0 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_c848ec2b334a0c0c95ecf82516738045 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_a08b36eb3272a0f66469bbe7d307dd42 +name=self_augassign fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=14 endLine=14 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR fieldOrigin=SELF_AUGASSIGN declaringMethodLinkHash=PY_METHOD_0392ee9e7a4bc56bb004a6e7afba21d3 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=14 hasAnnotation=false annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_6e8a8a980cbdfe31ca1f70cf5768aa54 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_e63bf9a63c8f71ab184065a04e212c41 +name=slot_entry fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=9 endLine=9 pyTypeLinkHash=PY_TYPE_8e5e7ae02d7e897e2dae13f4d1fc41bc ownerTypeName=Origins ownerQualifiedName=field_origins.Origins fieldAccess=PUBLIC_ACCESS fieldModifier=SLOT,INSTANCE_VAR,READ_ONLY fieldOrigin=SLOTS_ENTRY declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=9 hasAnnotation=false annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_c3dd5c9baa37a65645ba641deaf75370 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_3b691937578677fc25973ac19b74dd84 +name=store fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=28 endLine=28 pyTypeLinkHash=PY_TYPE_2dfd4389681449004ff326855f81c683 ownerTypeName=Repository ownerQualifiedName=service_layer.Repository fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_e9a9fd209ad674fca757b55f0d59046a receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=28 hasAnnotation=false annotationIsString=false initializerText=store initializerKind=NAME pyModuleLinkHash=PY_MODULE_87ff823ca7cda9220c2fe03210807591 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_761a7f788c9fcafe8fe54d441cd94d55 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_6de6365a260cb8ca2a2f41c13e057e22 +name=tag fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=54 endLine=54 pyTypeLinkHash=PY_TYPE_bd7d395e13e5c973a6e8538aced32fab ownerTypeName=ClassMethodFactory ownerQualifiedName=resolution_edges.ClassMethodFactory fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_eb5681c8ddcf04929815457a1e598d59 receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=54 hasAnnotation=false annotationIsString=false initializerText=tag initializerKind=NAME pyModuleLinkHash=PY_MODULE_daef721b73c8bfbcc0348299f2f7bf9a bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_0f7fa5219483899dc1b1880a6f9d494a serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_77575b7f5a7219c8590bc9a37754d3d0 +name=values fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=37 endLine=37 pyTypeLinkHash=PY_TYPE_1e732b162f5deaead4b2e1751fdc8b5f ownerTypeName=InClassBody ownerQualifiedName=comprehension_forms.InClassBody fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,READ_ONLY fieldOrigin=CLASS_BODY_ASSIGN declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=37 hasAnnotation=false annotationIsString=false initializerText=[i for i in range(3)] initializerKind=COMPREHENSION pyModuleLinkHash=PY_MODULE_69c6710ffe12b2fc51e03f770319af1b bindingLinkHash=PY_BINDING_086207b12b3601fd4624743e01cd845a pyExpressionLinkHash=PY_EXPRESSION_5d743a1c689a266929bda307ac93ebce serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_0098386942688fc6a884e12e9f6911e3 +name=x fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=22 endLine=22 pyTypeLinkHash=PY_TYPE_834b419437b855738443676b9b3cada0 ownerTypeName=WithSlots ownerQualifiedName=class_forms.WithSlots fieldAccess=PUBLIC_ACCESS fieldModifier=SLOT,INSTANCE_VAR,READ_ONLY fieldOrigin=SLOTS_ENTRY declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=22 hasAnnotation=false annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_bf49d94b1b5b1d615224bbcede193fd6 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_410e9424d9f85c7f01069a6333206741 +name=x fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=25 endLine=25 pyTypeLinkHash=PY_TYPE_834b419437b855738443676b9b3cada0 ownerTypeName=WithSlots ownerQualifiedName=class_forms.WithSlots fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,SLOT,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_1c60534bccbeaabf71d83fbb629164fb receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=25 hasAnnotation=false annotationIsString=false initializerText=1 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_0862eff6ff0a1adb1f8aedc4c19d268f serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_23dc9f7e711905b8433e9f2ce8b4811e +name=x fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=10 endLine=10 pyTypeLinkHash=PY_TYPE_6f4e90e4697b76a388566c9bd84e0d4d ownerTypeName=Point ownerQualifiedName=match_class_and_sequence_patterns.Point fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_ed898c7ba31b1d5a88a7ed47da6a477b receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=10 hasAnnotation=false annotationIsString=false initializerText=x initializerKind=NAME pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_d8d7077f45710e9120c3e6398cc0986e serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_92a0068d85f354f19ad6d582e045d7df +name=x fieldTypeName=int fieldBaseType=int potentialQualifiedName=field_origins.Point.x isAmbiguous=false startLine=19 endLine=19 pyTypeLinkHash=PY_TYPE_6b71a387bb09f3559bd7785e8741463e ownerTypeName=Point ownerQualifiedName=field_origins.Point fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=19 hasAnnotation=true annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_faff6556d980286b5caffa3120d0a328 pyExpressionLinkHash=PY_EXPRESSION_d3a3294efad0cccf2cebd3a749b421ca serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_7d18be1a9fe29d0bed13a216fb06f410 +name=y fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=11 endLine=11 pyTypeLinkHash=PY_TYPE_6f4e90e4697b76a388566c9bd84e0d4d ownerTypeName=Point ownerQualifiedName=match_class_and_sequence_patterns.Point fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_ed898c7ba31b1d5a88a7ed47da6a477b receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=11 hasAnnotation=false annotationIsString=false initializerText=y initializerKind=NAME pyModuleLinkHash=PY_MODULE_e8c22c801b9d7d33638ace57665fdd49 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_0b2011f5a35e76274b3622f5fcb378c2 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_39c1e4363707f3951248f99912d45ae9 +name=y fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=22 endLine=22 pyTypeLinkHash=PY_TYPE_834b419437b855738443676b9b3cada0 ownerTypeName=WithSlots ownerQualifiedName=class_forms.WithSlots fieldAccess=PUBLIC_ACCESS fieldModifier=SLOT,INSTANCE_VAR,READ_ONLY fieldOrigin=SLOTS_ENTRY declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=22 hasAnnotation=false annotationIsString=false initializerText= initializerKind=NONE pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_259ffe8cc6332d9477a5ec7ca24bb921 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_11cbc9360f3ced40836b98405f95cda3 +name=y fieldTypeName= fieldBaseType= potentialQualifiedName= isAmbiguous=false startLine=26 endLine=26 pyTypeLinkHash=PY_TYPE_834b419437b855738443676b9b3cada0 ownerTypeName=WithSlots ownerQualifiedName=class_forms.WithSlots fieldAccess=PUBLIC_ACCESS fieldModifier=INSTANCE_VAR,SLOT,READ_ONLY fieldOrigin=SELF_ASSIGN declaringMethodLinkHash=PY_METHOD_1c60534bccbeaabf71d83fbb629164fb receiverName=self isDeclaredInInit=true writeCount=1 writtenInMethodCount=1 firstWriteLine=26 hasAnnotation=false annotationIsString=false initializerText=2 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_512fa5aa67cc77834aa18c596c931e59 bindingLinkHash= pyExpressionLinkHash=PY_EXPRESSION_341ada2d54c00d80488099254b019ebe serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_0a33242ff4f96e4f7da2aaf37f7390af +name=y fieldTypeName=int fieldBaseType=int potentialQualifiedName=field_origins.Point.y isAmbiguous=false startLine=20 endLine=20 pyTypeLinkHash=PY_TYPE_6b71a387bb09f3559bd7785e8741463e ownerTypeName=Point ownerQualifiedName=field_origins.Point fieldAccess=PUBLIC_ACCESS fieldModifier=CLASS_VAR,DATACLASS_FIELD,READ_ONLY fieldOrigin=DATACLASS_FIELD declaringMethodLinkHash= receiverName= isDeclaredInInit=false writeCount=1 writtenInMethodCount=0 firstWriteLine=20 hasAnnotation=true annotationIsString=false initializerText=0 initializerKind=LITERAL pyModuleLinkHash=PY_MODULE_d9b6cd4b9890511117f0581289c3e72e bindingLinkHash=PY_BINDING_16132b28e5f39ba60433d84427b51e50 pyExpressionLinkHash=PY_EXPRESSION_4c779c40db426accfc5948670ab22620 serviceVersionLinkHash=SERVICE_VERSION_00000000000000000000000000000000 pyFieldUniqueHash=PY_FIELD_54dec3f2ca5dacd7edb5e5868dfee299 diff --git a/parser/src/test/python-gates/field-lines.ts b/parser/src/test/python-gates/field-lines.ts new file mode 100644 index 000000000..3b543f851 --- /dev/null +++ b/parser/src/test/python-gates/field-lines.ts @@ -0,0 +1,117 @@ +/** + * A field row carries the line its write is ON, 1-based like every other Python relation. + * + * The field extractor copied tree-sitter's 0-based `row` straight into `startLine`, + * `endLine` and `firstWriteLine`, while methods, types, comments and blocks all add 1. So + * every field sat one line above its write: the first field of a nested `class Meta:` was + * recorded AT the `class Meta:` line, and a query by `file:line` or a diff hunk on the + * write landed on the field above it, or on nothing. + * + * The frozen golden could not see it: it had frozen the 0-based values. This gate names + * the expected line of each write form, so a wrong value is red even when it is stable. + * + * Near-miss controls: the declaration lines of the classes and of `__init__` must not move + * (they were already 1-based), and no field may sit on a `class` line. + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { PythonProjectAnalyzer } from '@/workflows/python/python-project-analyzer'; + +const SOURCE = `class Form: + name = "form" + + class Meta: + csrf = True + fields = ( + "a", + "b", + ) + + def __init__(self): + self.count = 0 + setattr(self, "dynamic", 1) + + +class Slotted: + __slots__ = ("x",) +`; + +/** field name -> [startLine, endLine] of its (first) write, 1-based. */ +const FIELDS: ReadonlyArray = [ + ['name', 2, 2, 'a top-level class-body assignment'], + ['csrf', 5, 5, 'the first field of a nested class: NOT the `class Meta:` line (4)'], + ['fields', 6, 9, 'a multi-line class-body assignment spans its value'], + ['count', 12, 12, 'self.x = ... in __init__'], + ['dynamic', 13, 13, 'setattr(self, "name", v)'], + ['x', 17, 17, 'a __slots__ entry'], +]; + +/** control: declarations that were already 1-based keep their line. */ +const DECLS: ReadonlyArray = [ + ['all-python-types.csv', 'Form', 1], + ['all-python-types.csv', 'Meta', 4], + ['all-python-types.csv', 'Slotted', 16], + ['all-python-methods.csv', '__init__', 11], +]; + +export async function fieldLines(): Promise { + const problems: string[] = []; + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'py-fieldlines-')); + const source = path.join(root, 'src'); + fs.mkdirSync(source, { recursive: true }); + fs.writeFileSync(path.join(source, 'forms.py'), SOURCE); + + const outputDir = path.join(root, 'out'); + await new PythonProjectAnalyzer().analyze({ + rootDir: source, + outputDir, + baseMservPath: '/repo', + serviceVersionLinkHash: 'SERVICE_VERSION_' + '0'.repeat(32), + }); + + const tsv = (file: string): Record[] => { + const lines = fs.readFileSync(path.join(outputDir, file), 'utf-8').split('\n').filter(Boolean); + const header = lines[0]!.split('\t'); + return lines.slice(1).map((l) => { + const cells = l.split('\t'); + return Object.fromEntries(header.map((h, i) => [h, cells[i] ?? ''])); + }); + }; + + const fields = tsv('all-python-fields.csv'); + for (const [name, start, end, why] of FIELDS) { + const row = fields.find((f) => f.name === name); + if (!row) { + problems.push(`${name} (${why}): no field row`); + continue; + } + const got = [Number(row.startLine), Number(row.endLine), Number(row.firstWriteLine)]; + if (got[0] !== start || got[1] !== end || got[2] !== start) { + problems.push(`${name} (${why}): expected start=${start} end=${end} firstWrite=${start}, ` + + `got start=${got[0]} end=${got[1]} firstWrite=${got[2]}`); + } + } + + const classLines = new Set(); + for (const [file, name, line] of DECLS) { + const row = tsv(file).find((r) => r.name === name); + const got = row ? Number(row.startLine) : NaN; + if (got !== line) problems.push(`control: ${name} in ${file} expected line ${line}, got ${got}`); + if (file === 'all-python-types.csv') classLines.add(line); + } + for (const f of fields) { + if (classLines.has(Number(f.startLine))) { + problems.push(`control: field ${f.name} sits on a class line (${f.startLine})`); + } + } + + fs.rmSync(root, { recursive: true, force: true }); + console.log(` ${FIELDS.length} write forms checked for 1-based lines; ` + + `${DECLS.length} declaration lines and the no-field-on-a-class-line rule as controls`); + for (const problem of problems) { + console.log(` FAIL ${problem}`); + } + return problems.length === 0 ? 0 : 1; +} diff --git a/parser/src/test/python-tests.ts b/parser/src/test/python-tests.ts index db6bc3961..feaf252ad 100644 --- a/parser/src/test/python-tests.ts +++ b/parser/src/test/python-tests.ts @@ -36,6 +36,7 @@ import { softKeywordTypeCall } from './python-gates/soft-keyword-type-call'; import { asyncIteration } from './python-gates/async-iteration'; import { splatCallee } from './python-gates/splat-callee'; import { cachedProperty } from './python-gates/cached-property'; +import { fieldLines } from './python-gates/field-lines'; import { pep604Union } from './python-gates/pep604-union'; const VERIFIED = 'src/test-data/python/verified'; @@ -587,6 +588,8 @@ const CHECKS: Check[] = [ proves: 'a call whose result is splatted is a named static call, not a dynamic one' }, { name: 'cached property', run: cachedProperty, proves: 'a @cached_property is a property getter — a read that runs a body — not a method nobody calls' }, + { name: 'field lines', run: fieldLines, + proves: 'a field row sits on the line of its write, 1-based; never on the class line above it' }, { name: 'PEP 604 unions', run: pep604Union, proves: 'a union is decomposed whatever its operands look like; a subscripted operand does not collapse it' }, { name: 'async iteration', run: asyncIteration, diff --git a/parser/src/test/typescript-tests.ts b/parser/src/test/typescript-tests.ts index 2e7ca86aa..1cb810987 100644 --- a/parser/src/test/typescript-tests.ts +++ b/parser/src/test/typescript-tests.ts @@ -2944,6 +2944,83 @@ async function annotationCanDeclareASignatureSet(): Promise { } } +/** + * An annotation the parser cannot read is not a licence to name the initialiser (#536). + * + * tsc resolves a call through a variable's ANNOTATION. Where the annotation needs + * the checker -- an indexed access through a generic alias, a named interface -- + * the parser fell through to the arrow initialiser and claimed it with + * `overloadCandidateCount = 1`, where tsc sees an overload pair. The row now stays + * unresolved, which the engine measures instead of trusting. + */ +async function uncheckableAnnotationLeavesCallUnresolved(): Promise { + if (!parserPresent()) { + return pendingCheck('an uncheckable annotation leaves the call unresolved', + 'no extractor yet. An annotation may need the checker'); + } + const { outputDir, cleanup } = await analyseInline('ts-uncheckable-', { + 'store.ts': [ + 'type SetStateInternal = {', // 1 + ' _(partial: T | Partial, replace?: false): void;', // 2 + ' _(state: T, replace: true): void;', // 3 + "}['_'];", // 4 + 'export interface StoreApi { setState: SetStateInternal }', // 5 + ].join('\n'), + 'a.ts': [ + "import type { StoreApi } from './store';", // 1 + 'interface Twice { (a: string): void; (a: number): void }', // 2 + 'export function wire(state: S): void {', // 3 + " const fromTools: StoreApi['setState'] = (..._a: unknown[]): void => {};", // 4 + ' fromTools(state);', // 5 -> needs the checker + ' const twice: Twice = (_a: string | number): void => {};', // 6 + " twice('x');", // 7 -> needs the checker + ' const bare = (_a: string): void => {};', // 8 + " bare('x');", // 9 -> control: the arrow + ' const typed: (a: string) => void = (_a: string): void => {};', // 10 + " typed('x');", // 11 -> control: the annotation + '}', + ].join('\n'), + }); + try { + const methodLine = new Map(relation(outputDir, 'all-typescript-methods.csv') + .map((r) => [r['tsMethodUniqueHash'] ?? '', r['startLine'] ?? ''])); + const calls = relation(outputDir, 'all-typescript-call-sites.csv'); + const failures: string[] = []; + const expect = (line: string, targetLine: string, count: string, kind: string): void => { + const row = calls.find((r) => r['startLine'] === line); + if (row === undefined) { + failures.push(`no call site at line ${line}`); + return; + } + const got = methodLine.get(row['resolvedSignatureLinkHash'] ?? '') ?? ''; + if (got !== targetLine) { + failures.push(`call at line ${line} resolved to a method at line ${got}, ` + + `expected ${targetLine}`); + } + if ((row['overloadCandidateCount'] ?? '') !== count) { + failures.push(`call at line ${line} reports overloadCandidateCount ` + + `${row['overloadCandidateCount']}, expected ${count}`); + } + if ((row['resolvedTargetKind'] ?? '') !== kind) { + failures.push(`call at line ${line} reports resolvedTargetKind ` + + `${row['resolvedTargetKind']}, expected ${kind}`); + } + }; + // tsc names an arm of the annotation; the parser cannot, so it names nothing. + expect('5', '', '0', 'UNRESOLVED'); + expect('7', '', '0', 'UNRESOLVED'); + // Controls: an unannotated arrow and a function-type annotation still resolve. + expect('9', '8', '1', 'PROJECT_IMPLEMENTATION'); + expect('11', '10', '1', 'PROJECT_SIGNATURE'); + console.log(' 4 call(s): two through an annotation that needs the checker left unresolved, ' + + 'an unannotated arrow and a function-type annotation unchanged'); + for (const f of failures.slice(0, 8)) { console.log(` ${f}`); } + return failures.length ? 1 : 0; + } finally { + cleanup(); + } +} + /** * An object literal's property KEY reaches the IR (#111). @@ -4586,6 +4663,7 @@ const CHECKS: Check[] = [ { name: 'a decorator is descended once', proves: 'a callable inside a decorator argument is one ts_method, not two colliding on one key as a false overload set', run: decoratorDescendedOnce }, { name: 'declaration extensions are whole extensions', proves: '`.d.cts` and `.d.mts` are single extensions, so no stem keeps a stray `.d`', run: declarationExtensionsAreWholeExtensions }, { name: 'an annotation can declare a signature set', proves: 'a type literal holding several call signatures is resolved as the set it is, and arity picks the arm tsc picks', run: annotationCanDeclareASignatureSet }, + { name: 'an uncheckable annotation leaves the call unresolved', proves: 'a call through a variable whose annotation needs the checker names neither the initialiser nor a single candidate', run: uncheckableAnnotationLeavesCallUnresolved }, { name: 'object-literal keys reach the IR', proves: 'a property key is emitted as its own row, joinable to its value, never bound as a scope reference, and absent when computed', run: objectLiteralKeysReachTheIr }, { name: 'empty imports record their module edge', proves: 'an import that binds nothing still emits a resolved row, so a package imported only for its ambient declarations can be staged', run: emptyImportsRecordTheirModuleEdge }, { name: 'reopened type members share a group key', proves: 'a member of a declaration-merged type has one identity across files, so two signatures of it are overload siblings rather than a wrong answer', run: reopenedTypeMembersShareAGroupKey }, diff --git a/parser/src/workflows/services/services-project-analyzer.ts b/parser/src/workflows/services/services-project-analyzer.ts index 04f107ae2..36c187562 100644 --- a/parser/src/workflows/services/services-project-analyzer.ts +++ b/parser/src/workflows/services/services-project-analyzer.ts @@ -12,6 +12,9 @@ import { OUTPUT_SERVICE_PROVIDER_CSV_FILENAME, OUTPUT_SKIPPED_SERVICES_FILES_CSV_FILENAME, SERVICES_DIR, + SPRING_DIR, + SPRING_FACTORIES_FILE, + SPRING_IMPORTS_SUFFIX, } from '@/constants/consts'; import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants'; import { SkippedFileReason } from '@/enums/SkippedFileReason'; @@ -29,7 +32,9 @@ interface SkippedServicesFile { } /** - * Extracts `META-INF/services` provider-configuration files across a codebase. + * Extracts `META-INF/services` provider-configuration files across a codebase, + * and Spring's two registries in the same shape: `META-INF/spring/.imports` + * and `META-INF/spring.factories` (one descriptor per key). * * ## Why one file is attributed to exactly one project * @@ -143,14 +148,19 @@ export class ServicesProjectAnalyzer { // skipped. It is a well-formed descriptor that declares zero providers — // a real and meaningful statement — and recording it as a skipped file // would make "no providers declared" indistinguishable from "not read". - const [descriptor, providers] = this.parser.parse( - content, - filePath, - baseMservPath, - serviceVersionHash - ); - this.allDescriptors.push(descriptor); - this.allProviders.push(...providers); + const fileName = path.basename(filePath); + const parentDir = path.basename(path.dirname(filePath)); + const parsed: Array<[ServiceDescriptor, ServiceProvider[]]> = + parentDir === META_INF_DIR && fileName === SPRING_FACTORIES_FILE + ? this.parser.parseSpringFactories(content, filePath, baseMservPath, serviceVersionHash) + : parentDir === SPRING_DIR && fileName.endsWith(SPRING_IMPORTS_SUFFIX) + ? [this.parser.parse(content, filePath, baseMservPath, serviceVersionHash, + fileName.slice(0, -SPRING_IMPORTS_SUFFIX.length))] + : [this.parser.parse(content, filePath, baseMservPath, serviceVersionHash)]; + for (const [descriptor, providers] of parsed) { + this.allDescriptors.push(descriptor); + this.allProviders.push(...providers); + } } catch (error) { console.error(` ❌ Error parsing ${filePath}:`, error); this.recordSkip( @@ -211,6 +221,16 @@ export class ServicesProjectAnalyzer { if (entry.name === META_INF_DIR) { await this.collectProviderFiles(path.join(subPath, SERVICES_DIR), files); + // Spring's two registries beside it: `META-INF/spring/*.imports` (one class + // per line, the ServiceLoader format) and `META-INF/spring.factories`. + await this.collectProviderFiles(path.join(subPath, SPRING_DIR), files, SPRING_IMPORTS_SUFFIX); + try { + if ((await fs.stat(path.join(subPath, SPRING_FACTORIES_FILE))).isFile()) { + files.push(path.join(subPath, SPRING_FACTORIES_FILE)); + } + } catch { + // No spring.factories here, which is the common case. + } // A `META-INF` directory holds resources, never a nested project, so // there is nothing else beneath it to look for. continue; @@ -224,7 +244,7 @@ export class ServicesProjectAnalyzer { } } - private async collectProviderFiles(servicesDir: string, files: string[]): Promise { + private async collectProviderFiles(servicesDir: string, files: string[], suffix = ''): Promise { let entries; try { entries = await fs.readdir(servicesDir, { withFileTypes: true }); @@ -234,7 +254,7 @@ export class ServicesProjectAnalyzer { } for (const entry of entries) { - if (entry.isFile()) { + if (entry.isFile() && entry.name.endsWith(suffix)) { files.push(path.join(servicesDir, entry.name)); } } diff --git a/plugins/axiomcode/.claude-plugin/plugin.json b/plugins/axiomcode/.claude-plugin/plugin.json index b419564ab..28e1ce92e 100644 --- a/plugins/axiomcode/.claude-plugin/plugin.json +++ b/plugins/axiomcode/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "axiomcode", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", - "version": "0.1.4", + "version": "0.1.6", "author": { "name": "AxiomCode" } diff --git a/plugins/axiomcode/.codex-plugin/plugin.json b/plugins/axiomcode/.codex-plugin/plugin.json index 672ebecb5..d13a75434 100644 --- a/plugins/axiomcode/.codex-plugin/plugin.json +++ b/plugins/axiomcode/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.4", + "version": "0.1.6", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.", "author": { "name": "AxiomCode", "url": "https://github.com/AxiomCodeAI/axiomcodegraph" }, "homepage": "https://github.com/AxiomCodeAI/axiomcodegraph", diff --git a/plugins/axiomcode/.cursor-plugin/plugin.json b/plugins/axiomcode/.cursor-plugin/plugin.json index 0edce18ab..32feb6586 100644 --- a/plugins/axiomcode/.cursor-plugin/plugin.json +++ b/plugins/axiomcode/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "axiomcode", - "version": "0.1.4", + "version": "0.1.6", "description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed. Java, TypeScript, Python, JavaScript.", "author": { "name": "AxiomCode" }, "homepage": "https://github.com/AxiomCodeAI/axiomcodegraph", diff --git a/plugins/axiomcode/hooks/_graphline.py b/plugins/axiomcode/hooks/_graphline.py index f5b58f072..abff45b86 100644 --- a/plugins/axiomcode/hooks/_graphline.py +++ b/plugins/axiomcode/hooks/_graphline.py @@ -88,27 +88,40 @@ def distinct_paths(files): _TI = None -def _command_for(lang, files, classes): - """the runnable command `axiomcode test-impact` prints, from the same function""" +def _ti(): + """axiomcode-test-impact as a module, loaded once; None when it cannot be""" global _TI if _TI is None: p = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts', 'axiomcode-test-impact') try: ld = importlib.machinery.SourceFileLoader('ax_test_impact', p) m = importlib.util.module_from_spec(importlib.util.spec_from_loader('ax_test_impact', ld)); ld.exec_module(m) - _TI = m.command_for + _TI = m except Exception: - _TI = lambda *a: None - try: return _TI(lang, files, classes) + _TI = False + return _TI or None + + +def _command_for(lang, files, classes, db=None, repo='.'): + """the runnable command `axiomcode test-impact` prints, from the same function; a TypeScript/JavaScript selection + can need one command per package and runner, joined with '; ' here because the hook's answer is one line""" + try: cmd = _ti().command_for(lang, files, classes, db, repo) except Exception: return None + return cmd.replace("\n", "; ") if cmd else cmd + + +def _concrete(db, classes): + """the classes a runner can run: each abstract test class replaced by the classes that extend it (test-impact)""" + try: return _ti().concrete_test_classes(db, classes)[0] + except Exception: return classes -LANG = {'.java': 'java', '.py': 'python', '.ts': 'typescript', '.tsx': 'typescript', '.js': 'javascript', - '.mjs': 'javascript', '.cjs': 'javascript', '.jsx': 'javascript', '.cs': 'csharp'} +import _where +LANG = {e: ls[0] for e, ls in _where.BY_EXT.items()} # one table for every hook (_where.py) SHOWN = 6 -def body_line(db, results): +def body_line(db, results, repo='.'): """ONE line for an edit that changed only bodies: which declarations, how many tests reach them, how to run those. `results` is [(changed-declaration, impact-json)], the same pairs the blast-radius block is built from.""" names = [d['symbol'] for d, _ in results] @@ -136,7 +149,9 @@ def body_line(db, results): return f"graph: body edit of {what}: no test reaches it through the graph (a lower bound)" lang = LANG.get(os.path.splitext(results[0][0].get('file', ''))[1], '') fl, cl = sorted(files), sorted(owners) - cmd = _command_for(lang, fl[:SHOWN], cl[:SHOWN]) + if lang in ('java', 'csharp') and cl: + cl = sorted(_concrete(db, cl)) # before the cut, so the command and the "+N more" count the same classes + cmd = _command_for(lang, fl[:SHOWN], cl[:SHOWN], None, repo) more = (len({c.split('.')[-1] for c in cl}) if lang in ('java', 'csharp') and cl else len(fl)) - SHOWN tail = (f"; run: {cmd}" + (f" (+{more} more: axiomcode test-impact)" if more > 0 else '')) if cmd else "; axiomcode test-impact gives the command" return f"graph: body edit of {what}: {n} test(s) reach it{tail}" diff --git a/plugins/axiomcode/hooks/_where.py b/plugins/axiomcode/hooks/_where.py index dc71e291c..e83b7da30 100644 --- a/plugins/axiomcode/hooks/_where.py +++ b/plugins/axiomcode/hooks/_where.py @@ -30,6 +30,34 @@ '.ts': ('typescript',), '.tsx': ('typescript',), '.mts': ('typescript',), '.cts': ('typescript',), '.js': ('javascript', 'typescript'), '.jsx': ('javascript', 'typescript'), '.mjs': ('javascript', 'typescript'), '.cjs': ('javascript', 'typescript')} +# THE ONE TABLE OF WHAT A HOOK CALLS SOURCE. Each hook kept its own regex or tuple of extensions, and each drifted: +# the edit hooks had no `.cs`, so a C# edit got no graph line while a C# read did. Every hook asks these instead. +SOURCE_EXT = tuple(BY_EXT) +SOURCE_ALT = '|'.join(sorted((e[1:] for e in BY_EXT), key=len, reverse=True)) # for a regex: (?:py|java|cs|...) +# test code, in each language's convention: a tests/ or src/test/ directory, a *Test / *Tests class file, a C# test +# project (App.Tests/), a test_*.py module, a *.spec.ts / *.test.js file +TEST = re.compile(r'(^|/)(tests?|__tests__)/|/src/test/|(^|/)[\w.]+\.Tests?/|Tests?\.(java|cs)$|\.(spec|test)\.[jt]sx?$|(^|/)test_') + + +def is_source(path): + """a file some language's graph is built from: a known extension, or an extensionless script with a Python + shebang (the index reads those as modules)""" + path = str(path or '') + ext = os.path.splitext(path)[1].lower() + if ext: return ext in BY_EXT + try: + with open(path, 'rb') as f: first = f.readline(200) + return first.startswith(b'#!') and b'python' in first + except OSError: return False + + +def is_test(rel): + return bool(TEST.search(str(rel or '').replace(os.sep, '/'))) + + +def lang_of(path): + """the language a file's tests are run in, '' when none""" + return (BY_EXT.get(os.path.splitext(str(path or ''))[1].lower()) or ('',))[0] _session = 'x' _cache = None diff --git a/plugins/axiomcode/hooks/changes.py b/plugins/axiomcode/hooks/changes.py index 3bfa63e8a..ef48771ab 100644 --- a/plugins/axiomcode/hooks/changes.py +++ b/plugins/axiomcode/hooks/changes.py @@ -28,7 +28,7 @@ if tool in ('Edit', 'Write', 'MultiEdit') and inp.get('file_path'): os.environ.update(_where.lang_env(cwd, inp['file_path'])) SCR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts') -SRC = re.compile(r'\.(java|ts|tsx|js|mjs|cjs|py)$'); TEST = re.compile(r'(^|/)(tests?|__tests__)/|/src/test/|Tests?\.java$|\.(spec|test)\.[jt]sx?$|(^|/)test_') +TEST = _where.TEST # what counts as source and as test: one table, _where.py STATE = os.path.join(cwd, '.axiomcode', f"hooks-state-{ev.get('session_id', 'x')}.json") def load_state(): try: return json.load(open(STATE)) @@ -118,7 +118,7 @@ def impact(d): if len(decls) > 3: lines.append(f" … +{len(decls) - 3} more: axiomcode changed --impact") if bodies: lines.append(_graphline.body_line(os.path.join(os.environ.get('AXIOMCODE_GRAPH') or os.path.join(cwd, '.axiomcode'), 'out', 'graph.sqlite'), - bodies + [(d, {}) for d in body[3:]])) + bodies + [(d, {}) for d in body[3:]], cwd)) return lines def key(d): return f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')}" @@ -126,7 +126,7 @@ def key(d): return f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')} lines = [] if event == 'PreToolUse' and tool in ('Edit', 'Write', 'MultiEdit'): fp = _where._abs(inp.get('file_path', ''), scwd); rel = rel_of(fp) - if not SRC.search(rel) or TEST.search(rel) or not os.path.exists(fp): sys.exit(0) + if not _where.is_source(fp) or TEST.search(rel) or not os.path.exists(fp): sys.exit(0) cur = open(fp, errors='replace').read(); new = cur if tool == 'Write': new = str(inp.get('content', '')) else: @@ -153,7 +153,7 @@ def key(d): return f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')} if bg: os.environ['AXIOMCODE_GRAPH'] = bg if event == 'PostToolUse' and tool == 'Bash': c = str(inp.get('command', '')) - if not re.search(r'\bsed\s+-i|\bpatch\b|\bgit\s+(apply|checkout|switch|pull|merge|rebase|revert|cherry-pick|stash\s+pop|reset\s+--hard|restore)\b|>>?\s*\S+\.(java|ts|tsx|js|py)\b|\b(python3?|node|bash|sh)\s+\S+|\bmv\b|\bcp\b|\brm\b', c): sys.exit(0) + if not re.search(r'\bsed\s+-i|\bpatch\b|\bgit\s+(apply|checkout|switch|pull|merge|rebase|revert|cherry-pick|stash\s+pop|reset\s+--hard|restore)\b|>>?\s*\S+\.(' + _where.SOURCE_ALT + r')\b|\b(python3?|node|bash|sh)\s+\S+|\bmv\b|\bcp\b|\brm\b', c): sys.exit(0) j = changed([], timeout=18) st = load_state(); seen = set(st.get('reported', [])) new = [d for d in j.get('changed', []) if d.get('target') and key(d) not in seen and not TEST.search(d['file'])] diff --git a/plugins/axiomcode/hooks/direct.py b/plugins/axiomcode/hooks/direct.py index c9aea74a3..5b8da8c71 100755 --- a/plugins/axiomcode/hooks/direct.py +++ b/plugins/axiomcode/hooks/direct.py @@ -22,30 +22,105 @@ · ONLY WHERE THE CHOICE IS. A Bash call that searches or reads source competes with the graph; `git`, `ls`, a build or a test run does not, and a directive in front of one is noise. """ -import json, os, re, sys +import json, os, re, shlex, sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import _host, _where -MARK = '.axiomcode/.directed-{}' # once per session: keyed by session id, not once per repo forever -SRC = ('.java', '.ts', '.tsx', '.py', '.js', '.jsx', '.mjs', '.cjs', '.cs') +SRC = _where.SOURCE_EXT # one table for every hook (_where.py) # the shell commands that are the search or read this directive competes with SEARCH = re.compile(r'(^|[;&|(]\s*|\s)(grep|egrep|rg|ag|ack|git\s+grep|find|fd|cat|head|tail|sed|awk|less)\b') +# words a search pattern is made of that name no declaration (a keyword written into the pattern to anchor it) +KEYWORDS = {'def', 'class', 'public', 'private', 'protected', 'static', 'final', 'void', 'function', 'import', 'from', + 'return', 'new', 'async', 'await', 'self', 'this', 'interface', 'struct', 'record', 'override', 'virtual', + 'const', 'let', 'var', 'string', 'int', 'bool', 'true', 'false', 'null', 'None', 'True', 'False', 'package', + 'namespace', 'using', 'extends', 'implements'} +CALLABLE = ('method', 'function', 'constructor') # The MCP tools are named first and the shell verbs second (#1425): `axiomcode install` pre-approves only the tools, # so an agent that takes the first spelling it reads should be taking the one it is already allowed to call. +# Short on purpose: it is read once, and 13 generic lines were re-read on every later turn. FULL = ( - "graph: this repository has a resolved call graph, and it answers the question this search is asking.\n" - " the axiomcode_impact tool (targets=[]) — everything a change to that declaration reaches, each entry\n" - " labelled with how certain it is; finds callers that never spell the name (an interface, an override, a\n" - " callback, DI, a config key), which is what a grep for the name cannot do.\n" - " axiomcode_path — how one thing reaches another. axiomcode_context — where a task's words land.\n" - " axiomcode_changed / axiomcode_test_impact — what an edit moved and which tests reach it.\n" - " Without those tools, the same verbs from the shell: `axiomcode impact `, `axiomcode path `,\n" - " `axiomcode context \"\"`, `axiomcode changed`, `axiomcode test-impact`.\n" - " Search raw files after that, to read or to change specific lines. If you hand this to a subagent,\n" - " carry this line into its prompt — exploration done in a child is exploration done without the graph." + "graph: this repository has a resolved call graph; ask it before a raw search. axiomcode_impact targets=[]:\n" + " who uses a declaration and what a change breaks, with callers that never spell the name (an interface, an override,\n" + " a callback, DI). axiomcode_path: how A reaches B. axiomcode_context: where a task lands. axiomcode_changed /\n" + " axiomcode_test_impact: what an edit moved, which tests reach it. Shell: `axiomcode impact `, `axiomcode path\n" + " `, `axiomcode context \"\"`, `axiomcode changed`, `axiomcode test-impact`. Pass this to a subagent." ) +# after a line that already names the verb for THIS search, the rest of the menu in one line +REST = (" Also: axiomcode_path (how A reaches B), axiomcode_context (a task in words), axiomcode_test_impact (the tests an" + " edit reaches); in a shell `axiomcode `. Pass this to a subagent.") + + +def _stamp(cwd, sid): + """ONCE PER SESSION, not once per (session, repository): keyed on the session in the temp directory, so an agent + that moves between repositories (a dev copy, a second service) hears it the first time only. A session with no id + keeps the old per-repository stamp, since every such session would otherwise share one.""" + if not sid: + return os.path.join(cwd, '.axiomcode', '.directed-x') + tmp = os.environ.get('TMPDIR') or os.environ.get('TEMP') or os.environ.get('TMP') or ('/tmp' if os.name != 'nt' else os.path.expanduser('~')) + return os.path.join(tmp, 'axiomcode-hooks', 'directed-' + (re.sub(r'[^\w.-]', '_', str(sid))[:80] or 'x')) + + +def _pattern(tool, inp): + """the text a search looks for: Grep's pattern, or the first argument after grep / rg / ag / ack in a shell command""" + if tool == 'Grep': + return str(inp.get('pattern') or '') + if tool == 'Bash': + try: toks = shlex.split(str(inp.get('command') or '')) + except ValueError: toks = str(inp.get('command') or '').split() + for i, t in enumerate(toks): + if t in ('grep', 'egrep', 'rg', 'ag', 'ack'): + j = i + 1 + while j < len(toks) and toks[j].startswith('-'): + if toks[j] in ('-e', '--regexp'): j += 1; break + j += 2 if toks[j] in ('-f', '-A', '-B', '-C', '-m', '--include', '--exclude', '-g', '-t', '--type') else 1 + if j < len(toks): return toks[j] + return '' + + +def specific(cwd, tool, inp, fp): + """ONE line naming the verb that answers THIS search, when the graph has what it names: a Grep for a declared name + gets `impact` on that declaration, a Read of an indexed file gets `context` scoped to it. '' when the graph has + nothing to say about it (the generic block then stands alone).""" + import sqlite3 + db = _where.graph_db(cwd, fp or None) + if not os.path.isfile(db): + return '' + try: + con = sqlite3.connect(f'file:{db}?mode=ro', uri=True) + except sqlite3.Error: + return '' + try: + pat = _pattern(tool, inp) + if pat: + for w in re.findall(r'[A-Za-z_]\w{2,}', pat): + if w in KEYWORDS: + continue + rows = con.execute("SELECT display, kind, file, line FROM symbols WHERE name = ? AND kind != 'module' " + "ORDER BY is_test, (kind IN ('method','function','constructor')) DESC, file, line LIMIT 4", (w,)).fetchall() + if not rows: + continue + disp, kind, f, l = rows[0] + target = disp if len(rows) == 1 else w + what = 'Who calls it and what a change breaks' if kind in CALLABLE else \ + ('Who reads and writes it' if kind in ('field', 'const', 'variable', 'property') else 'Who uses it, its subtypes, what a change breaks') + where = f"{disp}, {f}:{l}" if len(rows) == 1 else f"{len(rows) if len(rows) < 4 else '4+'} declarations, first {disp} {f}:{l}" + return (f"graph: `{w}` is declared here ({where}). {what}: axiomcode_impact targets=[\"{target}\"]" + f" (`axiomcode impact {target}`); a grep misses the callers that never spell it.") + if tool in ('Read', 'Bash') and fp: + rel = os.path.relpath(os.path.realpath(fp), os.path.realpath(cwd)).replace(os.sep, '/') + n = con.execute("SELECT count(*) FROM symbols WHERE file = ? AND kind IN ('method','function','constructor')", (rel,)).fetchone()[0] + if n: + return (f"graph: {rel} has {n} callable(s) in the graph. How it works: axiomcode_context task=\"\"" + f" in_path=\"{rel}\" source=True; one of them: axiomcode_impact targets=[\"{rel}:\"].") + except sqlite3.Error: + pass + finally: + con.close() + return '' + + def main(): ev = _host.read() tool = ev.get('tool_name') or '' @@ -56,7 +131,7 @@ def main(): if not cwd: sys.exit(0) - stamp = os.path.join(cwd, MARK.format(ev.get('session_id') or 'x')) + stamp = _stamp(cwd, ev.get('session_id')) # the agent is already reaching for the graph -- saying it again is the nag this is trying not to be, # and once it has, the directive has nothing left to say this session @@ -71,7 +146,7 @@ def main(): # a Read of something that is not source is not the decision this is about (a log, a lockfile, a README) if tool == 'Read': fp = inp.get('file_path') or '' - if not fp.endswith(SRC): + if not _where.is_source(_where._abs(fp, ev.get('cwd') or os.getcwd())): sys.exit(0) if tool == 'Bash' and not SEARCH.search(inp.get('command') or ''): @@ -82,7 +157,7 @@ def main(): if tool == 'Bash': _, paths = _where.bash_where(inp.get('command'), ev.get('cwd') or os.getcwd()) files = [p for p in paths if os.path.isfile(p)] - if files and not any(p.endswith(SRC) for p in files): + if files and not any(_where.is_source(p) for p in files): sys.exit(0) if os.path.exists(stamp): @@ -93,7 +168,13 @@ def main(): except Exception: pass - text = FULL + fp = _where._abs(inp['file_path'], ev.get('cwd') or os.getcwd()) if tool == 'Read' and inp.get('file_path') else \ + (next((p for p in files if _where.is_source(p)), '') if tool == 'Bash' else '') + try: + one = specific(cwd, tool, inp, fp) + except Exception: + one = '' + text = (one + '\n' + REST) if one else FULL # stream-json does not carry additionalContext, so a run cannot show from its transcript that this # fired or what it said -- and #1100 is a question about exactly that. enrich.py already logs itself # next to the graph for the same reason; this writes the same file, so one reader sees both halves. diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index 75edab875..8d0369649 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -113,7 +113,7 @@ def outside(p): if files and all(os.path.splitext(a)[1] and os.path.splitext(a)[1].lower() not in _graphline.LANG for a in files): sys.exit(0) if m: tool = 'Grep'; inp = {'pattern': m.group(3)}; _from_shell = True else: - m = re.search(r"sed -n '?(\d+),(\d+)p'? (\S+)", c) or re.search(r'\bcat\s+(\S+\.(?:java|ts|tsx|js|py|cs))\b', c) + m = re.search(r"sed -n '?(\d+),(\d+)p'? (\S+)", c) or re.search(r'\bcat\s+(\S+\.(?:' + _where.SOURCE_ALT + r'))\b', c) # a relative file is relative to where the shell is when it runs: the session's directory, or a `cd` before it if m: base, _ = _where.bash_where(c[:m.start()], scwd) if m and m.re.groups == 3: tool = 'Read'; inp = {'file_path': _where._abs(m.group(3), base), 'offset': int(m.group(1)), 'limit': int(m.group(2)) - int(m.group(1)) + 1} @@ -160,7 +160,7 @@ def context_ids(): # with it, who produces or writes it, who reads it, what reaches those, the tests. The moment this is useful is now. import concurrent.futures fp = _where._abs(inp.get('file_path', ''), scwd); rel = rel_of(fp) - if not re.search(r'\.(java|ts|tsx|js|mjs|cjs|py)$', rel) or re.search(r'(^|/)(tests?|__tests__)/|/src/test/|Tests?\.java$|\.(spec|test)\.[jt]sx?$|(^|/)test_', rel): sys.exit(0) + if not _where.is_source(fp) or _where.is_test(rel): sys.exit(0) SCR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts') try: ch = json.loads(subprocess.run([sys.executable, os.path.join(SCR, 'axiomcode-changed'), cwd, rel, '--json'], capture_output=True, text=True, timeout=10).stdout or '{}') except Exception: ch = {} @@ -231,7 +231,7 @@ def names(xs, k=4): return ', '.join(f"{x['display']} {x['at'].split('/')[-1]}" for n in ch.get('notes', [])[:2]: lines.append(f" added: {n}") if bodies: lines.append(_graphline.body_line(os.path.join(os.environ.get('AXIOMCODE_GRAPH') or os.path.join(cwd, '.axiomcode'), 'out', 'graph.sqlite'), - bodies + [(d, {}) for d in body[3:]])) + bodies + [(d, {}) for d in body[3:]], cwd)) elif tool == 'Read': fp = _where._abs(inp.get('file_path', ''), scwd); rel = rel_of(fp) a = int(inp.get('offset') or 1); b = a + int(inp.get('limit') or 100000) @@ -260,11 +260,23 @@ def names(xs, k=4): return ', '.join(f"{x['display']} {x['at'].split('/')[-1]}" # ranked ★ (connected to earlier Reads) first, then few-caller methods, then the rest as one line; 6 lines at most lo, hi = rows[0]['line'], rows[-1]['end_line'] or b # the lines the text actually covers mids = [r['method_id'] for r in rows]; ids = [r['id'] for r in rows]; ph = ','.join('?' * len(rows)) - def visible(f, ln): return f == rel and lo <= ln <= hi # the other end is in the text the model just read + # the other end is in the text the model just read: the range READ, which runs past the last declaration (a script's + # top-level code after its last function is text the model has), not the span of the declarations in it + def visible(f, ln): return f == rel and min(a, lo) <= ln <= max(b - 1, hi) + # A CALLER IS SHOWN BY THE CALL, NOT BY ITS DECLARATION. Judged by the caller's own line, a file's top level (its + # ``, declared at L1) was never in a range that did not start at L1, so reading a script's functions got + # `main L56 ← trend. L1` for every one of them — the calls at the bottom of the very text just read. In the + # loop's runs that shape was most of the Read blocks nobody acted on. A caller whose call site lies in the range is + # visible; the caller's line is the fallback where a graph records no site line. up = collections.defaultdict(list); anyup = set() # anyup: has a caller at all, shown or not - for e in q(f"SELECT DISTINCT e.callee_method_id m, cr.id, cr.display d, cr.file f, cr.line ln FROM call_edges e JOIN symbols cr ON cr.id = e.caller_id WHERE e.callee_method_id IN ({ph}) ORDER BY cr.is_test, cr.display", *mids): - anyup.add(e['m']) - if not visible(e['f'], e['ln']) and e['d'] not in {x['d'] for x in up[e['m']]}: up[e['m']].append(e) # overloads of one caller are one name + sites = collections.defaultdict(list) + for e in q(f"SELECT e.callee_method_id m, cr.id, cr.display d, cr.file f, cr.line ln, cr.kind k, cs.start_line sl FROM call_edges e JOIN symbols cr ON cr.id = e.caller_id LEFT JOIN call_sites cs ON cs.id = e.call_site_id WHERE e.callee_method_id IN ({ph}) ORDER BY cr.is_test, cr.display", *mids): + sites[(e['m'], e['id'])].append(e) + for (m, _), es in sites.items(): + anyup.add(m); e = dict(es[0]) + if any(visible(x['f'], x['sl'] or x['ln']) for x in es): continue + if e['k'] == 'module': e['ln'] = min((x['sl'] for x in es if x['sl']), default=e['ln']) # a top level is where its call is + if e['d'] not in {x['d'] for x in up[m]}: up[m].append(e) # overloads of one caller are one name dn = collections.defaultdict(list) for e in q(f"SELECT DISTINCT e.caller_id c, ce.id, ce.display d, ce.file f, ce.line ln FROM call_edges e JOIN symbols ce ON ce.method_id = e.callee_method_id WHERE e.caller_id IN ({ph}) AND e.callee_provenance = 'client'", *ids): if not visible(e['f'], e['ln']) and e['d'] not in {x['d'] for x in dn[e['c']]}: dn[e['c']].append(e) @@ -290,6 +302,9 @@ def visible(f, ln): return f == rel and lo <= ln <= hi # the # one, the whole file after a range of it, or the same file through another spelling of its path. A key on the # read's own arguments caught only the first of those. info = [x for x in info if x['r']['id'] not in done] + # A BLOCK OF BARE UNRESOLVED COUNTS NAMES NOTHING TO GO TO (`main L44 ?7 unresolved call(s)` and no edge): no caller, + # callee or override to open, so it is context spent on a number. Kept when one declaration carries anything else. + if not any(x['up'] or x['dn'] or x['ovi'] or x['ovo'] or task_hits(x['r']['display']) for x in info): info = [] block_ids = [x['r']['id'] for x in info] # an edge into a file the agent has not opened is what a read cannot show it; one into a file it has read is # something it may already have seen from the other end @@ -329,7 +344,10 @@ def line(x): shown = (picked + few)[:5] if rows else [] for x in shown: lines.append(line(x)) left = [x for x in info if x not in shown] if rows else [] - if left: lines.append(" " + ('+%d more: ' % len(left)) + ', '.join(f"{short(x['r']['display'])} ←{ups(x)}" + (f" →{len(x['dn'])}" if x['dn'] and not x['up'] else '') + (f" ?{x['un']}" if x['un'] else '') for x in sorted(left, key=lambda x: (-len(x.get('hits') or ()), -(len(x['up']) + x['un'])))[:6]) + (' …' if len(left) > 6 else '') + " (grep Type.name or axiomcode path to narrow)") + # an anonymous function (``, ``) has no name to grep or ask about: counted in `+N more`, not listed + anon = lambda x: re.fullmatch(r'<[\w-]+>', x['r']['display'].rsplit('.', 1)[-1]) is not None # `Walker.` too + n_left = len(left); left = [x for x in left if not anon(x)] + if left: lines.append(" " + ('+%d more: ' % n_left) + ', '.join(f"{short(x['r']['display'])} ←{ups(x)}" + (f" →{len(x['dn'])}" if x['dn'] and not x['up'] else '') + (f" ?{x['un']}" if x['un'] else '') for x in sorted(left, key=lambda x: (-len(x.get('hits') or ()), -(len(x['up']) + x['un'])))[:6]) + (' …' if len(left) > 6 else '') + " (grep Type.name or axiomcode path to narrow)") elif tool == 'Grep' and not (inp.get('path') and os.path.relpath(os.path.realpath(_where._abs(inp['path'], scwd)), os.path.realpath(cwd)).split(os.sep)[0] == '..'): # (a Grep of a path outside this tree is about another codebase — nothing here to add) # a real search is rarely one identifier: `hasNext\(\)|\.next\(\)|close\(\)`, `getScanner|RTBoundValidator|withSSTablesIterated`. @@ -387,6 +405,11 @@ def lookup(n): if e: rel_[r['id']] = (e['how'], ctx_names.get(e['who'], '?')) rows = sorted(rows, key=lambda r: r['id'] not in rel_) # stable: the order above within each group if plain and not rel_ and not unres: return n, [], [] + # OVERLOADS IN ONE FILE ARE ONE NAME TO THE READER: `ISender.Send` declared twice in one interface printed as the + # same line twice. Each (display, file) is one line, saying how many declarations it stands for. + grp = {} + for r in rows: grp.setdefault((r['display'], r['file']), []).append(r) + rows = [v[0] for v in grp.values()]; n_ol = {v[0]['id']: len(v) for v in grp.values()} total = len(rows); rows = rows[:2] out = [] paths = _graphline.distinct_paths([r['file'] or '' for r in rows]) @@ -404,7 +427,8 @@ def lookup(n): + (f"; {nt} in tests" if nt and 2 < len(up) < 40 and nt < len(up) else '') + ")") if up \ else _graphline.zero_label(c, r['method_id'], r['display'].rsplit('.', 1)[-1], r['is_test']) out.append(f" {r['display']} {path}:{r['line']} ← {callers} → {dn}" + (f" ? {un}" if un else '') - + (f" ⇣ {nover[r['id']]} override(s)" if nover.get(r['id']) else '') + tag) + + (f" ⇣ {nover[r['id']]} override(s)" if nover.get(r['id']) else '') + + (f" ({n_ol[r['id']]} overloads)" if n_ol.get(r['id'], 1) > 1 else '') + tag) if total > len(rows) and out: out[-1] += f" (+{total - len(rows)} more declaration(s){'' if total < 40 else ' or more'})" if unres: out.append(f" ({ctx_names.get(unres[0]['caller_id'], '?')}, which you just read, calls a `{n}` at L{unres[0]['start_line']} whose receiver is not typed — it may be any of the above)") return n, rows, out diff --git a/plugins/axiomcode/hooks/orient.py b/plugins/axiomcode/hooks/orient.py index e78d27ec0..2755a551f 100755 --- a/plugins/axiomcode/hooks/orient.py +++ b/plugins/axiomcode/hooks/orient.py @@ -88,7 +88,7 @@ def prompt_root(prompt): # It is the only moment where saying nothing guarantees the skill is never used, so it says one thing and # takes the same once-per-repo stamp. Still bounded, still never repeated, and still silent where it would # be noise: a tree with no source in a supported language has nothing to offer and says nothing. - EXT = ('.java', '.ts', '.tsx', '.py', '.js', '.jsx', '.mjs', '.cjs', '.cs') + EXT = _where.SOURCE_EXT SKIP = {'node_modules', '.git', 'dist', 'build', 'target', 'venv', '.venv', '__pycache__', 'obj'} found = 0 for root, dirs, files in os.walk(cwd): @@ -135,7 +135,7 @@ def _below(d, depth): # Plain English words are not enough however rare: a large codebase declares functions named `when`, `used`, `safe`, # `main`, `loop` and `parser`, so any sentence about anything matched two of them. IDENT = re.compile(r'`([^`]+)`|\b([A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)+)\b|\b([a-z]+[A-Z]\w*|[A-Za-z]+_\w+)\b|\b(\w+)\(\)|(?, 'x', N, 2, a.b, src/x) rather than prose ("no --in was given") _FLAG = re.compile(r"(?]*>|'[^']*'|N(?:\|all)?(?![\w])|\d+(?![\w])|[a-z](?![\w.])|[\w*-]*[/.:*][^\s`'\"(),;\]]*))?") -_CODE = re.compile(r'^\s*(\d+ )?\| ') # a line of quoted source (context --source): never rewritten +# a line of quoted source (context --source), or a site of a grep-shaped answer (`path:line: code [tag]`, whose tag +# names no flag): never rewritten, since the code on it is the file's own text +_CODE = re.compile(r'^\s*(\d+ )?\| |^[^\s:]+:\d+: ') def mcp_words(text): """An answer with each CLI flag that is an MCP parameter written as that parameter: `--in ` -> `in_path=`, @@ -111,7 +113,7 @@ def run(args, cwd=None, timeout=900): # A QUERY ON A REPOSITORY WITH NO GRAPH YET does not hold the call for the length of a first build (minutes; past this # server's timeout it came back as a bare "Error executing tool"): the build is started in the background and the # answer says what stage it is at (ax_contract.ensure_graph). `index` is asked for a build and still waits for it. - env = dict(os.environ, AXIOMCODE_BUILD_NOWAIT='1') if args and args[0] != 'index' else None + env = dict(os.environ, AXIOMCODE_BUILD_NOWAIT='1', AXIOMCODE_SURFACE='mcp') if args and args[0] != 'index' else None try: r = subprocess.run([BASH, AX, *args], cwd=cwd or None, capture_output=True, text=True, timeout=timeout, env=env) except OSError as e: @@ -132,6 +134,14 @@ def run(args, cwd=None, timeout=900): if not r.returncode: out += ''.join('\n' + l for l in (r.stderr or '').splitlines() if l.startswith('graph refresh:')) return mcp_words(out.strip()) or f"(no output, exit {r.returncode})" +# SITES, ONE PER LINE, BY DEFAULT. When the answer is a list of sites (who uses it, the hops of a chain, where a task +# lands, the tests to run) it comes the way grep prints: `path:line: code [resolved | one of a set | text | hop N]`, +# capped, the rest counted (scripts/ax_grep.py). The prose answer's sections, headers and explanations were most of what +# an agent read, and the fan-out it complained of. full=True, or asking for what only the prose carries (the code of a +# flow, test routes, a delete verdict, a later page), gives the verb's own answer, unchanged. +def grep(full, limit=0): + return [] if full else ['--grep'] + (['--grep-limit', str(limit)] if limit else []) + @srv.tool() def axiomcode_index(repo: str = ".", lang: str = '', src: str = '', library: str = '') -> str: """Build (or refresh) the call graph of a repository: parser → engine → /.axiomcode/out/graph.sqlite. Run once before path/impact/graph. lang: java|typescript|python|javascript|csharp when the repo mixes languages; src: subtree to analyse (e.g. src); library: comma-separated dependency roots so calls into them resolve.""" @@ -139,38 +149,41 @@ def axiomcode_index(repo: str = ".", lang: str = '', src: str = '', library: str return run(a) @srv.tool() -def axiomcode_context(task: str, repo: str = ".", in_path: str = '', budget: int = 0, source: bool = False, page: int = 1, explain: bool = False, from_: str = '', fresh: bool = False) -> str: - """[resolved]/[sound] rows are verified against the graph; the answer ends with `next:`, the one step to take. START HERE when you have a task in words and no name to ask about yet. A task that asks HOW something works ("how does X …", "explain …", or explain=True) also gets the call FLOW — every step in the order the calls are written, with ⚠ where the graph lost a call; from_ (comma-separated names) starts the flow where you choose. Pass source=True with it: each step then carries its code, so answer from that and open a file only for a step whose body was cut or a ⚠ call. Otherwise it returns the files and callables that task touches, from the problem statement alone. Deterministic — task terms scored against the graph's vocabulary by inverse document frequency, tests demoted, the closure walked from the best seed per term and ranked by nearest hop. in_path accepts SEVERAL paths, comma-separated: they are combined rather than intersected, so a change spanning two roots comes back in one call. budget is how many files are listed (default 12; the ranking is the same at any budget); source=True includes the code. A long answer comes in pages; ask for page=2 only if page 1's files are not enough. Ends by saying what it could not see. After an edit the answer comes at once from the last graph, rows in edited files marked (may be out of date); fresh=True waits for the rebuild.""" - a = ['context', task, repo] + (['--fresh'] if fresh else []) + (['--in', in_path] if in_path else []) + (['--budget', str(budget)] if budget else []) + (['--source'] if source else []) + (['--page', str(page)] if page and page != 1 else []) + (['--explain'] if explain else []) + [x for n in from_.split(',') if n.strip() for x in ('--from', n.strip())] +def axiomcode_context(task: str, repo: str = ".", in_path: str = '', budget: int = 0, source: bool = False, page: int = 1, explain: bool = False, from_: str = '', fresh: bool = False, full: bool = False) -> str: + """[resolved]/[sound] rows are verified against the graph; the answer ends with `next:`, the one step to take. START HERE when you have a task in words and no name to ask about yet. A task that asks HOW something works ("how does X …", "explain …", or explain=True) also gets the call FLOW — every step in the order the calls are written, with ⚠ where the graph lost a call; from_ (comma-separated names) starts the flow where you choose. Pass source=True with it: each step then carries its code, so answer from that and open a file only for a step whose body was cut or a ⚠ call. Otherwise it returns the files and callables that task touches, from the problem statement alone. Deterministic — task terms scored against the graph's vocabulary by inverse document frequency, tests demoted, the closure walked from the best seed per term and ranked by nearest hop. in_path accepts SEVERAL paths, comma-separated: they are combined rather than intersected, so a change spanning two roots comes back in one call. budget is how many files are listed (default 12; the ranking is the same at any budget); source=True includes the code. A long answer comes in pages; ask for page=2 only if page 1's files are not enough. Ends by saying what it could not see. Without source/explain/from_ the answer is one site per line (`path:line: code [tag]`); full=True gives the prose. After an edit the answer comes at once from the last graph, rows in edited files marked (may be out of date); fresh=True waits for the rebuild.""" + flow = source or explain or from_.strip() or (page and page != 1) or budget + a = ['context', task, repo] + grep(full or flow) + (['--fresh'] if fresh else []) + (['--in', in_path] if in_path else []) + (['--budget', str(budget)] if budget else []) + (['--source'] if source else []) + (['--page', str(page)] if page and page != 1 else []) + (['--explain'] if explain else []) + [x for n in from_.split(',') if n.strip() for x in ('--from', n.strip())] return run(a) @srv.tool() -def axiomcode_path(from_: str, to: str, repo: str = ".", every: bool = False, in_path: str = '', depth: int = 0, limit: int = 0, page: int = 1, fresh: bool = False) -> str: - """[resolved]/[sound] rows are verified against the graph, so a change need not re-derive them by reading (to explain how something works, read each hop's body); the answer ends with `next:`, the one step to take. A chain of calls from A to B in the graph, each hop verified, or why there is none. When you have ONE concept word you can name, a bare fragment resolves to every declaration containing it, so path('decrypt', '*') answers "what is the decryption code and what does it touch". For a whole task in words, with no name at all, use axiomcode_context first. Endpoints otherwise as written in the code: Owner.method, method, Type, Outer$Inner.m, file.java:123, file.py, @Decoration, a library call as written (new File, Files.readAllBytes). '*' on one side = everything that reaches B / everything A reaches. every=True lists every route; in_path restricts to files containing it; depth bounds a closure. A long answer comes in pages, nearest routes first, with the whole answer's counts on every page; ask for page=2 only if page 1 is not enough. fresh=True: after an edit, wait for the rebuild instead of answering from the last graph with rows in edited files marked (may be out of date).""" - a = ['path', from_, to, repo] + (['--fresh'] if fresh else []) + (['--every'] if every else []) + (['--in', in_path] if in_path else []) + (['--depth', str(depth)] if depth else []) + (['--limit', str(limit)] if limit else []) + (['--page', str(page)] if page and page != 1 else []) +def axiomcode_path(from_: str, to: str, repo: str = ".", every: bool = False, in_path: str = '', depth: int = 0, limit: int = 0, page: int = 1, fresh: bool = False, full: bool = False) -> str: + """[resolved]/[sound] rows are verified against the graph, so a change need not re-derive them by reading (to explain how something works, read each hop's body); the answer ends with `next:`, the one step to take. A chain of calls from A to B in the graph, each hop verified, or why there is none. When you have ONE concept word you can name, a bare fragment resolves to every declaration containing it, so path('decrypt', '*') answers "what is the decryption code and what does it touch". For a whole task in words, with no name at all, use axiomcode_context first. Endpoints otherwise as written in the code: Owner.method, method, Type, Outer$Inner.m, file.java:123, file.py, @Decoration, a library call as written (new File, Files.readAllBytes). '*' on one side = everything that reaches B / everything A reaches. every=True lists every route; in_path restricts to files containing it; depth bounds a closure. A long answer comes in pages, nearest routes first, with the whole answer's counts on every page; ask for page=2 only if page 1 is not enough. fresh=True: after an edit, wait for the rebuild instead of answering from the last graph with rows in edited files marked (may be out of date). The answer is one site per line, each hop at the line its call is written on (`path:line: code [resolved · hop 1/3 → B]`); full=True gives the prose, which also says why when there is no chain.""" + paged = page and page != 1 + a = ['path', from_, to, repo] + grep(full or paged, limit) + (['--fresh'] if fresh else []) + (['--every'] if every else []) + (['--in', in_path] if in_path else []) + (['--depth', str(depth)] if depth else []) + (['--limit', str(limit)] if limit and (full or paged) else []) + (['--page', str(page)] if paged else []) return run(a) @srv.tool() -def axiomcode_impact(targets: list[str], repo: str = ".", tests: bool = False, why: bool = False, tests_in: str = '', depth: int = 0, in_path: str = '', kind: str = '', page: int = 1, budget: int = 0, limit: int = 0, delete: bool = False, fresh: bool = False) -> str: - """Trust it: [resolved]/[sound] rows are verified against the graph, so do not re-derive them by reading; the answer ends with `next:`, the one step to take. What has to be looked at again when a declaration changes: must-change-with-it (overrides, subtypes), everything that directly uses it (with how sure each is), everything that reaches those, and the bound (unresolved calls). The tests are always counted, by rung, with the strong-route ones named and the top test files. Ask for the full list SECOND, only if you need it: tests=True returns ONLY the tests, grouped by rung and test file; why=True adds each test's route; tests_in narrows that listing to test files containing it. Long answers come in pages of ~2000 tokens: every page carries the counts of the WHOLE answer and the rows come strongest first, so page 1 is usually enough; ask for page=2 only if you need the weaker rows. budget changes the page size. Targets as written: Owner.method, Owner.field, Type, Owner.method(param), Type, Owner.method:local, or file.ts:123 (the declaration at that line). When you know where the declaration is, target it by file:line: a bare name answers for EVERY declaration of that name, and two unrelated functions in different files come back as one answer. kind: method|field|type|param|typeparam|var when a name is declared as several kinds. limit: rows shown per section (the `… +N (limit=N)` lines); delete=True adds a verdict on whether it is safe to delete. fresh=True: after an edit, wait for the rebuild (use it before a delete or a rename) instead of answering from the last graph with rows in edited files marked (may be out of date).""" - a = ['impact', *targets, repo] + (['--fresh'] if fresh else []) + (['--tests-only'] if tests else []) + (['--why'] if why else []) + (['--tests-in', tests_in] if tests_in else []) + (['--depth', str(depth)] if depth else []) + (['--in', in_path] if in_path else []) + (['--kind', kind] if kind else []) + (['--page', str(page)] if page and page != 1 else []) + (['--budget', str(budget)] if budget else []) + (['--limit', str(limit)] if limit else []) + (['--delete'] if delete else []) +def axiomcode_impact(targets: list[str], repo: str = ".", tests: bool = False, why: bool = False, tests_in: str = '', depth: int = 0, in_path: str = '', kind: str = '', page: int = 1, budget: int = 0, limit: int = 0, delete: bool = False, fresh: bool = False, full: bool = False) -> str: + """Trust it: [resolved]/[sound] rows are verified against the graph, so do not re-derive them by reading; the answer ends with `next:`, the one step to take. What has to be looked at again when a declaration changes: must-change-with-it (overrides, subtypes), everything that directly uses it (with how sure each is), everything that reaches those, and the bound (unresolved calls). The tests are always counted, by rung, with the strong-route ones named and the top test files. Ask for the full list SECOND, only if you need it: tests=True returns ONLY the tests, grouped by rung and test file; why=True adds each test's route; tests_in narrows that listing to test files containing it. Long answers come in pages of ~2000 tokens: every page carries the counts of the WHOLE answer and the rows come strongest first, so page 1 is usually enough; ask for page=2 only if you need the weaker rows. budget changes the page size. Targets as written: Owner.method, Owner.field, Type, Owner.method(param), Type, Owner.method:local, or file.ts:123 (the declaration at that line). When you know where the declaration is, target it by file:line: a bare name answers for EVERY declaration of that name, and two unrelated functions in different files come back as one answer. kind: method|field|type|param|typeparam|var when a name is declared as several kinds. limit: rows shown per section (the `… +N (limit=N)` lines); delete=True adds a verdict on whether it is safe to delete. fresh=True: after an edit, wait for the rebuild (use it before a delete or a rename) instead of answering from the last graph with rows in edited files marked (may be out of date). The answer is one site per line, surest first (`path:line: code [resolved | one of a set | by name | text | hop N | test]`), capped with a count of the rest; full=True gives the sectioned prose (why, delete, budget and page give it too).""" + prose = full or why or delete or (page and page != 1) or budget + a = ['impact', *targets, repo] + grep(prose, limit) + (['--fresh'] if fresh else []) + (['--tests-only'] if tests else []) + (['--why'] if why else []) + (['--tests-in', tests_in] if tests_in else []) + (['--depth', str(depth)] if depth else []) + (['--in', in_path] if in_path else []) + (['--kind', kind] if kind else []) + (['--page', str(page)] if page and page != 1 else []) + (['--budget', str(budget)] if budget else []) + (['--limit', str(limit)] if limit and prose else []) + (['--delete'] if delete else []) return run(a) @srv.tool() def axiomcode_changed(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, impact: bool = False, page: int = 1) -> str: - """Which declarations an edit changed and HOW — signature (parameters added / removed / retyped, return type), field (its type, name, initializer), type header, body only, removed, added — the working tree against the commit the graph was built from (default), two commits (range='a..b'), or the index (staged=True); each with the target impact takes. impact=True runs impact on all of them as one change set and returns its answer.""" + """Which declarations an edit changed and HOW — signature (parameters added / removed / retyped, return type), field (its type, name, initializer), type header, body only, removed, added (a new file is one `added` line) — the working tree against the commit the graph was built from (default), your branch's commits (range='a..b': read from `git merge-base a b`, so commits a received after you branched are not yours; a note says so when a has moved), or the index (staged=True); each with the target impact takes. When the working tree is clean but HEAD has commits of its own, it says which range=... to ask. files=[...] limits it to those files; on a copy without git (which it refuses otherwise) every declaration in a named file counts as changed. Changed files outside every indexed language (fixtures, case data, a schema) are named, never dropped. impact=True runs impact on all of them as one change set and returns its answer.""" a = ['changed', repo, *files] + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--impact'] if impact else []) + (['--page', str(page)] if page and page != 1 else []) return run(a) @srv.tool() -def axiomcode_test_impact(repo: str = ".", range: str = '', staged: bool = False, in_path: str = '', limit: int = 0, why: bool = False, page: int = 1) -> str: - """Which tests actually have to run for the edit in front of you: the test files that reach any changed declaration, with the chain, so the selection can be checked rather than trusted. Working tree by default, or range='a..b', or staged=True. Conservative by design — a test reached only through an edge the graph does not encode (reflection, a service loader, a runtime-built case) will NOT appear, so it is a lower bound. why=True prints the chain for each.""" - a = ['test-impact', repo] + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--in', in_path] if in_path else []) + (['--limit', str(limit)] if limit else []) + (['--why'] if why else []) + (['--page', str(page)] if page and page != 1 else []) +def axiomcode_test_impact(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, in_path: str = '', limit: int = 0, why: bool = False, page: int = 1, full: bool = False) -> str: + """Which tests actually have to run for the edit in front of you: the test files that reach any changed declaration, with the chain, so the selection can be checked rather than trusted, and the command that runs them. Working tree by default; range='a..b' for your branch's commits (from `git merge-base a b`, so a base branch that moved on is not counted as your change); staged=True for the index; files=[...] for named files (a named file with no edit, or any on a copy without git, counts whole: the tests of everything in it). An edited test file is itself listed to run. Changed files outside every indexed language (fixtures, case data) are named with the test files that name them in their text. Conservative by design — a test reached only through an edge the graph does not encode (reflection, a service loader, a subprocess, a runtime-built case) will NOT appear, so it is a lower bound. why=True prints the chain for each. The answer is one test per line (`path:line: code [test · resolved · hop N]`) and the command that runs them; full=True gives the prose.""" + a = ['test-impact', repo, *files] + grep(full or why or (page and page != 1)) + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--in', in_path] if in_path else []) + (['--limit', str(limit)] if limit else []) + (['--why'] if why else []) + (['--page', str(page)] if page and page != 1 else []) return run(a) @srv.tool() def axiomcode_graph(repo: str = ".", out: str = '') -> str: - """Draw the graph as one interactive HTML page (/.axiomcode/graph/graph.html, or out=); runs index first if there is no graph yet.""" + """Draw the graph as one interactive HTML page, for a person: every language the repository was indexed in, at /.axiomcode/graph/graph.html or out=. Drawn from the existing graph when it is up to date (seconds, no engine run); a graph that is out of date is rebuilt first with the --lang, --src and --library it was indexed with, never for a language the index left out; with no graph yet the repository is indexed first. Answers with what it drew, in prose, and the page's absolute path.""" return run(['graph', repo] + (['--out', out] if out else [])) if __name__ == '__main__': diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index eaba7b67f..71b2e082c 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -13,6 +13,11 @@ Read / Grep results as `graph: …` lines. **Trust the answer, and know what it is.** A `[resolved]` / `[sound]` row has already been looked up again in the graph (the `verified:` line): do not re-derive it by grepping. Each answer ends with `next:` — the one step to take. For a CHANGE (who calls it, what breaks, which tests), read only the lines you will cite or change. To EXPLAIN how something works, the graph gives the reading order, not the explanation: read each step's body, and continue through every `⚠` (a call the graph lost). `[by name]` / `[text]` rows are leads, not facts. +**A list of sites comes the way grep prints it.** The MCP `impact`, `path`, `test_impact` and `context` (without +`source` / `explain` / `from_`) answer one site per line: `path:line: [resolved · hop 2 · test …]`, +surest first, capped with a count of the rest; `limit=N` lists more, `full=True` gives the sectioned answer with `next:`. +From the shell the same shape is `--grep` (`--grep-limit N`); without it the answer is the prose. + ## Start here | the question in front of you | the call | @@ -27,7 +32,7 @@ Read / Grep results as `graph: …` lines. | "how does A reach B" · "everything that reaches X" | `axiomcode path A B` · `axiomcode path '*' X` | | "what did my edit touch" · "which tests do I run" | `axiomcode changed --impact` · `axiomcode test-impact` | | "is it safe to delete X" | `axiomcode impact X --delete` | -| the graph as a page for a human · this repo should prefer the graph, once | `axiomcode graph` · `axiomcode install` | +| the graph as a page for a human · this repo should prefer the graph, once | `axiomcode graph` (drawn from the existing graph in seconds; a stale one is rebuilt first with the flags it was indexed with; prints the page's absolute path) · `axiomcode install` | Rules that decide whether an answer means anything: @@ -38,7 +43,8 @@ Rules that decide whether an answer means anything: briefly on its own only when the answer touches an edited file and the rebuild is nearly done. - **Before a delete or a rename, ask with `--fresh`** (MCP `fresh=true`): it waits for the rebuild, printing its progress, and answers from a graph that includes every edit. - A manual `index` with different flags (e.g. dropping `--library`) rebuilds a worse graph over the good one. + A manual `index` with different flags rebuilds a worse graph over the good one. A bare `index`, the background + refresh and `graph` keep the `--lang` (and `--src`, `--library`) the graph was indexed with; pass `--lang` to change it. - A repo in several languages is indexed in all of them, one graph each, and every query asks each graph; calls are not followed from one language to another. `--lang` restricts it, `--src src` narrows it; `--library ` so calls into dependencies resolve (without it they are `ambiguous_unknown` — do not quote that resolution rate). @@ -57,6 +63,7 @@ An answer's label is the **worst** rung on its route. Read it before acting on t | `[defines]` · `[protocol]` · `[decorator by name]` | closure from its definer · interpreter-called method · wrapper rebinding the name | | `[fixture]` · `[at import]` | injected before the test body · module raised on import, test never collected | | `[by key]` | joined through a registration **string** (route, signal, CLI command) — not an edge | +| `[stubs it]` | a call written inside a mock's stub or verification (`when(m.f())`, `verify(m).f()`, `Setup(x => x.F())`, `Received().F()`): names it, runs none of it — never a test route, listed apart | | `[in scope]` · `[by name]` · `[text]` | same name in the owner's scope · same name elsewhere (may be another thing) · text only | Below `[sound]` / `[one of a set]` the order is a tie-break, not a measured ranking. `[sound]` means the edges @@ -81,9 +88,11 @@ keys, injected beans and handlers registered as values — none has a call site. ## changed · test-impact — from an edit -`axiomcode changed [--impact] [--staged | --range a..b]` says how each declaration changed (`signature`, `body`, -`field`, `type`, `removed`, `added`). `axiomcode test-impact [--why]` lists the tests the edit reaches and the -command to run them. It is a **lower bound**: skipping what it does not name is your risk decision, since reflection +`axiomcode changed [--impact] [--staged | --range a..b] […]` says how each declaration changed (`signature`, `body`, +`field`, `type`, `removed`, `added`). `axiomcode test-impact [--why] […]` lists the tests the edit reaches and the +command to run them. For your branch's commits ask `--range ..HEAD`: it reads from the merge-base, so a base +that moved on is not counted as yours. On a copy without git, name the files you edited. Changed fixtures and other +files no graph reads are named, with the tests whose text names them. It is a **lower bound**: skipping what it does not name is your risk decision, since reflection and service loaders are invisible. Detail: `reference/changed-and-tests.md`. ## path — asking the graph @@ -92,6 +101,8 @@ and service loaders are invisible. Detail: `reference/changed-and-tests.md`. (with the unresolved sites that might connect them). Endpoints as written: `Owner.method`, `Type`, `file.ts:123`, `'new File'`, `'@GetMapping'`, `'*'`, or a bare word. A misspelt name stops with the close ones. Detail: `reference/path.md`. +A fact no verb prints (decorations, bases, entry points by reason, field writers): `reference/schema.md` names the table per language. + ## What it cannot see — say so instead of guessing Reflection, string dispatch, event buses; receivers the engine could not type; callbacks invoked by a library; diff --git a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md index e90684db0..844eb7ad2 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md +++ b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md @@ -11,6 +11,31 @@ commits (when the graph is at the newer side, the declarations are the new text' `--staged` the index, `--old/--new/--file` two texts of one file. Each line ends with the target `impact` takes for it — a signature with one parameter changed is `Owner.m(param)` — and `--impact` runs impact on all of them as one change set. +What to pass, and what the answer says when the question cannot be answered the way it was asked: + +| situation | ask | what comes back | +|---|---|---| +| uncommitted edits | `changed` · `test-impact` | the edits against the baseline | +| your branch's commits | `changed --range ..HEAD` (MCP `range='..HEAD'`) | read from `git merge-base HEAD`, not from ``'s tip: commits the base branch received after you branched are not yours and are left out. A `note: range base: merge-base …` line says so whenever `` has moved. `a...b` means the same; `a` alone is `a..HEAD` | +| committed work, clean tree | `changed` | `no change …` followed by `next: … HEAD is N commit(s) ahead of — ask --range ..HEAD` | +| a copy without git | `changed` | a refusal: no base to diff against. Name the files instead | +| named files | `changed …` · `test-impact …` (MCP `files=[…]`) | each file's edit; a named file with no edit (or any named file on a copy without git) counts **whole**: every callable declared in it is `named`, and test-impact selects the tests of all of them | +| a file the base does not have | (any) | one line, `added — new file, N declaration(s)`, plus each new declaration something outside the file already calls, with its impact target. Never its parameters or docstring words | +| fixtures, case data, a schema | (any) | named as `outside every indexed language`, never "no change"; test-impact lists the test files whose text names them (the path, the file name, or a quoted directory), as a `[text]` tier, and says when no test names them | + +**A lambda is part of what encloses it.** Every lambda a front end declares carries one name (``), so it is never +the declaration an edit is charged to: an edit inside a lambda in a method is that method's `body` change, and one inside a +field's initializer is that field's. A lambda nothing encloses (an entry in a module-level table) is its own `body` +change, named by where it is, `module.` or `Owner.method.`, and its target is `file:line`; that +name is also a target `impact` and `path` accept. Its parameter list is read from the lambda's own header, so an unchanged +header is never reported as a parameter change. `impact :` on a field, a property, a constant or a type +header line answers for that declaration; a callable written on the line still wins. + +`test-impact` also lists an edited or new **test file** as one to run, and adds it to the command. Code that is also run as a +program (`if __name__ == '__main__'`, `static void main`, `Main`) is looked for by name in the tests, since a test that starts +it as a subprocess or drives it from case data has no call edge to it; when no test names it the answer says the selection is +a lower bound for it. + Measured against 270 real fixes (a Java defect-benchmark arena: the fix applied to the buggy files, the declarations it reports against the benchmark's own scanner's reading of the same hunks, its class-level state expansion taken out): exact agreement on 255, 465 declarations reported for the scanner's 473 — recall 0.968, precision 0.985. Every remaining @@ -55,7 +80,7 @@ absent, never wrong, and the `? n` count says how many. ## test-impact — which tests this edit reaches -`axiomcode test-impact` takes the edit (the working tree by default, `--range a..b` or `--staged`), maps it onto the +`axiomcode test-impact` takes the edit (the working tree by default, `--range a..b`, `--staged`, or named files), maps it onto the declarations through `changed`, asks `impact` which tests reach any of them, and prints the test files with the runner command that runs exactly those. It is `changed` + `impact --tests` with the answer shaped for a pipeline rather than for a reader. @@ -75,5 +100,12 @@ library a `[sound]` route (every hop a single resolved target) was right **29 ti `[by name]` 0 in 1; a `[fixture]` route is right 30 times in 30 on a service where a fixture is the only way in and about 1 in 4 on a framework where every test builds an app. Run the sound rung first, and decide about the rest with the number in front of you. Skipping what it does not name is a decision about risk that this tool cannot make for -you: a test reached only through reflection, a service loader, or a case built at runtime does not appear here. +you: a test reached only through reflection, a service loader, a subprocess, or a case built at runtime does not appear +here (the `[text]` tier above recovers the ones whose test names the file it loads). + +A test that only **stubs** a changed declaration on a mock (`when(repo.find(1))`, `mock.Setup(r => r.Find(1))`) is not +selected for a body edit: it runs none of the body. It is named on a `not selected:` line, and it is selected when the +change is a signature change or a removal, which breaks the stub. A test that reaches the change only through a +framework-entered entry point (an HTTP route, an event, a mediator send) is named on a `NOT COUNTED` line, with the +search that finds it, unless a `[by key]` route already joined it. diff --git a/plugins/axiomcode/skills/axiomcode/reference/impact.md b/plugins/axiomcode/skills/axiomcode/reference/impact.md index 967f21f51..67e77124f 100644 --- a/plugins/axiomcode/skills/axiomcode/reference/impact.md +++ b/plugins/axiomcode/skills/axiomcode/reference/impact.md @@ -13,10 +13,11 @@ first uses the type). Several targets in one call are one change set. A name dec Every judgement is a rule in `dl/impact.dl`: the Python side exports facts from graph.sqlite once per graph (members, owners, extends, nesting, decorations, overrides, resolved and unresolved call sites, references with the qualifier written on the line, type references, string literals, tests and fixtures), writes the target and the few text-level facts for the query, and -runs one Soufflé program — compiled to a native binary by `axiomcode index` (~20 s once for all of them, cached by the -program's hash under `dl/.cache/` and shared by every repository on the machine; on first use if the index did not warm it, -the interpreter when there is no `c++`). Warming it at index time is what keeps a caller with a timeout — `hooks/changes.py` -runs impact with `timeout=14` on every edit — from killing the compile before it can finish and caching nothing. +runs one Soufflé program, compiled to a native binary once per machine (45-140 s for impact.dl), cached by the program's +hash under `~/.cache/axiomcode/queries/` and shared by every repository and every plugin copy with the same rules. `axiomcode +index` starts that compile in the background when the build starts; a query never waits for it: until it is done, and when +there is no `c++`, the same program runs in the Soufflé interpreter, with the same answer. The compile runs detached, so a +caller with a timeout (`hooks/changes.py` runs impact with `timeout=14` on every edit) cannot kill it half-way. Direct dependents, the contract, the seeds, the closure, the chains (`parent_up`) and the tests are all derived in the same run; nothing is recomputed a second way. What is verified afterwards is the export: every printed chain hop and every `[resolved]` entry is looked up again in `graph.sqlite` (the `verified:` line). @@ -29,6 +30,9 @@ every printed chain hop and every `[resolved]` entry is looked up again in `grap - **what the container injects** — a type registered as a bean, or a method that defines one, lists the callables the container hands it to (`ctor_param`, a field injection): `receives it by dependency injection — the container hands it over, no call site`. Swapping a `@Bean` implementation reaches its consumers this way. + A class that registers the type from another class (`@EnableConfigurationProperties({T.class})`, a `@MapperScan` + or properties package scan) is listed as `registers it as a bean`, and a configuration class lists who is injected + with the beans its own `@Bean` methods define (`is injected with a bean this class defines`). - **what a framework hands over (Python)**: the engine's `framework_edge` joins a task body to its `.delay()` / `.apply_async()` producer, a `@receiver` to the `send` of the same signal object, a view to its route table, a `Depends()` provider to the handler declaring it, and a fixture to the test naming it. The end that hands over is listed @@ -75,7 +79,9 @@ line and names the constructor query to run; take that suggestion before acting field's value there, through the generated setters). A field's declared or generated setter, a generated constructor. - **reads or uses it** — every callable whose text uses the declaration, grouped by *why* (calls it, reads it, instantiates it, names it in a signature, uses a member imported from it, …) and by *how sure*: `[resolved]` an edge the engine resolved (a call - — `[one of a set]` when it is a multi_inferred target set —, an override, a subtype, a constructor); `[in scope]` a reference by + — `[one of a set]` when it is a multi_inferred target set —, an override, a subtype, a constructor; a call written against + the interface or base method this one implements is a direct row too, worded `calls it (via the interface)` or `(via the + base class)`, and `[resolved]` only when nothing else can run there); `[in scope]` a reference by that name inside the owner type, a subtype or a nested type; `[by name]` a reference by that name elsewhere — the receiver was not typed, so it may be a same-named other thing — including a read written through a variable from a callable with no owner type at all, which is what a module-level function in Python or JavaScript is; `[text]` the name found in the source where the parser records no line (Java @@ -175,6 +181,18 @@ line and names the constructor query to run; take that suggestion before acting of what a service's suite does. The two spellings of a path are matched segment by segment (`/orders/o-1/price` against `/orders/{order_id}/price`, ``, `:id`), never normalised. It is **not** an edge the engine resolved and is never shown as one: the hop is `[by key]`, and a literal can be a same-valued other thing. +- **a stub on a mock is NOT a hop** — `when(repo.find(1))`, `verify(repo).save(x)`, `doReturn(v).when(repo).find(1)`, + `mock.Setup(r => r.Find(1))`, `mock.Verify(...)`, `sub.Received().Find(1)`, `sub.Find(1).Returns(v)`: the engine + resolves the call to the declared method, which is right about the name and wrong about execution, since the receiver + is a mock. Such a site is marked by its position against the mocking library's own call (a knob table per language in + `scripts/ax_edges.py`, `STUB_WRAPPERS`), and it is a `[stubs it]` row: a rename or a new parameter breaks it, a body + change never does. It is kept out of the closure, so a test whose only contact is a stub is not counted under `tests:`; + it is listed on its own `[stubs it]` line, and `test-impact` selects it only for a signature change or a removal. A call + in the stub's ARGUMENT list (`when(repo.find(Ids.first()))`) runs for real and stays a route. A test that drives the + class under test with a mock injected still counts through the class under test: the graph cannot see which object + is injected. An entry point of the change that a framework enters (a route handler, a listener) is named on a + `NOT COUNTED` line with the search that finds the tests driving it, since those are counted only where a `[by key]` + route joins them. - **a decorator that rebinds the name is a hop** — `@audited def summarise(…)` leaves `summarise` denoting what `audited(summarise)` RETURNED, so every caller written with that name runs the wrapper. That is the engine's own resolution (`ext_decorated_name_target`), not a name match, so the hop is `[sound]`; without it a `functools.wraps` diff --git a/plugins/axiomcode/skills/axiomcode/reference/schema.md b/plugins/axiomcode/skills/axiomcode/reference/schema.md new file mode 100644 index 000000000..b423e757c --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/reference/schema.md @@ -0,0 +1,103 @@ +# schema — which table holds X, per language + +Ask a verb first; open the graph only for a fact no verb prints. Every graph holds ONE language, and the same fact +lives in a different table per language. This page says where, for Python, Java and C#, and what is not recorded +at all, so you stop looking. Measured on a Django app, a Spring Boot app and an ASP.NET app, one fresh index each. + +## Which graph + +| | | +|---|---| +| the main language (most files) | `.axiomcode/out/graph.sqlite`, a symlink to `.axiomcode/out//graph.sqlite` | +| every other language | `.axiomcode/lang//out/graph.sqlite` | +| which one you opened | `sqlite3 -readonly "SELECT value FROM run WHERE key='language'"` | +| tested SQL, caveats, value meanings | the `schema_queries`, `schema_notes`, `schema_vocab` tables in the same file | + +Ids are opaque (`PY_METHOD_…`, `METHOD_REGISTRY_…`, `CS_PROPERTY_…`): join on them, never parse them. `symbols` +holds every declaration of every kind with `file`, `line`, `owner`, `is_test`; `symbols.id` is the id the other +tables use, and `symbols.method_id` / `type_id` join it to `methods` / `types`. + +## Fact by language + +| fact | Python | Java | C# | +|---|---|---|---| +| decoration / annotation / attribute | `decorations`; owner is a method or type | `decorations`; owner is a method, type, field or a **parameter** (`METHOD_PARAMETER_…`, joins nothing) | `decorations`; owner is a method, type or property (`CS_PROPERTY_…`) | +| its name and text | `name` = last dotted segment (`@admin.register(X)` → `register`); `text` = as written, args included | `name` as written after `@`; `text` with args, string quotes tripled (`"""/articles"""`) | `name` as written; `text` = `@Name` **only**, even for `[Endpoint(Name = "x")]`: the arguments are in `literals` at the same file:line | +| base types, resolved | `type_ancestors` (transitive) | `type_ancestors`, library bases included as `types.provenance='external'` | `type_ancestors` (transitive) | +| base types, library / unresolved | **not** in `type_ancestors`: `type_refs` `context='BASE_CLASS'` (last segment only, `Model`) and `ext_type_base_unresolved` (c1 = type id, c3 = text, `models.Model`) | as above; also `type_use` `context='SUPER_TYPE'` with `owner_type_id` | **not** in `type_ancestors`: `type_refs` `context='BASE_LIST'` (name without type args); `ext_type_base_unresolved` c3 = name, but c1 is a declaration group, not a `types.id` | +| entry points | `entry_points(method_id, reason)`: `url`, `orm_hook` seen; rules also emit `http`, `task`, `signal_receiver`, `fixture`, `di_provider`, `grpc_service`. **No** `test` or `main` reason | `test`, `http`, `bean_ctor`, `factory`, `main` seen; also `cli`, `queue`, `scheduled`, `lifecycle`, `spring_factories`; config keys in `ext_config_entry_point` | `test`, `http`, `orm_hook`, `framework_hook`, `main` seen; also `queue`, `grpc_service` | +| field declarations | `symbols` `kind='field'` (`PY_FIELD_…`, `owner` `Form` or `Form.Meta`); `fields` is **empty** | `fields` | `fields` = true fields and consts only; a property is `symbols` `kind='field'` with a `CS_PROPERTY_…` id, and its accessors are `methods` `kind` `PROPERTY_GET` / `PROPERTY_SET` / `PROPERTY_INIT` (`get_X`, `set_X`). In `symbols` every field, const, property and enum member has `owner` = its declaring type (`Outer.Inner` when nested) and `qualified_name` `..` | +| who writes / reads a field | **not recorded**: `field_access` is empty; `refs` `ATTRIBUTE_ACCESS` / `FIELD` is every mention by name and line, read and write alike, with no field id | `field_access` (`access` read / write, `tier`, `caller_id`) | property: `call_edges` `kind` `property_write` / `property_read` to the accessor. Field and const: **not recorded** (`field_access` empty; `refs` `MEMBER_ACCESS` and `NAME_REFERENCE` by name and line, with no field id) | +| call edges | `call_edges`; tiers `known_edge`, `multi_inferred`, `boundary_lib`, `ambiguous_unknown`; kinds `METHOD_CALL`, `SELF_CALL`, `DECORATOR_*`, `PROPERTY_READ`, … | tiers add `ambiguous_anon`; kinds `method`, `new`, `anon_new`, `ctor_delegate`, `ref` | tiers add `known_builtin_operator`, `known_implicit_ctor`; kinds add `property_read`/`_write`, `operator`, `conversion`, `indexer`, `delegate` | +| why a call is unresolved | `ext_call_site_unresolved` (c0 site, c1 caller, c2 reason, c3 call kind) | `unresolved_sites` only, no reason | `ext_site_unresolved_named` (c0 site, c1 receiver type or ``, c2 name) | +| strings in source | `literals(value, file, line)` | `literals`; config keys: `ext_config_binding` (key, mechanism, target kind, target id, owner), `ext_config_class_ref` | `literals` | +| text outside the source (XML, YAML, SQL, …) | **not in the graph**: scanned per query, cached in `.axiomcode/out/dl/nonsource.sqlite` (`files(id, rel)`, `tok(tok, fid)`) | same | same | +| tests | `symbols.is_test` (by file path); no test entry point | `is_test` + `entry_points` `reason='test'` | `is_test` + `entry_points` `reason='test'` | +| test rungs (`[sound]`, `[fixture]`, `[at import]`, …) | **not stored**: computed per query | same | same | + +`field_access`, `type_use` and `type_instantiated` are empty in some languages (`type_use` in Python and C#, +`type_instantiated` in C#): run `SELECT count(*)` before reading an empty answer as "nothing". `overrides` is empty +in Python: its dispatch set is `dispatch_candidates` (basis `mro`). + +## The verb for each fact + +| fact | verb | +|---|---| +| methods carrying a decoration | `path '@login_required' '*'` (or `'@GetMapping'`): the decorated methods and what they reach | +| subtypes of a type | `impact `: "must change with it" | +| who writes a Java field | `impact .`: "produces or writes it" | +| who writes a C# property | `impact .` (or `:`): readers and writers `[resolved]` through its accessors | +| who reads a C# field or const | `impact .`: readers `[in scope]` inside the type, `[by name]` elsewhere, since no C# field access is resolved; `:` of a field answers nothing (no callable spans it), so ask by name | +| a Python field | `impact .` lists readers `[in scope]` / `[by name]` only; there is no writer section, because no writer relation exists | +| text files naming a declaration | `impact X`: "bound from outside the source"; `context ""`: "text files that name these" | +| a config key or a quoted string | `impact app.cache.ttl` · `impact '"some-string"'` | +| test rungs and routes | `impact X --tests-only --why` · `test-impact --why` | +| entry points by reason | no verb: SQL below | + +## Queries + +```sh +G=.axiomcode/out/graph.sqlite +# [all] decorations, with the owner whatever its kind (a Java parameter's owner comes back NULL) +sqlite3 -readonly $G "SELECT d.text, s.kind, s.qualified_name, d.file, d.line FROM decorations d + LEFT JOIN symbols s ON s.id = d.owner_id WHERE d.name = 'GetMapping'" +# [all] entry points by reason, then one reason's methods +sqlite3 -readonly $G "SELECT reason, count(*) FROM entry_points GROUP BY 1" +sqlite3 -readonly $G "SELECT m.qualified_name, m.file_path, m.start_line FROM entry_points e + JOIN methods m ON m.id = e.method_id WHERE e.reason = 'http'" +# [all] resolved ancestors (Java: library ones too, provenance 'external') +sqlite3 -readonly $G "SELECT a.qualified_name, a.provenance FROM type_ancestors x JOIN types t ON t.id = x.type_id + JOIN types a ON a.id = x.ancestor_type_id WHERE t.name = ''" +# [python] library bases, full text as written +sqlite3 -readonly $G "SELECT t.qualified_name, u.c3 FROM ext_type_base_unresolved u JOIN types t ON t.id = u.c1" +# [csharp] library bases: the owner is the innermost type whose span holds the base-list line +sqlite3 -readonly $G "SELECT r.name, (SELECT t.qualified_name FROM types t WHERE t.file_path = r.file + AND r.line BETWEEN t.start_line AND t.end_line ORDER BY t.start_line DESC LIMIT 1) AS owner + FROM type_refs r WHERE r.context = 'BASE_LIST'" +# [java] field writers +sqlite3 -readonly $G "SELECT m.qualified_name, a.file_path, a.start_line, a.tier FROM field_access a + JOIN fields f ON f.id = a.field_id JOIN methods m ON m.id = a.caller_id + WHERE f.owner_qualified_name LIKE '%.' AND f.name = '' AND a.access = 'write'" +# [csharp] property writers (property_read for readers) +sqlite3 -readonly $G "SELECT c.qualified_name, s.file_path, s.start_line FROM call_edges e + JOIN methods t ON t.id = e.callee_method_id JOIN methods c ON c.id = e.caller_id + JOIN call_sites s ON s.id = e.call_site_id WHERE e.kind = 'property_write' AND t.name = 'set_'" +# [all] tiers in this graph; [python] what the unresolved sites are waiting on +sqlite3 -readonly $G "SELECT tier, count(*) FROM call_edges GROUP BY 1" +sqlite3 -readonly $G "SELECT c2, count(*) FROM ext_call_site_unresolved GROUP BY 1 ORDER BY 2 DESC" +``` + +## Traps + +- **Paths.** Java `methods`, `types`, `fields`, `call_sites` and `field_access` hold ABSOLUTE paths; its `symbols`, + `decorations` and `type_refs` hold repo-relative ones, as every Python and C# table does. Match Java with + `LIKE '%/rel/path.java'`. +- **Lines.** Java `type_refs` rows carry `line = 0` in every context but the two annotation ones: locate a Java + base through `type_use` or the type. A C# `BASE_LIST` line is where the base list is written, which is below + `types.start_line` when attributes or a line break come first: join by span, not by equal line. In a graph built + before the field-line fix, every Python field line is one early (0-based): a nested class's first field sits on + its `class Meta:` line. +- **Names.** Python `type_refs` and `decorations` keep only the last dotted segment; the full text is in + `ext_type_base_unresolved.c3` and `decorations.text`. String cells are CSV-escaped: match with `LIKE '%x%'`. +- **`ext_*` tables** have positional columns `c0…cN`; `SELECT description FROM schema_tables WHERE name = ''` + names them. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py index f51fba1e2..65cec6498 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_contract.py @@ -406,13 +406,14 @@ def ensure_graph(repo, db): killed every time, cache nothing, and repeat on the next edit forever. AXIOMCODE_GRAPH points at a graph someone else built and placed; nothing is built into it.""" + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))); import ax_fresh + rr = os.path.realpath(repo); auto = os.environ.get('AXIOMCODE_AUTOBUILD') + if not os.environ.get('AXIOMCODE_GRAPH'): ax_fresh.relink(rr) # a pointer into another checkout is not this graph (#1605) if os.path.exists(db): return True # A BUILD IS RUNNING: wait for it, never start a second one (#1305). The graph (or the baseline graph `changed` reads, # or another language's) can be missing for a moment while a build swaps it; a query that took that for "no graph" # started a full build of its own, which queued behind the running one and then rebuilt everything again as an # explicit index. The hooks, which run under timeouts of seconds, do not wait. - sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))); import ax_fresh - rr = os.path.realpath(repo); auto = os.environ.get('AXIOMCODE_AUTOBUILD') if ax_fresh.building(rr): # a build is running: never a silent wait. Under the MCP server answer at once with the stage it is at; from a # shell wait for it, printing the stage as it moves; either way, use the graph the moment it exists @@ -445,15 +446,17 @@ def ensure_graph(repo, db): # a graph WAS built here and its pointer is broken: a repair, which keeps the baseline `changed` and test-impact # measure edits against, as the background refresh does. Built as a first index it moved the baseline to the # edited tree, and every edit made before it dropped out of `changed` - print(f"the graph of {repo} is missing (a build was interrupted) — rebuilding it; the baseline edits are measured against is kept …", file=sys.stderr) + # relink above took every pointer that leaves this .axiomcode/out: one still here is this repository's own + try: gone = f" (.axiomcode/out/graph.sqlite points at {os.readlink(db)}, which is not there)" + except OSError: gone = '' + print(f"the graph of {repo} is missing (a build was interrupted){gone} — rebuilding it; the baseline edits are measured against is kept …", file=sys.stderr) env = dict(os.environ, AXIOMCODE_KEEP_BASE='1') else: print(f"no graph for {repo} yet — building one (this is the only slow call; later ones read it) …", file=sys.stderr) if nowait: os.makedirs(os.path.join(repo, '.axiomcode'), exist_ok=True) log = open(os.path.join(repo, '.axiomcode', 'first-build.log'), 'w') - kw = dict(start_new_session=True) if os.name != 'nt' else dict(creationflags=0x00000008 | 0x00000200) # DETACHED | NEW_GROUP, as ax_fresh.kick - p = subprocess.Popen([bash, build, repo], stdin=subprocess.DEVNULL, stdout=log, stderr=log, close_fds=True, env=env, **kw) + p = _start_build([bash, build, repo], log, env) # until the build holds its lock a second query would see no build and start another, so that much is always waited up = time.time() + 10 while p.poll() is None and not ax_fresh.building(repo) and time.time() < up: time.sleep(0.05) @@ -469,7 +472,7 @@ def ensure_graph(repo, db): os.makedirs(os.path.join(repo, '.axiomcode'), exist_ok=True) logp = os.path.join(repo, '.axiomcode', 'first-build.log') with open(logp, 'w') as log: - p = subprocess.Popen([bash, build, repo], stdin=subprocess.DEVNULL, stdout=log, stderr=log, close_fds=True, env=env) + p = _start_build([bash, build, repo], log, env) shown = [0] def relay(): try: @@ -482,6 +485,25 @@ def relay(): return p.returncode == 0 and os.path.exists(db) +def _start_build(argv, log, env): + """the build a query starts, in a session (a process group) of its own, as ax_fresh.kick starts the refresher. + + THE BUILD IS NOT THE QUERY'S (#1555). It goes on solving the other languages after the query has its answer, and a + query is stopped all the time: a caller's timeout, Ctrl-C, an agent host that ends the command's process group when + it returns or times out. Started in the query's group, the build was stopped with it -- on a repository in several + languages, whose first build outlasts a two-minute timeout, before the main graph was published, or while it was + being indexed, which deleted the solved graph as a failure. No graph.sqlite was left, the next query built again + from nothing, was stopped again, and every query rebuilt forever. In its own session the build finishes whatever + becomes of the query; a later query finds it running and waits for it rather than starting another.""" + if os.name != 'nt': + return subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=log, stderr=log, close_fds=True, env=env, start_new_session=True) + # DETACHED | NEW_GROUP, and out of the caller's job object where the job allows it (see ax_fresh.kick) + for flags in (0x00000008 | 0x00000200 | 0x01000000, 0x00000008 | 0x00000200): + try: return subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=log, stderr=log, close_fds=True, env=env, creationflags=flags) + except OSError: continue + return subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=log, stderr=log, close_fds=True, env=env) + + def _published(repo): """the main graph is out while the build goes on solving the repository's other languages: the answer is given now, and names on stderr (a `graph refresh:` line, which the MCP server carries into the answer) what it cannot see yet""" diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index 9e603a242..68d176734 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -13,7 +13,7 @@ FUNCTION_CALL_APPLY FUNCTION_CALL_CALL FUNCTION_CALL_BIND python SIMPLE_CALL METHOD_CALL SELF_CALL SUPER_CALL CHAINED_CALL SUBSCRIPT_CALL CONTEXT_MANAGER PROPERTY_READ METACLASS_CREATION DYNAMIC_CALL UNKNOWN_CALLEE_CALL DECORATOR_{APPLICATION,ATTRIBUTE,BARE,CALL} - csharp + boundary_generated known_implicit_ctor known_builtin_operator ambiguous_dynamic fan_capped + csharp + boundary_generated known_implicit_ctor known_builtin_operator ambiguous_dynamic fan_capped event_dispatch runtime_observed (only with a runtime trace) · new property_read property_write Two rules hold here, and they are the reason this module exists rather than a dict at the top of @@ -30,7 +30,7 @@ reaches B in 11 calls" is false when five of the eleven are containment. `calls_in()` is what a hop count is taken from. """ -import re +import collections, re # ── how certain a hop is. Lower is more certain; the reader prefers the lowest at every step. ────── TIER_RANK = { @@ -56,6 +56,7 @@ 'ambiguous_dynamic': 8, # a call through C# `dynamic`: undecidable from source by design 'ambiguous_unknown': 8, 'by-name': 9, # not resolved at all: the names simply match + 'stub': 9, # written inside a mock's stub or verification: named, never run (stub_sites) } UNRANKED = 10 # an engine tier this table has not been taught — least certain, never silent @@ -82,6 +83,7 @@ 'fan_capped': 'the candidate set was too large to enumerate — a sample, not the set', 'written': 'the call is written at that line', 'by-name': 'unresolved — the names match and nothing more', + 'stub': 'written inside a mock\'s stub or verification; the real method does not run there', } # ── what the hop IS, in one word that means the same thing in every language ─────────────────────── @@ -160,6 +162,7 @@ def legend(tiers): 'callback_registered': 'registered', 'event_dispatch': 'registered', 'ambient_terminal': 'registered', 'dynamic_terminal': 'registered', 'intrinsic_terminal': 'registered', 'fan_capped': 'capped set', + 'stub': 'stubs it', # a call inside a mock's stub or verification (stub_sites below): named, never run } DIRECT_CERT_DEFAULT = 'registered' # unlisted: an edge the engine asserted and this table cannot name — never `resolved` @@ -167,19 +170,87 @@ def legend(tiers): DIRECT_WHY = { 'registered': 'handed over as a value — the engine recorded the hand-off, not a call site', 'capped set': 'calls it, as one of a candidate set too large to enumerate — this is a sample of that set', + 'stubs it': 'stubs it on a mock: the real method does not run there, and the test breaks only if the name or parameters change', +} +# …and where the TIER says something more specific than its certainty. A request or event is not handed over as a +# value (a JavaScript callback's wording): the dependent sends it, and a framework runs the handler for what is sent. +TIER_WHY = { + 'event_dispatch': 'sends the request or event this handles — a framework runs it for what is sent here, no call site names it', +} + + +def direct_why(tier): + """what a DIRECT dependent row says for an edge of this tier.""" + return TIER_WHY.get(tier) or DIRECT_WHY.get(direct_cert(tier), 'calls it') + + +# …and a call written against a declaration this one is override-equivalent to (#1542): the caller names the interface +# or base method, and the declaration asked about is what runs there. Worded by the kind of type declaring the base. +# The service-layer shape (a controller holding an interface-typed field, the implementation behind it) is the common +# one; before this row its callers showed only under "reaches those", and an agent read that as "few callers". +VIA_BASE_WHY = { + 'interface': 'calls it (via the interface)', + 'class': 'calls it (via the base class)', +} +BODILESS_BASE = {'interface', 'protocol', 'trait'} # a base of this kind cannot itself be what runs at the call + + +def via_base_why(base_kind): + """what a direct row says for a caller that reaches this declaration through a base declaration of this kind""" + return VIA_BASE_WHY['interface' if (base_kind or '').lower() in BODILESS_BASE else 'class'] + + +# ── entry points: what the reason token means, said in words ─────────────────────────────────────────────── +# `entry_points.reason` is an engine token (`orm_hook`, `bean_ctor`, `framework_hook`). Printed raw inside a fixed +# sentence it read "is a orm_hook entry point … Changing it changes what the outside world can call" for a model +# configuration callback the ORM runs at startup, which no outside caller ever reaches. Two kinds, one table: +# OUTSIDE a request, a process start, a remote client, a command line or a message reaches it: changing it +# changes what the outside world can call, and the sentence says so (unchanged). +# CALLBACK the framework calls it back (a hook, a lifecycle method, a constructor it runs, a factory, a fixture, a +# provider): a change breaks that framework contract, not an outside caller. +# Every surface that words an entry point (impact's entry line and `next:`, path) reads this table. A token it has not +# seen is worded "framework-called ()", never printed bare, and is a callback: the weaker claim. +ENTRY = { + 'http': ('an HTTP route handler', 'outside'), 'url': ('a URL route handler', 'outside'), + 'main': ('a program entry point', 'outside'), 'grpc_service': ('a gRPC service method', 'outside'), + 'hub': ('a real-time hub method', 'outside'), 'cli': ('a command-line command', 'outside'), + 'queue': ('a message consumer', 'outside'), 'task': ('a background task a queue runs', 'outside'), + 'web_filter': ('a web request filter', 'outside'), + 'package_export': ('an export of the package', 'outside'), + 'exported_from_entry_module': ('an export of the entry module', 'outside'), + 'unimported_module': ('a module run directly, which nothing imports', 'outside'), + 'framework_hook': ('a framework hook', 'callback'), 'orm_hook': ('a model hook the ORM or validation library runs', 'callback'), + 'lifecycle': ('a lifecycle callback', 'callback'), 'bean_ctor': ('a constructor the container runs to build a bean', 'callback'), + 'factory': ('a factory method the container calls', 'callback'), 'fixture': ('a test fixture', 'callback'), + 'service_loader': ('a provider a service loader instantiates', 'callback'), + 'spring_factories': ('an auto-configuration class the container loads', 'callback'), + 'di_provider': ('a dependency-injection provider', 'callback'), 'signal_receiver': ('a signal receiver', 'callback'), + 'web_listener': ('a web container listener', 'callback'), 'scheduled': ('a scheduled job', 'callback'), + 'event_listener': ('an event listener', 'callback'), + 'test': ('a test', 'test'), } + +def entry_phrase(reason): + """'an ORM model hook' for `orm_hook`; an unknown token is 'a framework-called () method'""" + return ENTRY[reason][0] if reason in ENTRY else f"a framework-called ({reason}) method" + + +def entry_outside(reason): + """does the outside world (a request, a process start, a client, a message) call an entry point of this reason?""" + return ENTRY.get(reason, ('', 'callback'))[1] == 'outside' + # certainties that are backed by an edge the ENGINE asserted, as opposed to a name or a text match. # Consumers that used to test `cert == 'resolved'` to mean "this row claims an edge" test this # instead: the membership is exactly what it was before this table existed, so no row leaves any # set — only the label it is printed under changes. It matters most for the --delete verdict, where # dropping a hand-off would turn "something still holds this" into "safe to delete". -EDGE_BACKED = frozenset({'resolved', 'one of a set', 'registered', 'capped set'}) +EDGE_BACKED = frozenset({'resolved', 'one of a set', 'registered', 'capped set', 'stubs it'}) # most certain first. A caller with several call sites to the same callee can hold sites of different # tiers; a summary that names the caller once takes the best of them, which is the honest reading of # "at least one resolved call exists here". -DIRECT_ORDER = ('resolved', 'one of a set', 'registered', 'capped set') +DIRECT_ORDER = ('resolved', 'one of a set', 'registered', 'capped set', 'stubs it') # ── `defines`: a callable written inside another one's body ──────────────────────────────────────────────── @@ -201,6 +272,11 @@ def legend(tiers): # whose sites (the `new Runnable() {…}` itself, or the call a lambda is passed to) spans a site of the anon's own. # The narrowest such site wins, so an anon inside an anon nests under the inner method, not the outer one. An anon # that makes no call has no site to place it by and keeps the stack's answer. +# · a front end whose qualified names follow the source's own nesting (JavaScript: `keys.outer.` +# is written inside `keys.outer`, `keys.` beside it) says outright which callable on a line holds an +# anonymous one, with or without a call site: on an equal span it nests only under a callable whose qualified name +# its own extends. `function first() {…} const xs = [1].map(function (v) {…});` are siblings (#1598). Java, C# and +# TypeScript name a lambda or an anonymous class after its type, not its method, so there the name says nothing. # Only these scopes are anonymous. A named constructor or initializer (``, ``, # ``, ``, ``, ``) is a sibling of the methods written beside it. _ANON_SCOPE = re.compile(r'(?:^|\.)<(?:anon[ >]|lambda>|arrow>|function-expression>|locals>)') @@ -212,6 +288,31 @@ def anon_depth(display): return len(_ANON_SCOPE.findall(display or '')) +# A class's synthesized initializer spans the fields it runs, and a method written between two of them lies inside +# that span without being written inside any initializer (#1663): it is the initializer's sibling, and its display +# says so, the same owner and no scope of the initializer's in between. What an initializer does define (an arrow or +# a function expression handed to a call in it) has a display that is not the initializer's sibling. +_NAMED_INIT = re.compile(r'(?:^|\.)<(?:static-init|instance-init|clinit|classbody)>$') + + +def _sibling_of_init(display, init_display): + """`Type.m` beside `Type.`: a member of the initializer's owner, not a callable written inside it""" + if not init_display or not _NAMED_INIT.search(init_display) or not display or anon_depth(display): return False + owner = init_display.rsplit('.', 1)[0] if '.' in init_display else '' + return bool(owner) and display.rsplit('.', 1)[0] == owner and '.' in display + + +def init_holds(init_display, init_qname, display, qname): + """a named initializer holds a callable on its own line span: `static cfg = make({ run() {…} });` gives the + initializer and `run` the same one line, and line spans alone call them siblings. The qualified name says `run` + is written under the initializer's owner (`src/a.ObjLit.run`), and the display that it is not a member of it + (`run`, `ObjLit..m`), so the initializer defines it. A function written after a one-line class + has no such qualified name, and a method of the class is the initializer's sibling.""" + if not (init_display and _NAMED_INIT.search(init_display) and init_qname and qname and display): return False + if '.' not in init_qname or _sibling_of_init(display, init_display): return False + return qname.startswith(init_qname.rsplit('.', 1)[0] + '.') and qname != init_qname + + def _within(inner, outer): """a call site's (line, col, end_line, end_col) lies inside another's and is not the same one""" return inner != outer and outer[:2] <= inner[:2] and inner[2:] <= outer[2:] @@ -227,22 +328,40 @@ def sites(ids): return sites +_LEXICAL_IDS = ('JS_METHOD_',) # front ends whose qualified name of a callable extends the one it is written in + + def defines_edges(callables, sites=None): - """(definer, defined, 'defines') for callables given as (file, line, end_line, id, display, method_id). + """(definer, defined, 'defines') for callables given as (file, line, end_line, id, display, method_id[, qualified_name]). `sites(method_ids)` returns (caller_id, line, col, end_line, end_col) for the call sites of those callables; it is asked only about lines where more than one callable could have defined an anonymous one.""" - byfile = {}; mid_of = {} - for f, ln, en, i, disp, mid in callables: + byfile = {}; mid_of = {}; lex = {}; disp_of = {}; qn_of = {} + for f, ln, en, i, disp, mid, *qn in callables: if not (ln and en) or str(mid or i).startswith('generated:'): continue - byfile.setdefault(f, []).append((ln, -en, anon_depth(disp), i)); mid_of[i] = mid or i + byfile.setdefault(f, []).append((ln, -en, anon_depth(disp), i)); mid_of[i] = mid or i; disp_of[i] = disp + qn_of[i] = qn[0] if qn else None + if qn and qn[0] and str(mid or i).startswith(_LEXICAL_IDS): lex[i] = qn[0] + holds = lambda c, x: init_holds(disp_of[c], qn_of[c], disp_of[x], qn_of[x]) + def may_hold(c, x): + """on an equal span: c may be x's definer unless both names follow the nesting and x's does not extend c's, or + c is a class's initializer holding x (a member of an object or class written in it: `src/a.C.run`)""" + return c not in lex or x not in lex or lex[x].startswith(lex[c] + '.') or holds(c, x) parent = {}; groups = [] for f, rows in byfile.items(): - rows.sort(); st = [] + # at an equal span a named initializer comes first, so what it holds on its line finds it on the stack + rows.sort(key=lambda r: (r[0], r[1], r[2], not _NAMED_INIT.search(disp_of[r[3]] or ''), r[3])); st = [] for ln, neg, d, i in rows: # pop what ends before this one ends (END against END, not against this one's start: the two agree on nested - # spans and not on overlapping ones), and a sibling: the same span at the same depth - while st and (st[-1][1] < -neg or (st[-1][0] == ln and st[-1][1] == -neg and st[-1][2] >= d)): st.pop() - if st and st[-1][3] != i: parent[f, i] = st[-1][3] + # spans and not on overlapping ones), and a sibling: the same span at the same depth, unless it is an + # initializer holding this one on its line (init_holds) + while st and (st[-1][1] < -neg or (st[-1][0] == ln and st[-1][1] == -neg and st[-1][2] >= d + and not holds(st[-1][3], i))): st.pop() + # past an equal span the name says it is not written in (kept on the stack: a later one may be), and past a + # class's initializer when this is a member of that class beside it (#1663) + j = len(st) - 1 + while j >= 0 and (_sibling_of_init(disp_of[i], disp_of[st[j][3]]) + or (st[j][0] == ln and st[j][1] == -neg and not may_hold(st[j][3], i))): j -= 1 + if j >= 0 and st[j][3] != i: parent[f, i] = st[j][3] st.append((ln, -neg, d, i)) # equal spans holding an anonymous callable and more than one other candidate to have written it span = {} @@ -268,6 +387,140 @@ def defines_edges(callables, sites=None): return [(p, i, 'defines') for (_, i), p in parent.items()] +# ── a STUB SITE: a call written inside a mocking library's stub or verification ───────────────────────────── +# `when(repo.find(id)).thenReturn(x)`, `verify(repo).save(x)`, `mock.Setup(r => r.Find(id))`, +# `sub.Received().Save(x)`, `sub.Find(id).Returns(x)`. The engine resolves `repo.find` to the declared method, which +# is right about the NAME (a rename or a new parameter breaks the test) and wrong about EXECUTION: the receiver is a +# mock, so the real body never runs there. Walked as a call, every stub was a `[sound]` route from the test to the +# real method, and "which tests run this body" was answered with the tests that replace it. Measured on a Spring REST +# project: all 8 tests named for a service method stubbed it on a @MockBean, and none of them ran it. +# +# A site is a stub site by its POSITION against the wrapper's own call site (the parser records both spans), never by +# its own name, and only when the wrapper did NOT resolve to a client declaration: a project method called `when` or +# `Setup` is not the library's. Three shapes, one knob table per language: +# arg the stubbed call is the wrapper's ARGUMENT, the outermost call in it: `when(x.m(a()))` marks m and not a(), +# which runs for real to build the stub's argument; `mock.Setup(x => x.M())` marks M +# prefix the wrapper's RESULT is the stubbed call's receiver: `verify(x).m()`, `sub.Received(1).M()`. Mockito's +# `doReturn(v).when(x).m()` is this shape too, and `when` counts here only behind a do* call, because an HTTP +# test DSL writes a request as `given().when().get("/orders")` and that `get` is the request, not a stub +# recv the stubbed call is the wrapper's RECEIVER: NSubstitute's `sub.M(a).Returns(v)` +# Python has no row on purpose: unittest.mock replaces an attribute by STRING (`patch("pkg.mod.f")`, +# `patch.object(C, "m")`) and a MagicMock receiver is untyped, so no call on a mock resolves to a client method. +STUB_WRAPPERS = { + 'java': { + 'arg': {'when', 'given'}, # Mockito.when, BDDMockito.given + 'prefix': {'verify', 'should'}, # verify(x).m(), then(x).should().m() + 'prefix_after_do': {'when'}, # doReturn(v).when(x).m() + 'recv': set(), + 'lambda_arg': False, + }, + 'csharp': { + # Moq's mock.Setup(x => x.M()), FakeItEasy's A.CallTo(() => f.M()). The stubbed call is written in a LAMBDA + # (lambda_arg below): a snapshot library's `Verify(await svc.Get())` runs Get for real, and a BDD runner's + # `.When(x => GetOrders())` runs its lambda, so `When` is not in the table at all + 'arg': {'Setup', 'SetupGet', 'SetupSet', 'SetupSequence', 'SetupProperty', 'Verify', 'VerifyGet', 'VerifySet', + 'CallTo', 'CallToSet'}, + 'lambda_arg': True, + 'prefix': {'Received', 'DidNotReceive', 'ReceivedWithAnyArgs', 'DidNotReceiveWithAnyArgs'}, # NSubstitute + 'prefix_after_do': set(), + 'recv': {'Returns', 'ReturnsForAnyArgs', 'ReturnsNull', 'ReturnsNullForAnyArgs', 'Throws', 'ThrowsAsync', + 'ThrowsForAnyArgs', 'ThrowsAsyncForAnyArgs'}, # NSubstitute + }, +} +STUB_DO = {'doReturn', 'doThrow', 'doNothing', 'doAnswer', 'doCallRealMethod'} +STUB_LANG = {'java': 'java', 'kt': 'java', 'groovy': 'java', 'cs': 'csharp'} +STUB_TIER = 'stub' + + +def _short(name): + """a call site's callee as written, reduced to its simple name: `Mockito.when` -> when, `Substitute.For` -> For""" + n = re.sub(r'<.*$', '', (name or '').replace('()', '')).strip() + return n.rsplit('.', 1)[-1] + + +def _stub_lang(f): + return STUB_LANG.get((f or '').rsplit('.', 1)[-1]) if '.' in (f or '') else None + + +def stub_sites(q): + """the ids of the call sites written inside a mocking library's stub or verification (STUB_WRAPPERS above). + q(sql, params) -> rows. Only the files holding a wrapper-named call site are read.""" + names = {n for lang in STUB_WRAPPERS.values() for k in ('arg', 'prefix', 'prefix_after_do', 'recv') for n in lang[k]} + files = sorted({f for f, n in q("SELECT DISTINCT file_path, callee_name FROM call_sites WHERE callee_name IS NOT NULL", ()) + if _stub_lang(f) and _short(n) in names}) + out = set() + for f in files: + lang = STUB_WRAPPERS[_stub_lang(f)] + rows = [(r[0], _short(r[1]), (r[2], r[3], r[4], r[5]), bool(r[6]), bool(r[7])) for r in q( + """SELECT s.id, s.callee_name, s.start_line, s.start_column, s.end_line, s.end_column, + EXISTS (SELECT 1 FROM call_edges e WHERE e.call_site_id = s.id AND e.callee_provenance = 'client' + AND e.callee_method_id IS NOT NULL), + EXISTS (SELECT 1 FROM symbols y WHERE y.id = s.caller_id AND y.display LIKE '%%') + FROM call_sites s WHERE s.file_path = ?""", (f,)) if None not in r[2:6]] + spans = [(i, sp) for i, _, sp, _, _ in rows] + in_lambda = {i for i, _, _, _, lam in rows if lam} + wraps = [(n, sp) for _, n, sp, client, _ in rows if not client] # the LIBRARY's wrapper, never a project method + def outermost(cands): # the candidates no other candidate encloses + return {i for i, sp in cands if not any(_within(sp, o) for _, o in cands)} + def nearest(cands): # the candidates enclosing no other candidate + return {i for i, sp in cands if not any(_within(o, sp) for _, o in cands)} + for n, w in wraps: + if n in lang['arg']: + # inside the wrapper's span and NOT inside its receiver chain, which starts where the wrapper starts: + # `given().body(new HashMap<>() {…}).when().put("/orders/1")` holds the HashMap inside `when`'s span, as + # the receiver's argument, not as when's + recv = [sp for _, sp in spans if _within(sp, w) and sp[:2] == w[:2]] + out |= outermost([(i, sp) for i, sp in spans if _within(sp, w) and sp[:2] > w[:2] + and not any(sp == r or _within(sp, r) for r in recv) + and (i in in_lambda or not lang['lambda_arg'])]) + if n in lang['prefix'] or (n in lang['prefix_after_do'] and + any(x in STUB_DO and _within(sp, w) and sp[:2] == w[:2] for x, sp in wraps)): + out |= nearest([(i, sp) for i, sp in spans if _within(w, sp) and sp[:2] == w[:2]]) + if n in lang['recv']: + out |= outermost([(i, sp) for i, sp in spans if _within(sp, w) and sp[:2] == w[:2]]) + return out + + +# ── a MOCKED TYPE: a test class that holds a mock of T never runs T's methods ──────────────────────────────────── +# `@MockBean OrderService orders` in a web test replaces the bean the controller is handed, so a request the test sends +# reaches the controller and stops at the mock: a change to OrderService's body cannot fail that test, however the +# route to it is drawn. Read off the test class's FIELDS (and those of the classes it extends): a mock-making +# annotation on the field, a Moq `Mock` field type, or a mock factory written in the field's initializer. A spy +# (@Spy, @SpyBean, Substitute.ForPartsOf) runs the real methods and is not a mock here; neither is @InjectMocks, the +# object under test. A mock held in a LOCAL is not read (no fact records a local's initializer), so a test that builds +# its mocks inside the method keeps its routes. +MOCK_FIELD_DECOR = {'Mock', 'MockBean', 'MockitoBean'} +MOCK_FACTORY = {'mock', 'For', 'Fake'} # Mockito.mock(T.class), Substitute.For(), A.Fake() +_MOCK_GENERIC = re.compile(r'^(?:\w+\.)*Mock<\s*([\w.]+)') + + +def _simple_type(t): + return re.sub(r'<.*$', '', (t or '').strip()).split('.')[-1] + + +def mocked_types(q, line_of=None): + """{test type simple name: {mocked type simple name}} from the fields a test class declares. q(sql, params); + line_of(file, line) -> the source line, read only where the parser kept `Mock` and dropped its type argument (C#).""" + try: + fields = q("SELECT id, type_name, owner_qualified_name, file_path, start_line FROM fields WHERE owner_qualified_name IS NOT NULL", ()) + except Exception: + return {} + dec = collections.defaultdict(set) + for oid, name in q("SELECT owner_id, name FROM decorations WHERE owner_id IS NOT NULL", ()): + dec[oid].add((name or '').split('.')[-1]) + fac = collections.defaultdict(set) + for f, l, n in q("SELECT file_path, start_line, callee_name FROM call_sites WHERE callee_name IS NOT NULL", ()): + if _short(n) in MOCK_FACTORY: fac[(f, l)].add(_short(n)) + out = collections.defaultdict(set) + for fid, tn, owner, f, l in fields: + m = _MOCK_GENERIC.match(tn or '') + if not m and _simple_type(tn) == 'Mock' and line_of and f and l: + m = re.search(r'\bMock<\s*([\w.]+)', line_of(f, l) or '') + t = m.group(1).split('.')[-1] if m else (_simple_type(tn) if (dec.get(fid, set()) & MOCK_FIELD_DECOR or fac.get((f, l))) else None) + if t: out[owner.split('.')[-1]].add(t) + return dict(out) + + def best_cert(certs): return min(certs, key=lambda c: DIRECT_ORDER.index(c) if c in DIRECT_ORDER else len(DIRECT_ORDER)) @@ -275,3 +528,12 @@ def best_cert(certs): def direct_cert(tier): """how sure a DIRECT dependent row is, from the edge's tier.""" return DIRECT_CERT.get(tier, DIRECT_CERT_DEFAULT) + + +def site_lines(start, end, cap=40): + """every line a call site spans. A chained call (`router\\n .route('/')\\n .post(h)`) records its edges on the + statement's first line while an argument sits on a later one. Capped: a site spanning a whole callback body is + not a hand-off on every line of it.""" + start = start or 0 + if not end or end <= start or end - start > cap: return [start] + return list(range(start, end + 1)) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index 74360263b..a46d39e45 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -17,7 +17,7 @@ every row that lies in a file changed since marked (`query`); they wait only when the answer touches such a file and the refresh is expected within a small budget, or when asked (--fresh). - ax_fresh.py snapshot print the file table (JSON) — axiomcode-build stores it + ax_fresh.py snapshot print the file table (JSON), with what built the graph — axiomcode-build stores it ax_fresh.py status [--json] fresh | stale (+ the changed files) | building | no graph ax_fresh.py kick start the worker if the graph is stale; never waits ax_fresh.py wait [] kick, then wait up to for a fresh graph @@ -27,6 +27,7 @@ ax_fresh.py worker the worker itself (what kick detaches) ax_fresh.py lock take the build lock on an fd the calling shell holds open ax_fresh.py count the source files of each language, walked as the parser walks + ax_fresh.py chosen the --lang and --src an explicit index chose, which a rebuild keeps Environment: AXIOMCODE_NO_REFRESH=1 turns every trigger off; AXIOMCODE_REFRESH_DEBOUNCE (seconds, default 2) is the quiet window; AXIOMCODE_FRESH_WAIT (seconds, default 30) is the most a query whose answer touches an edited file @@ -240,6 +241,152 @@ def changes(repo, table=None): if p.endswith(SOURCE[l]) and rel not in old: added.append(rel); break return sorted(changed), sorted(added), sorted(set(old) - seen) +# ── WHAT BUILT THE GRAPH ─────────────────────────────────────────────────────────────────────────────────────────── +# A graph is current when the files it was built from are unchanged AND the axiomcode that built it is the one that +# would build it now. Only the first was checked: after a plugin or engine update, `index` said "graph up to date" and +# every query answered from a graph the previous engine had solved, with the previous rules, until someone deleted +# .axiomcode by hand. So the file table also records what built it (`built_by`), and a difference is a stale graph: +# engine a hash of what the engine builds a graph with (its parser, rules, pipeline, launcher), and its version +# rules a hash of this plugin's code that shapes the graph and its answers: the query rules (dl/*.dl) and +# axiomcode-index, which writes the symbols every verb reads +# impact IMPACT_VERSION, the version of the facts `impact` exports from the graph +# The engine is found the way axiomcode-build finds it (AXIOMCODE_ENGINE, the checkout this plugin sits in, the engine +# that built this graph, an `axiomcode` on PATH); when none of those is a built engine it is not compared, since +# `npm root -g` is too slow to ask before every query. Only content counts: an install moved to another directory +# with the same bytes is the same engine. +ENGINE_TREES = ('bin', 'dist', 'graph', os.path.join('parser', 'dist')) +ENGINE_FILES = ('package.json', os.path.join('parser', 'package.json')) +ENGINE_SKIP_DIRS = frozenset({'test', 'node_modules', '__pycache__', '.cache'}) +ENGINE_SKIP_EXT = ('.map', '.md', '.d.ts', '.tsbuildinfo', '.pyc') + +def engine_ok(d): + return bool(d) and os.path.isfile(os.path.join(d, 'bin', 'axiomcode')) and os.path.isdir(os.path.join(d, 'graph')) \ + and os.path.isfile(os.path.join(d, 'package.json')) + +def engine_built(d): + return engine_ok(d) and os.path.isfile(os.path.join(d, 'parser', 'dist', 'index.js')) + +def current_engine(repo): + """the engine the next build of repo would use (axiomcode-build's order, without its last resort, npm root -g), or + None when none of those is an engine""" + e = os.environ.get('AXIOMCODE_ENGINE') + if e and engine_ok(e): return e + w = H + while os.path.dirname(w) != w and not os.path.isfile(os.path.join(w, 'bin', 'axiomcode')): w = os.path.dirname(w) + if engine_built(w): return w + try: rec = open(os.path.join(repo, '.axiomcode', 'engine')).read().strip() + except OSError: rec = '' + if engine_built(rec): return rec + import shutil + p = shutil.which('axiomcode') + if p: + d = os.path.dirname(os.path.dirname(os.path.realpath(p))) + if engine_built(d): return d + d = os.path.join(os.path.dirname(p), 'node_modules', '@axiomcode', 'code-graph') + if engine_built(d): return d + return None + +def _engine_files(d): + for t in ENGINE_TREES: + for root, subdirs, files in os.walk(os.path.join(d, t)): + subdirs[:] = sorted(s for s in subdirs if s not in ENGINE_SKIP_DIRS and not s.startswith('.')) + for f in sorted(files): + if not f.endswith(ENGINE_SKIP_EXT): yield os.path.join(root, f) + for f in ENGINE_FILES: + if os.path.isfile(os.path.join(d, f)): yield os.path.join(d, f) + +_ENGINE_MEMO = {} + +def engine_id(d): + """(version, content hash, stat signature) of the engine at d. The stat signature (every file's size and mtime) is + what is compared first; the content hash, which reads every file, only when the signature differs, and it is kept + for that signature for the life of the process""" + stats = [] + for p in _engine_files(d): + try: st = os.stat(p); stats.append((os.path.relpath(p, d), st.st_size, st.st_mtime_ns)) + except OSError: pass + sig = hashlib.sha1(json.dumps(stats).encode()).hexdigest() + try: version = json.load(open(os.path.join(d, 'package.json'))).get('version', '?') + except (OSError, ValueError): version = '?' + return version, (lambda: _engine_hash(d, sig, stats)), sig + +def _engine_hash(d, sig, stats): + k = (os.path.realpath(d), sig) + if k not in _ENGINE_MEMO: + h = hashlib.sha1() + for rel, _, _ in stats: + h.update(rel.replace(os.sep, '/').encode() + b'\0') + try: h.update(digest(os.path.join(d, rel)).encode()) + except OSError: pass + _ENGINE_MEMO[k] = h.hexdigest() + return _ENGINE_MEMO[k] + +IMPACT_VERSION_RE = re.compile(r"^\s*IMPACT_VERSION\s*=\s*'([^']*)'", re.M) + +def plugin_id(): + """(rules hash, IMPACT_VERSION) of the plugin this script belongs to""" + h = hashlib.sha1() + dl = os.path.join(H, 'dl') + try: names = sorted(f for f in os.listdir(dl) if f.endswith('.dl')) + except OSError: names = [] + for f in names + ['axiomcode-index']: + p = os.path.join(dl, f) if f.endswith('.dl') else os.path.join(H, f) + try: h.update(f.encode() + b'\0' + digest(p).encode()) + except OSError: pass + try: m = IMPACT_VERSION_RE.search(open(os.path.join(H, 'axiomcode-impact'), errors='replace').read()) + except OSError: m = None + return h.hexdigest(), (m.group(1) if m else '?') + +def built_by(engine): + """what a build with `engine` records in its file table""" + rules, impact = plugin_id() + d = dict(rules=rules, impact=impact) + if engine and engine_ok(engine): + version, content, sig = engine_id(engine) + d.update(engine=engine, engine_version=version, engine_hash=content(), engine_stat=sig) + return d + +def _label(version, h): return f"{version} {h[:8]}" if h else version + +def engine_change(repo, t=None): + """'' when the graph was built by the axiomcode that would build it now, else what differs, written + "graph built by an older axiomcode ( -> )". A table from before this was recorded differs. A graph + placed by AXIOMCODE_GRAPH is not this repository's build, and AXIOMCODE_NO_ENGINE_CHECK=1 turns the check off""" + if os.environ.get('AXIOMCODE_GRAPH') or os.environ.get('AXIOMCODE_NO_ENGINE_CHECK'): return '' + t = t if t is not None else load_table(repo) + if not t: return '' + old = t.get('built_by') + rules, impact = plugin_id() + eng = current_engine(repo) + now_e = engine_id(eng) if eng else None + if not isinstance(old, dict): + new = f"{now_e[0]} {now_e[1]()[:8]}" if now_e else f"IMPACT_VERSION {impact}" + return f"graph built by an older axiomcode (one that did not record its engine -> {new})" + diff = [] + if now_e and old.get('engine_hash') and old.get('engine_stat') != now_e[2]: + h = _seen_hash(repo, now_e) + if h != old['engine_hash']: + diff.append(f"engine {_label(old.get('engine_version', '?'), old['engine_hash'])} -> {_label(now_e[0], h)}") + elif now_e and not old.get('engine_hash'): + diff.append(f"engine unrecorded -> {_label(now_e[0], _seen_hash(repo, now_e))}") + if old.get('rules') != rules: diff.append(f"rules {(old.get('rules') or 'unrecorded')[:8]} -> {rules[:8]}") + if str(old.get('impact')) != impact: diff.append(f"IMPACT_VERSION {old.get('impact') or 'unrecorded'} -> {impact}") + return f"graph built by an older axiomcode ({'; '.join(diff)})" if diff else '' + +def _seen_hash(repo, e): + """the content hash of engine e: an engine whose files moved (a reinstall, a new checkout) but whose bytes did not + is hashed once, and its hash kept beside the graph for its stat signature, so later queries only stat it""" + p = os.path.join(out_dir(repo), 'engine-seen.json') + try: seen = json.load(open(p)) + except (OSError, ValueError): seen = {} + if e[2] in seen: return seen[e[2]] + h = e[1]() + seen = {e[2]: h} # the latest one only: this is a cache, not a history + tmp = p + f'.{os.getpid()}' + try: json.dump(seen, open(tmp, 'w')); os.replace(tmp, p) + except OSError: pass + return h + def read_state(repo): try: return json.load(open(state_path(repo))) except (OSError, ValueError): return {} @@ -260,6 +407,37 @@ def has_graph(repo): def graph_broken(repo): return has_graph(repo) and not os.path.exists(os.path.join(out_dir(repo), 'graph.sqlite')) +def relink(repo, locked=False, say=True): + """graph.sqlite points into THIS repository's .axiomcode/out, by a name relative to it (#1605). A build before this + wrote an absolute pointer, which a copy of the repository kept: the copy answered from the original's graph and never + saw its own edits, and once the original moved it found its pointer dangling and rebuilt itself as "a build was + interrupted". A pointer that leaves this .axiomcode/out is re-pointed at the same graph here (the copy's own), and + says which case it was; one with no graph here to take is removed, so the query builds one. Returns what it did.""" + out = out_dir(repo); p = os.path.join(out, 'graph.sqlite') + if not os.path.islink(p) or (not locked and building(repo)): return '' # a running build owns the pointer + try: t = os.readlink(p) + except OSError: return '' + if not os.path.isabs(t): return '' # relative: this out/'s own; dangling, it is an interrupted build to repair + parts = t.replace('\\', '/').rsplit('/.axiomcode/out/', 1) + tail = parts[1] if len(parts) == 2 and not os.path.normpath(parts[1]).startswith('..') else '' + own = os.path.join(out, tail) if tail else '' + there = parts[0] + '/.axiomcode/out' if tail else '' + # an older build's pointer into this very out/ stays this graph, dangling or not (a dangling one is a repair) + ours = bool(there) and os.path.isdir(there) and os.path.samefile(there, out) + try: + if ours or own and os.path.exists(own): + tmp = os.path.join(out, '.graph.sqlite.relink') + if os.path.lexists(tmp): os.remove(tmp) + os.symlink(tail, tmp); os.replace(tmp, p) + else: os.remove(p) + except OSError: return '' + if ours: return 'relative' + msg = (f"graph.sqlite pointed at another checkout's graph ({t})" + (" — this repository was copied from it" if os.path.exists(t) + else ", which is gone — this repository was moved or copied from it") + + (f"; re-pointed at its own graph (.axiomcode/out/{tail})" if own and os.path.exists(own) else "; this checkout has no graph of its own there, so one is built")) + if say: print(msg, file=sys.stderr) + return msg + def graph_lang(): """the language whose graph a verb reads when it is not the main one (AXIOMCODE_GRAPH_LANG, set by the dispatcher as it asks each language's graph in turn), else ''""" @@ -309,6 +487,18 @@ def legacy_params(repo): src = os.path.relpath(os.path.realpath(run.get('source_dir') or repo), os.path.realpath(repo)) return dict(lang=run.get('language', ''), src_arg='' if src == '.' or src.startswith('..') else src, library=run.get('library_roots') or '') +def rebuild_env(t, **extra): + """the environment that rebuilds the graph a file table `t` describes, with the language, --src and --library it + was built with: what the background refresh runs, and what `axiomcode graph` runs when the graph is stale. Languages + DETECTED are detected again (a language the repository gained since gets its graph); only a --lang the user gave is + kept. A table from before this was recorded counts as detected: it was built with one language even where the + repository had several, and keeping that would keep the others out for good.""" + env = dict(os.environ, AXIOMCODE_LANG='' if t.get('lang_auto', True) else t.get('lang', ''), AXIOMCODE_SRC=t.get('src_arg', ''), **extra) + env.pop('AXIOMCODE_LIBRARY', None) + env.pop('AXIOMCODE_LANG_AUTO', None); env.pop('AXIOMCODE_GRAPH_LANG', None) + if t.get('library'): env['AXIOMCODE_LIBRARY'] = t['library'] + return env + def _flock(fd, block): """an advisory lock on fd; the OS releases it when the last holder of the file description exits, so a killed build never leaves a lock behind (no stale-lock timeout to guess)""" @@ -365,6 +555,15 @@ def pending(repo): old = [l for l in langs if os.path.isfile(os.path.join(repo, '.axiomcode', 'lang', l, 'out', 'graph.sqlite'))] return dict(pending=langs, pending_old=old, first=all(len(r) > 1 and r[1] == 'first' for r in rows)) +def unbuilt(repo): + """the languages a build that was stopped after publishing the main graph left unbuilt (axiomcode-build writes them + to out/partial), [] when none""" + try: return open(os.path.join(out_dir(repo), 'partial')).read().split() + except OSError: return [] + +def unbuilt_row(repo): + return f".axiomcode/out/partial (the {', '.join(unbuilt(repo))} graph was not built: its build was stopped)" + def status(repo): if not has_graph(repo): return dict(state='no graph') if graph_broken(repo): return dict(state='building' if building(repo) else 'stale', changed=['.axiomcode/out/graph.sqlite (the pointer to the graph is broken)'], added=[], removed=[]) @@ -373,18 +572,33 @@ def status(repo): if c is None: return dict(state='unknown', note='the graph predates the file table; the next `axiomcode index` records it') changed, added, removed = c busy = building(repo) - if not (changed or added or removed): + eng = engine_change(repo) + if not (changed or added or removed) and not eng: # a build that already published this tree's main graph and is still solving other languages: the graph a query # reads is current, and waiting would be waiting for other languages' compiles - return dict(state='building', **pending(repo)) if busy else dict(state='fresh') + if busy: return dict(state='building', **pending(repo)) + return dict(state='stale', changed=[unbuilt_row(repo)], added=[], removed=[]) if unbuilt(repo) else dict(state='fresh') d = dict(state='building' if busy else 'stale', changed=changed, added=added, removed=removed) - if not busy and st.get('failed_table') == change_key(c): d['failed'] = st.get('failed_log', ''); d['failed_reason'] = st.get('failed_reason', '') + if eng: d['engine'] = eng + if busy and not (changed or added or removed): d.update(pending(repo)) + if not busy and failed_on(st, c, eng): d['failed'] = st.get('failed_log', ''); d['failed_reason'] = st.get('failed_reason', '') return d def change_key(c): """identifies a change set, so a build that failed on it is not retried until the files move again""" return hashlib.sha1(json.dumps(c).encode()).hexdigest() +def failed_on(st, c, engine=''): + """the last rebuild failed on exactly this change set and, when the graph is behind the installed axiomcode, on + the same difference: it is not retried until the files move or another axiomcode is installed. The difference is + compared only when there is one: a failed rebuild seen by a caller whose engine matches the graph (a shell without + the AXIOMCODE_ENGINE the refresher ran with) is still the failed rebuild of these files""" + return st.get('failed_table') == change_key(c) and (not engine or st.get('failed_engine', '') == engine) + +def behind(s): + """the graph is behind: files edited since it was built, or built by another axiomcode""" + return bool(edited(s) or s.get('engine')) + def enabled(repo): return not os.environ.get('AXIOMCODE_NO_REFRESH') and not os.environ.get('AXIOMCODE_GRAPH') and has_graph(repo) @@ -423,7 +637,7 @@ def worker(repo): lock = os.path.join(repo, '.axiomcode', 'refresh.lock') fd = os.open(lock, os.O_RDWR | os.O_CREAT, 0o644) if not _flock(fd, False): return 0 # single flight - debounce = float(os.environ.get('AXIOMCODE_REFRESH_DEBOUNCE') or 2); legacy_done = False + debounce = float(os.environ.get('AXIOMCODE_REFRESH_DEBOUNCE') or 2); legacy_done = False; engine_done = ''; eng = '' try: for _ in range(8): # bounded: a tree rewritten faster than it builds must not spin forever time.sleep(debounce) @@ -438,33 +652,34 @@ def worker(repo): else: c = changes(repo, t) if graph_broken(repo) and not (c and any(c)): c = [['.axiomcode/out/graph.sqlite (broken pointer)'], [], []] - if (c is None or not any(c)) and not base_moved(repo): + elif unbuilt(repo) and not (c and any(c)): c = [[unbuilt_row(repo)], [], []] + # BUILT BY ANOTHER AXIOMCODE: rebuilt with no file changed, once per worker. A rebuild that leaves the same + # difference behind (a build that could not record what built it) is not repeated + eng = engine_change(repo, t) + if eng and eng == engine_done: eng = '' + if (c is None or not any(c)) and not eng and not base_moved(repo): write_state(repo, state='fresh', checked=time.time(), checked_by=os.environ.get('AXIOMCODE_REFRESH_TRIGGER', '')); return 0 c = c or [[], [], []] st = read_state(repo) # a build that FAILED on exactly this tree is not retried on every trigger: the next edit retries it fp = change_key(c) - if st.get('failed_table') == fp: return 0 + if failed_on(st, c, eng): return 0 n = sum(len(x) for x in c) - why = (f"{n} file(s) changed" if n else f"HEAD moved to {(head(repo) or '')[:10]}") + f", found by {os.environ.get('AXIOMCODE_REFRESH_TRIGGER') or 'an edit'}" - # languages DETECTED are detected again (a language the repository gained since gets its graph); only a - # --lang the user gave is kept. A table from before this was recorded counts as detected: it was built with - # one language even where the repository had several, and keeping that would keep the others out for good - env = dict(os.environ, AXIOMCODE_LANG='' if t.get('lang_auto', True) else t.get('lang', ''), AXIOMCODE_SRC=t.get('src_arg', ''), - AXIOMCODE_BACKGROUND='1', AXIOMCODE_REFRESH_REASON=why) - env.pop('AXIOMCODE_LIBRARY', None) - env.pop('AXIOMCODE_LANG_AUTO', None); env.pop('AXIOMCODE_GRAPH_LANG', None) - if t.get('library'): env['AXIOMCODE_LIBRARY'] = t['library'] + why = (f"{n} file(s) changed" if n else eng if eng else f"HEAD moved to {(head(repo) or '')[:10]}") + \ + (f"; {eng}" if n and eng else '') + f", found by {os.environ.get('AXIOMCODE_REFRESH_TRIGGER') or 'an edit'}" + engine_done = eng + env = rebuild_env(t, AXIOMCODE_BACKGROUND='1', AXIOMCODE_REFRESH_REASON=why) t0 = time.time(); write_state(repo, state='building', started=t0, files=sum(len(x) for x in c)) if any(c): print(f"{time.strftime('%H:%M:%S')} refresh: {sum(len(x) for x in c)} file(s) changed ({', '.join((c[0] + c[1] + c[2])[:5])}) — rebuilding", flush=True) - else: print(f"{time.strftime('%H:%M:%S')} refresh: HEAD moved — moving the baseline to it", flush=True) + if eng: print(f"{time.strftime('%H:%M:%S')} refresh: {eng}; rebuilding", flush=True) + elif not any(c): print(f"{time.strftime('%H:%M:%S')} refresh: HEAD moved — moving the baseline to it", flush=True) # the worker is detached and has no console, so Windows would give the console program bash a new, visible # window for the length of every rebuild; CREATE_NO_WINDOW keeps it hidden r = subprocess.run([os.environ.get('AXIOMCODE_BASH') or 'bash', os.path.join(H, 'axiomcode-build'), repo], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **(dict(creationflags=0x08000000) if os.name == 'nt' else {})) took = round(time.time() - t0, 1) if r.returncode != 0: - write_state(repo, state='failed', finished=time.time(), seconds=took, failed_table=fp, + write_state(repo, state='failed', finished=time.time(), seconds=took, failed_table=fp, failed_engine=eng, failed_log=os.path.join(repo, '.axiomcode', 'refresh.log'), failed_reason=failure_reason(r.stdout)) print(f"refresh: build failed after {took}s; the previous graph is kept\n{r.stdout[-800:]}", flush=True) return 1 @@ -555,6 +770,13 @@ def note(s, marked=None, named=None, off=False): # the languages a running build has still to publish (#1555) get a line of their own, before any line about edits first = pending_note(s) if s.get('state') not in ('stale', 'building'): return first + if s.get('engine'): + # BUILT BY ANOTHER AXIOMCODE: every row may differ from what this version answers, so none is marked; the line says so + if off: says = "refresh is OFF (AXIOMCODE_NO_REFRESH is set), so nothing rebuilds it; `axiomcode index` does" + elif s.get('failed'): says = "the rebuild FAILED" + (f" — {s['failed_reason']}" if s.get('failed_reason') else '') + f" (see {s['failed']})" + else: says = "rebuilding in the background; --fresh waits for the rebuild" + first = (first + '\n' if first else '') + f"graph refresh: {s['engine']}; {says} — this answer is from that graph, " \ + "and may lack what the installed version finds" files = edited(s) if not files: return first first = first + '\n' if first else '' @@ -656,6 +878,7 @@ def mark_answer(out, stale, as_json): def query_names(verb, args, repo): """the names a query asks about (impact's targets, path's endpoints, context's --from), as written""" names, i, pos = [], 0, [] + if '--' in args: args = args[args.index('--') + 1:] # --grep runs the verb under ax_grep.py … --: not names while i < len(args): a = args[i] if a in _FLAG_VALUE: @@ -712,13 +935,26 @@ def expected_left(repo): if st.get('state') == 'building' and st.get('started'): return secs - (time.time() - st['started']) return secs + float(os.environ.get('AXIOMCODE_REFRESH_DEBOUNCE') or 2) -def wait_fresh(repo, seconds, say=False): +def _waits_on(s, files): + """the refresh has published the main graph and is still solving languages (#1555) of which one holds an edited file + the wait is for (`files`; True: any language at all): the graph that answers for that edit is not out yet""" + langs = set(s.get('pending') or ()) + if not langs or not files: return False + if files is True: return True + import ax_langs + return any(l in langs for f in files for l in ax_langs.BY_EXT.get(os.path.splitext(f)[1].lower(), ())) + +def wait_fresh(repo, seconds, say=False, files=()): """wait until the graph matches the files again (the refresh swapped in its graph), a rebuild fails, or `seconds` - pass. With `say`, a progress line on stderr every 10 s: a wait is never silent. True when the graph is current""" + pass. With `say`, a progress line on stderr every 10 s: a wait is never silent. True when the graph is current. + THE GRAPH OF THE EDIT, NOT THE MAIN ONE. A refresh publishes the main language's graph first and goes on with the + others (#1555); from then on the file table matches, and a wait that ended there answered a question about a Python + edit in a TypeScript repository from the previous Python graph: `nothing named` for the function just added. So a + wait for `files` goes on while a language that holds one of them is still being solved (files=True: any language).""" end = t0 = time.time(); end += seconds; last = t0 while True: s = status(repo) - if s['state'] in ('fresh', 'no graph', 'unknown') or not edited(s): return True + if (s['state'] in ('fresh', 'no graph', 'unknown') or not behind(s)) and not _waits_on(s, files): return True if s.get('failed'): return False if s['state'] == 'stale': kick(repo, 'a query') now = time.time() @@ -753,7 +989,7 @@ def with_note(n): # the answer as it is, then if s['state'] == 'unknown': if not off: kick(repo, 'a query') passthrough() - if s['state'] in ('fresh', 'no graph') or not edited(s): + if s['state'] in ('fresh', 'no graph') or not behind(s): # a build that published this tree's main graph and is solving the others (#1555): the answer is current and is # given now, and says which languages it cannot see yet if not pending_note(s): passthrough() @@ -762,9 +998,9 @@ def with_note(n): # the answer as it is, then as_json = '--json' in argv if fresh and not s.get('failed') and not off: left = expected_left(repo) - print(f"waiting for the graph to refresh (--fresh): {len(edited(s))} file(s) changed since the graph was built" + + print(f"waiting for the graph to refresh (--fresh): " + (f"{len(edited(s))} file(s) changed since the graph was built" if edited(s) else s.get('engine', '')) + (f", the last build took {int(build_seconds(repo)[0])} s" if left is not None else '') + " …", file=sys.stderr, flush=True) - if wait_fresh(repo, float(os.environ.get('AXIOMCODE_FRESH_MAX') or 600), say=True): passthrough() + if wait_fresh(repo, float(os.environ.get('AXIOMCODE_FRESH_MAX') or 600), say=True, files=True): passthrough() s = status(repo) stale = Stale(edited(s)); r = run() out = r.stdout.decode('utf-8', 'replace') @@ -778,7 +1014,7 @@ def with_note(n): # the answer as it is, then print(f"waiting for the graph to refresh: this answer touches {', '.join(stale.files[:3])}" + (' …' if len(stale.files) > 3 else '') + f", edited since the graph was built; up to {int(budget)} s" + (f" (the last build took {int(build_seconds(repo)[0])} s)" if left is not None else '') + " …", file=sys.stderr, flush=True) - if wait_fresh(repo, budget, say=True): passthrough() + if wait_fresh(repo, budget, say=True, files=stale.files): passthrough() s = status(repo); stale = Stale(edited(s)) if edited(s): r = run(); out = r.stdout.decode('utf-8', 'replace') @@ -792,6 +1028,7 @@ def with_note(n): # the answer as it is, then obj = json.loads(marked) if isinstance(obj, dict): obj['freshness'] = dict(state='off' if off else s['state'], edited=edited(s), rows_marked=n, + **({'built_by': s['engine']} if s.get('engine') else {}), **({'failed': s['failed']} if s.get('failed') else {}), **({'named_in_edits': [dict(name=a, file=b) for a, b in missed]} if missed else {})) marked = json.dumps(obj, indent=1, ensure_ascii=False) + '\n' @@ -807,11 +1044,16 @@ def main(argv): if cmd == 'lock': return 0 if _flock(int(argv[2]), '--try' not in argv) else 75 repo = os.path.realpath(argv[2]) if len(argv) > 2 else os.getcwd() + if cmd in ('query', 'baseline', 'relink'): relink(repo, locked='--locked' in argv[3:4]) # every query verb passes here + if cmd == 'relink': return 0 if cmd == 'snapshot': lang, src_arg = argv[3], (argv[4] if len(argv) > 4 else '') lib = os.environ.get('AXIOMCODE_LIBRARY', '') + # what built the graph (the engine axiomcode-build found, passed as AXIOMCODE_ENGINE), unless this table is taken + # for a graph an earlier build made (AXIOMCODE_BUILT_BY_UNKNOWN), which must not be credited to this one + by = {} if os.environ.get('AXIOMCODE_BUILT_BY_UNKNOWN') else dict(built_by=built_by(os.environ.get('AXIOMCODE_ENGINE') or current_engine(repo))) json.dump(dict(lang=lang, lang_auto=bool(os.environ.get('AXIOMCODE_LANG_AUTO')), src=src_arg.strip('/'), src_arg=src_arg, library=lib, built=time.time(), - files=snapshot(repo, lang, os.path.join(repo, src_arg))), sys.stdout); return 0 + files=snapshot(repo, lang, os.path.join(repo, src_arg)), **by), sys.stdout); return 0 if cmd == 'count': # the SOURCE files of each language under repo, walked as the refresher walks (the parser's skip list and git's # ignore rules): java typescript python javascript csharp. axiomcode-build picks the main language from these, and @@ -828,13 +1070,25 @@ def main(argv): subdirs[:] = [x for x in subdirs if not prunes('python', x, os.path.basename(d))] n += sum(1 for f in files if python_script(os.path.join(d, f), f)) print(n); return 0 + if cmd == 'chosen': + # the --lang (and --src) the graph here was indexed with, as "\t", when they were CHOSEN (an explicit + # --lang); nothing when its languages were detected, or there is no graph. axiomcode-build keeps them for a rebuild + # that names no language, so no rebuild path solves a language the index left out + t = load_table(repo) + if has_graph(repo) and t and t.get('lang_auto') is False and t.get('lang'): print(f"{t['lang']}\t{t.get('src_arg', '')}") + return 0 if cmd == 'uptodate': # exit 0 when the recorded table was built with this language / --src / --library and no file differs lang, src_arg, lib = argv[3], argv[4] if len(argv) > 4 else '', argv[5] if len(argv) > 5 else '' t = load_table(repo) if not t or t.get('lang') != lang or t.get('src_arg', '') != src_arg or (t.get('library') or 'nolib') != (lib or 'nolib'): return 1 c = changes(repo, t) - return 0 if c is not None and not any(c) else 1 + if c is None or any(c): return 1 + # the files are the graph's; the axiomcode that built it must be the one building now (axiomcode-build passes + # the engine it found as AXIOMCODE_ENGINE), or the graph is rebuilt, saying why + eng = engine_change(repo, t) + if eng: print(f"{eng}; rebuilding", flush=True); return 1 + return 0 if cmd == 'status': s = status(repo) if '--json' in argv: print(json.dumps(dict(s, **refreshed(repo)))) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py new file mode 100644 index 000000000..f8607426d --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +"""ax_grep.py [--limit N] -- — an answer as sites, one per line, the way grep prints them. + + path:line: [what the graph knows about it] + +An agent reads grep output without effort: one hit per line, the location first, the code it will act on after it. The +verbs' prose answers carry the same sites inside sections, headers and explanations, and when the answer is a LIST OF +SITES (who calls this, what reaches that, the hops of a chain, the tests to run) that prose is mostly the fan-out an +agent has to wade through. So the verb is run with --json, the same answer as a document, and its sites are printed: + + impact must-change-with-it, then direct dependents surest first, then what reaches them by hop, then the tests; + an `alongside` row (no call, no reference) and a text mention written as prose are counted, never listed + path every hop of each chain at the line the call is written on; `path '*' X` the declarations that reach X + context the call flow's steps (or, with no flow, the entry points and the declarations named per file), then the + text files that name them; in a repository in several languages, the graphs where the task's words land + test-impact the tests, nearest first, and the command that runs them + +The tag is the graph's knowledge, in its own words: [resolved] · [one of a set] · [registered] · [by name] · [text] · +hop N · test. The first --limit (default 30) sites are printed and the rest counted by kind; the last lines say whether +the answer was verified and what it cannot see. The verbose answer is the verb without --grep (MCP full=True). +A verb that refuses (no such name, no graph), or answers with no site at all (no chain, no dependent), is printed +exactly as the verb said it: there the reason is the answer. +""" +import json, os, re, subprocess, sys + +H = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, H) +import ax_edges + +CAP = 30 +TEXT_ROWS = 5 # rows of a name written in a non-source file: leads, so a few and a count +CODE_WIDTH = 140 +# a certainty word as the answers print it, keyed by the word the --json document carries +TAG = {'sound': 'resolved', 'entry': 'entry', None: 'resolved'} + + +class Code: + """the text of a line of a file under the repository, read once per file""" + def __init__(self, repo): + self.repo, self.files = repo, {} + + def line(self, at): + f, _, n = (at or '').rpartition(':') + if not f or not n.isdigit(): return None + if f not in self.files: + try: + with open(os.path.join(self.repo, f), encoding='utf-8', errors='replace') as h: self.files[f] = h.read().split('\n') + except OSError: self.files[f] = None + L = self.files[f]; i = int(n) + if not L or not 0 < i <= len(L): return None + s = L[i - 1].strip() + return s if len(s) <= CODE_WIDTH else s[:CODE_WIDTH - 1] + '…' + + +def site(code, at, tag, name=''): + """one row: path:line: code [tag]. With no readable line the declaration's name stands in for the code.""" + text = code.line(at) + if text is None: text = name or '' + # (not a `` / `` / ``: the line itself is all there is to say of those) + elif name and '<' not in name and not re.search(r'\b' + re.escape(name.split('.')[-1].split('(')[0]) + r'\b', text): + tag = f"{tag} · {name}" if tag else name # the line does not show whose it is (a decorator, a wrapped call) + return f"{at}: {text} [{tag}]" if tag else f"{at}: {text}" + + +def stale(r): + return ' · may be out of date' if isinstance(r, dict) and r.get('stale') else '' + + +def n_sites(r): + n = r.get('sites') or 1 + return f" ×{n}" if n > 1 else '' + + +def per_test_file(tests): + """the tests one row per test FILE, its nearest test first, with how many more that file holds. A test file is what + gets run, and a route through a module's import reaches every test in it: listed one by one, the 30 tests of one + file were the whole capped answer and pushed out everything the change reaches""" + first, n = {}, {} + for t in tests: + f = (t.get('at') or '').rpartition(':')[0] + n[f] = n.get(f, 0) + 1 + first.setdefault(f, t) + return [(t, n[f] - 1) for f, t in first.items()] + + +def more_in_file(k): + return f" · +{k} more in this file" if k else '' + + +# ── impact ─────────────────────────────────────────────────────────────────────────────────────────────────────────── +def impact(d, code): + rows, rest = [], {} + def more(k, n=1): rest[k] = rest.get(k, 0) + n + for r in d.get('contract', []): + rows.append(('contract', site(code, r['at'], f"must change · {r['why']}{stale(r)}", r['display']))) + seen = set() + for r in d.get('direct', []): + cert = r.get('certainty') or 'resolved' + if cert == 'alongside': more('alongside (no call, no reference)'); continue + seen.add(r['id']) + rows.append((cert, site(code, r['at'], f"{TAG.get(cert, cert)}{n_sites(r)}{stale(r)}", r['display']))) + tests = {t['id'] for t in d.get('tests', [])} + # a module's top level reaches it too, but its row is the file's first line (an import), which says nothing: those + # come after the tests, so a cap spends its lines on callables and on what to run + modules = [] + for r in d.get('reached', []): + if r['id'] in seen or r['id'] in tests: continue + mod = '' in (r.get('display') or '') + row = ('module' if mod else 'reached', site(code, r['at'], f"hop {r['hops']}" + (' · module scope' if mod else '') + + (' · test' if r.get('test') else '') + stale(r), r['display'])) + (modules if mod else rows).append(row) + for r, k in per_test_file(d.get('tests', [])): + rows.append(('test', site(code, r['at'], f"test · {TAG.get(r.get('certainty'), r.get('certainty'))} · hop {r['hops']}" + more_in_file(k) + stale(r), r['display']))) + for r in d.get('stub_tests', []): + rows.append(('stub', site(code, r['at'], f"test · stubs it{stale(r)}", r['display']))) + # a test file's top level is already its test row above + listed = {(r.get('at') or '').rpartition(':')[0] for r in d.get('tests', [])} + rows += [m for m in modules if m[1].split(':', 1)[0] not in listed] + # a name written in a non-source file is a lead, not a dependent: a few are listed, the rest counted. A common name + # (`main`, `run`) is written in hundreds of CI files and fixtures, and listed in full they were the whole answer + shown = 0 + for r in d.get('external', []): + if r.get('prose'): more('plain-word mentions'); continue + if shown >= TEXT_ROWS: more('[text]'); continue + rows.append(('text', site(code, r['at'], f"text · {r.get('how') or 'names it'}{stale(r)}"))); shown += 1 + foot = [] + nt = len(d.get('tests', [])) + if 'test_universe' in d: foot.append(f"tests: {nt} of {d['test_universe']} reach it" + ("; `axiomcode test-impact` runs them" if nt else '')) + foot.append(verified(d.get('verified'), d.get('checked_hops'))) + if d.get('unresolved_inside'): foot.append(f"bound: {d['unresolved_inside']} unresolved call(s) inside — a lower bound") + return rows, rest, foot + + +# ── path ───────────────────────────────────────────────────────────────────────────────────────────────────────────── +def path(d, code): + rows = [] + for a in d.get('answers', []): + hops, prev = a.get('hops', []), a.get('from', '') + for i, h in enumerate(hops, 1): + t = h.get('tier') + cert = 'defines (not a call)' if not h.get('is_call', True) else ax_edges.direct_cert(t) + at = h.get('call_at') or h.get('declared_at') + # caller → callee on every hop: the line is in the caller, and a chain found from B back to A reads right + rows.append(('hop', site(code, at, f"{cert} · hop {i}/{len(hops)} {prev} → {h['to']}{stale(h)}"))) + prev = h['to'] + for r in d.get('reached', []): + rows.append(('reached', site(code, r['at'], f"hop {r['hops']}{stale(r)}", r['name']))) + # every printed hop of a chain is looked up again (`unverified_hops` counts the ones that were not there); the + # document's own `verified` speaks for the `'*'` closure + ans = d.get('answers', []) + v = d.get('verified') + if ans: v = all(not a.get('unverified_hops') for a in ans) and v is not False + foot = [verified(v, sum(len(a.get('hops', [])) for a in ans) if ans else None)] + if d.get('bound'): foot.append(f"bound: {d['bound']}") + return rows, {}, foot + + +# ── context ────────────────────────────────────────────────────────────────────────────────────────────────────────── +def context(d, code): + rows = [] + listed = set() + for s in d.get('flow', []): + if s.get('repeat_of'): continue + c = s.get('certainty') or 'entry' + tag = f"step {s['step']} · {'entry' if c == 'entry' else TAG.get(c, c)}" + if s.get('called_at_line'): tag += f" · called at L{s['called_at_line']}" + if s.get('unresolved'): tag += ' · ⚠ ' + ', '.join(s['unresolved'][:2]) + rows.append(('flow', site(code, s['at'], tag + stale(s), s['name']))); listed.add(s['at']) + if not d.get('flow'): + for e in d.get('entry_points', []): + if e['at'] in listed: continue + rows.append(('entry', site(code, e['at'], f"entry · {e.get('why', '')}".rstrip(' ·') + stale(e), e['name']))); listed.add(e['at']) + for f in d.get('files', []): + for nm, at in zip(f.get('declarations', []), f.get('declared_at') or []): + if not at or at in listed: continue + rows.append(('file', site(code, at, f"{f['how']} · {f.get('task_terms_matched', 0)} task term(s){stale(f)}", nm))); listed.add(at) + tb = d.get('text_bindings', []) + for t in tb[:TEXT_ROWS]: + rows.append(('text', site(code, f"{t['file']}:{t['line']}", 'text · names ' + (t.get('name') or ', '.join(t.get('terms', []))) + stale(t)))) + foot = [] + if d.get('not_indexed'): foot.append('not indexed: ' + ', '.join(map(str, d['not_indexed'][:3]))) + foot.append("bound: follows call edges and names; a constant, config key, string or reflection does not appear") + return rows, ({'[text]': len(tb) - TEXT_ROWS} if len(tb) > TEXT_ROWS else {}), foot + + +def landing(d): + """how much of the task a language's graph answers for: the most task words one of its files or entry points matches""" + best = max([f.get('task_terms_matched', 0) for f in d.get('files', [])] + [0]) + for e in d.get('entry_points', []): + best = max(best, len(re.findall(r"'[^']+'", e.get('why', '')))) + return best + + +# ── test-impact ────────────────────────────────────────────────────────────────────────────────────────────────────── +def test_impact(d, code): + rows = [] + for f in d.get('edited_test_files', []): + rows.append(('test', f"{f}:1: (edited test file) [test · edited]")) + for t, k in per_test_file(d.get('tests', [])): + rows.append(('test', site(code, t['at'], f"test · {TAG.get(t.get('certainty'), t.get('certainty'))} · hop {t['hops']}{more_in_file(k)}{stale(t)}", t['display']))) + # a changed file no graph follows (a script, a fixture) is run by the test files that name it in their text + names = {} + for f, v in (d.get('named_in_test_text') or {}).items(): + for t in (v or {}).get('tests', []): + names.setdefault(t, []).append((v or {}).get('needle') or f) + for t, ns in names.items(): + rows.append(('text test', f"{t}:1: (names {', '.join(dict.fromkeys(ns))}) [test · text]")) + foot = [] + if d.get('command'): foot.append(f"run: {d['command']}") + if d.get('bound'): foot.append(f"bound: {d['bound']}") + return rows, {}, foot + + +def verified(v, hops=None): + if v is True: return "verified: ✓" + (f" ({hops} edge(s) looked up again)" if hops else '') + if v is False: return "verified: ✗ — an edge in this answer is not in the graph; do not use it" + return "verified: nothing to check" + + +# how the rows left out past --grep-limit are counted, by the kind each verb gave them +KIND_WORD = {'contract': 'must change', 'reached': 'hop N', 'module': 'module scope', 'stub': 'stubs it', 'flow': 'step', 'file': 'declaration', + 'test': 'test file'} +VERBS = {'impact': impact, 'path': path, 'context': context, 'test-impact': test_impact, 'tests': test_impact} + + +def render(verb, doc, repo, cap=CAP): + """the lines of the grep-shaped answer for a verb's --json document (and every other language's in it)""" + code = Code(repo) + docs = [(doc.get('language') or '', doc)] + list((doc.get('other_languages') or {}).items()) + left_out = [] + if verb == 'context' and len(docs) > 1: + # a task is about one language's code far more often than about all of them: the graphs its words land in + # best come first, and one where half as many words land or fewer is named, not listed + docs.sort(key=lambda ld: -landing(ld[1])) + best = landing(docs[0][1]) + if best > 0: + left_out = [l for l, x in docs if 2 * landing(x) <= best] + docs = [(l, x) for l, x in docs if 2 * landing(x) > best] + rows, rest, foot = [], {}, [] + for lang, d in docs: + if not isinstance(d, dict): continue + r, m, f = VERBS[verb](d, code) + rows += r + for k, n in m.items(): rest[k] = rest.get(k, 0) + n + foot += [x for x in f if x not in foot] + out = [x for _k, x in rows[:cap]] + if len(rows) > cap: + kinds = {} + for k, _x in rows[cap:]: + k = KIND_WORD.get(k, k); kinds[k] = kinds.get(k, 0) + 1 + rest = dict(list({f'[{k}]': n for k, n in sorted(kinds.items(), key=lambda kv: -kv[1])}.items()) + list(rest.items())) + if rest: + how = ("limit=N lists more, full=True gives the whole answer" if os.environ.get('AXIOMCODE_SURFACE') == 'mcp' else + "--grep-limit N lists more, the verb without --grep gives the whole answer") + out.append(f"… +{sum(rest.values())} more not listed: " + ', '.join(f"{n} {k}" for k, n in rest.items()) + f" — {how}") + if not rows: return None + if left_out: out.append(f"also answered in the {', '.join(left_out)} graph(s), where fewer of the task's words land; --lang asks one") + return out + foot + + +def main(argv): + verb, repo = argv[0], argv[1] + cap = CAP + rest = argv[2:] + if rest and rest[0] == '--limit': cap = int(rest[1]); rest = rest[2:] + cmd = rest[1:] if rest and rest[0] == '--' else rest + import ax_exec + r = subprocess.run(ax_exec.program(cmd + ['--json']), stdout=subprocess.PIPE, text=True, encoding='utf-8', errors='replace') + try: doc = json.loads(r.stdout) + except ValueError: doc = None + if not isinstance(doc, dict) or r.returncode: + # a refusal, as the verb said it: its prose when the document carries it ("no chain connects…"), else its text + if isinstance(doc, dict) and isinstance(doc.get('prose'), list): print('\n'.join(doc['prose'])) + else: sys.stdout.write(r.stdout) + return r.returncode + lines = render(verb, doc, repo, cap) + if lines is None: + # no site at all ("no chain", nothing depends on it): the WHY is the answer, and only the prose says it + if isinstance(doc.get('prose'), list): print('\n'.join(doc['prose'])) + else: return subprocess.run(ax_exec.program(cmd)).returncode + return r.returncode + print('\n'.join(lines)) + return r.returncode + + +if __name__ == '__main__': + if len(sys.argv) < 4: sys.exit(__doc__) + sys.exit(main(sys.argv[1:])) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py index 8e6bffa5a..aef1c5b37 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py @@ -50,10 +50,52 @@ def _classify(fp): return 1 if b'\0' in head else 0 +# ── which name matches are noise ───────────────────────────────────────────────────────────────────────── +# Two rules, applied to every hit before anyone sees it (impact's "bound from outside the source", context's text +# bindings): +# +# OUT OF SCOPE. A shell script or a Datalog file is never analysed, so a name matched inside one is noise by rule: +# `hits` for a method `hits` was 21 rows, most of them CI scripts and rule comments. An extensionless script is +# one by its first line (`mvnw`, `gradlew`: `#!/bin/sh`). +# +# PROSE. A name that is a plain English word (`note`, `export`, `build`, `validate`: lowercase letters only, no `_` +# and no case boundary) is written as a word in templates, YAML, CI files and comments far more often than as a +# binding: 261 rows for one Django view, 71 for `note`, a Maven `validate` for a Java `validate`. +# Such a match is kept as a binding only where the line writes it the way code or configuration refers to a +# callable: called (`note(`), quoted as a value (`"note"`, `'note'`), or qualified with `#`, `::` or `->` +# (`Owner#note`; a CSS `#note` selector is not one). A dotted `Owner.note` is matched as the qualified name itself and never reaches this rule. The +# rest are PROSE: counted and grep-able, not listed as places a rename breaks. +OUT_OF_SCOPE_EXT = {'.sh', '.bash', '.zsh', '.ksh', '.dl'} +SHELL_SHEBANG = re.compile(r'#!\s*\S*(?:/|\s)(?:env\s+)?(?:ba|z|k|da)?sh\b') +COMMON = re.compile(r'[a-z]+') +_QUOTES = '"\'`' +QUALIFIER = re.compile(r'[\w$)\]>](?:#|::|->)$') # `Owner#note`, `Owner::note`, `$obj->note`; not a CSS `#note` selector + + +def out_of_scope(rel, text): + """a shell script or a Datalog file: a name matched there is never a binding (owner's rule)""" + if os.path.splitext(rel)[1].lower() in OUT_OF_SCOPE_EXT: return True + return not os.path.splitext(rel)[1] and bool(SHELL_SHEBANG.match(text[:120])) + + +def is_common(name): + """a plain word: the names whose bare mentions are mostly prose""" + return bool(COMMON.fullmatch(name or '')) + + +def code_shaped(line, start, end): + """the match line[start:end] is written as a reference: called, quoted as a whole value, or #/::/-> qualified""" + before, after = line[:start], line[end:] + if after.lstrip().startswith('('): return True + if before[-1:] and before[-1] in _QUOTES and after[:1] == before[-1]: return True + return bool(QUALIFIER.search(before)) + + class NonSource: def __init__(self, repo, cache_dir, indexed, skip_dir, skip_ext): self.repo, self.cache_dir, self.indexed, self.skip_dir, self.skip_ext = repo, cache_dir, indexed, skip_dir, skip_ext self._files = None; self._uncached = set(); self.con = None + self.prose = set() # (name, rel, line) hits of a common word with no reference syntax around it (see PROSE) if cache_dir and not os.environ.get('AXIOMCODE_NO_SCAN_CACHE'): try: os.makedirs(cache_dir, exist_ok=True) @@ -154,8 +196,12 @@ def hits(self, names): if only is not None and rel not in only: continue try: text = open(os.path.join(self.repo, rel), errors='replace').read() except OSError: continue - if not any(n in text for n in names): continue + if not any(n in text for n in names) or out_of_scope(rel, text): continue for i, line in enumerate(text.split('\n'), 1): - for m in pat.finditer(line): hits.append((m.group(1), rel, i)) + shaped = {} + for m in pat.finditer(line): + n = m.group(1); hits.append((n, rel, i)) + shaped[n] = shaped.get(n, False) or not is_common(n) or code_shaped(line, m.start(1), m.end(1)) + self.prose.update((n, rel, i) for n, ok in shaped.items() if not ok) if len(hits) > 2000: break return hits diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py index 2ae8e4773..c838e9042 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py @@ -73,6 +73,10 @@ def totals_for(shown): if i > 0: cur.append(title + ' (continued)'); used += len(title) + 13 cur.append(l); used += len(l) + 1 if cur: pages.append(cur) + # AN ANSWER WITH NO ROWS BEYOND ITS FIRST PAGE IS NOT PAGED: a refusal or a note list longer than a page came back as + # "page 1 of 2 ... 1 more page, 0 rows", a footer promising more of an answer that had none + if len(pages) > 1 and page == 1 and not any(l.startswith(' ') for pg in pages[1:] for l in pg): + return text n = len(pages) if page < 1 or page > n: return f"page {page} does not exist: this answer has {n} page(s) at {budget_flag} {budget}\n" @@ -108,7 +112,14 @@ def next_path(text): rows = re.findall(r'^\s+(\d+) hop\(s\)\s+(\S+).*?\s' + LOC, text, re.M) if rows: near = min(int(h) for h, _, _ in rows) - first = [(n, loc) for h, n, loc in rows if int(h) == near][:5] + # each caller ONCE (#1389): the same caller is printed under "nearest callers" (at its call line) and again under + # "entry points" (at its declaration), so the rows are keyed on the name and its FILE, not the line. Two callers + # of one display name in two files are two callers, and stay two. + seen_, first = set(), [] + for h, n, loc in rows: + if int(h) == near and (n, loc.rsplit(':', 1)[0]) not in seen_: + seen_.add((n, loc.rsplit(':', 1)[0])); first.append((n, loc)) + first = first[:5] total = re.search(r'(\d+) (?:method|callable)s?\b', text) return (f"next: the nearest {'caller is' if len(first) == 1 else 'callers are'} at {near} hop(s): " + ', '.join(f"{n} {loc}" for n, loc in first) @@ -130,6 +141,11 @@ def next_path(text): return ("next: no chain of calls; the library calls named above are where one could continue: read the body " "that makes them — one that publishes, schedules or registers what the entered method handles connects " "the two at run time") + if 'connection is UNKNOWN, not absent' in text: + m = re.search(r'^\s+`[^`]*` in \S+ at (\S+:\d+)', text, re.M) + return ("next: not shown to be independent — " + (f"read {m.group(1)} and " if m else "read the calls through a value named above and ") + + "find what its callee is given (the arguments its callers pass, what the loop runs over); a function handed " + "there is the connection") if 'the hop above is the only connection' in text: return "next: the cross-process hop above is the only connection; `impact ` lists it as a [remote] dependent" # an endpoint a framework enters in a way the graph does not model: the check is the grep the verdict printed @@ -180,12 +196,20 @@ def next_context(text): "to it reaches. The other files are ranked context, not a reading list") def next_changed(text): - if re.search(r'^no change', text, re.M): return '' + if re.search(r'^(no change|no git base)', text, re.M): return '' return "next: `test-impact` names the tests this edit reaches and the command that runs exactly those; `impact ` for a signature or field change above" def next_test_impact(text): - m = re.search(r'^\s*((?:\./gradlew|gradle|mvn|\./mvnw|npx|npm|pytest|python -m pytest|dotnet|go) [^\n]+)$', text, re.M) - return f"next: run {m.group(1).strip()} — only the tests above; a test reached through reflection or a service loader is not among them" if m else '' + # a TypeScript selection can need one command per package and runner: `(cd pkg && npx tsx x.ts)` (#1570). + # When a text tier adds the tests that load a changed fixture, it prints the command(s) for both after + # "with the tests above:", and the first command alone left those tests out of the step an agent takes: the + # LAST such block wins, with every command that continues it + ms = list(re.finditer(r'^\s*(with the tests above: )?((?:\(cd \S+ && )?(?:\./gradlew|gradle|mvn|\./mvnw|npx|npm|pnpm|yarn|bun|node|tsx|pytest|python -m pytest|dotnet|go) [^\n]+)$', text, re.M)) + if not ms: return '' + last = max((i for i, m in enumerate(ms) if m.group(1)), default=0) + cmds = [m.group(2).strip() for m in ms[last:]] + what = cmds[0] if len(cmds) == 1 else f"the {len(cmds)} commands above, each from where it is written ({cmds[0]} …)" + return f"next: run {what} — only the tests above; a test reached through reflection, a service loader or a subprocess is not among them" NEXT = {'path': next_path, 'context': next_context, 'changed': next_changed, 'test-impact': next_test_impact} diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py index 3e1c9ce82..028c113df 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py @@ -16,8 +16,11 @@ here: the reference alone says it is passed as a value (`valueref` in dl/impact.dl), and naming the receiving call as one that "calls it where the graph cannot follow" was wrong for every synchronous collection operation (#1166). """ +import re ROUTE_VERB = {'get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace', 'connect', 'all', 'use', 'route'} +# the verbs that name an HTTP method; `all`, `use` and `route` register or mount without naming one +HTTP_VERB = ROUTE_VERB - {'all', 'use', 'route'} # A CALLBACK IS NOT RECOGNISED BY THE VERB ALONE, and a curated list of verbs was the wrong instrument. # The route leg has two independent signals -- an HTTP verb AND a path-shaped literal on the same line -- and it is @@ -80,7 +83,8 @@ def registrations(q, site_file=None): # handler does depend on it — so only the key is withheld. tf = {f for (f,) in q("SELECT DISTINCT file FROM symbols WHERE is_test = 1 AND file IS NOT NULL")} if _has(q, 'symbols') else set() out = {} - for name, site_kind, fp, a, b in q("""SELECT callee_name, kind, file_path, start_line, end_line FROM call_sites + verbs = {} # (file, line) -> {HTTP verb: first column}, for the wording below + for name, site_kind, fp, a, b, col in q("""SELECT callee_name, kind, file_path, start_line, end_line, end_column FROM call_sites WHERE callee_name IS NOT NULL AND start_line > 0"""): f = sf(fp); b = b or a if b < a or b - a > MAX_SITE_SPAN: @@ -98,9 +102,181 @@ def registrations(q, site_file=None): # a route beats a plain callback on the same line: `router.use('/x', wrap(handler))` is a route site if (f, l) not in out or kind == 'route': out[(f, l)] = (kind, key, why) + if short.lower() in HTTP_VERB and (l == b or a == b): + vs = verbs.setdefault((f, l), {}); vs[short.upper()] = min(vs.get(short.upper(), col or 0), col or 0) + # `router.route('/').get(h)` is two route-shaped calls on one line, and `route` said nothing about the method: the + # line's HTTP verbs name it, in the order written. A chain registering two (`.get(a).post(b)`) names both, because + # a line is all a reference records and it cannot say which argument list the handler sat in + for (f, l), (k, key, w) in list(out.items()): + vs = verbs.get((f, l)) + if k == 'route' and vs: + m = re.match(r'registered as a \w+ route (".*?") here', w) + if m: out[(f, l)] = (k, key, f"registered as a {'/'.join(sorted(vs, key=vs.get))} route {m.group(1)} here — the router calls it, no call site does") return sorted((ref_at.get((f, l), ''), f, l, k, key, w) for (f, l), (k, key, w) in out.items()) +# A CONST HOLDING A WRAPPED HANDLER — `const h = catchAsync(async (req, res) => …)`, then `router.get('/a', h)` — is a +# field, not a method, so it has no call edge of its own to carry the route wording. What both backends join instead +# (`const_route` in dl/impact.dl, graph_sql.direct_for_field) is two facts read from the source at the call sites the +# engine recorded, and neither is "the name appears on a route line": +# +# route_args the name is written IN A HANDLER POSITION of the route call: a top-level argument after the path +# (or an element of an array argument, which a router flattens) that is a bare name or a member +# chain ending in it. The receiver (`router.get`), an options object (`{ schema: { 200: S } }`), an +# argument of a nested call (`validate(bodySchema)`, `express.static(dir)`) and the path are not +# handed to the router to call, and a name written there is read, not registered. +# const_values the const HOLDS A FUNCTION: its initializer is a function, a reference to a declared function, or +# a call that returns one — a callee the engine says returns a function (`returns_fn`), or a wrapper +# that is handed a function (`asyncHandler(async (req, res) => …)`, nested wrappers too). A plain +# object, a schema builder, `express.Router()`, `require(…)`, a string or a number never is: a router +# mounted with `app.use('/p', router)` is a mount, not a handler. + +_IDENT = r'[A-Za-z_$][\w$]*' +_CHAIN = re.compile(rf'({_IDENT})(?:\s*\??\.\s*({_IDENT}))*\s*$') +_OPEN, _CLOSE = '([{', ')]}' + + +def _match(text, i): + """the index just past the bracket group opening at text[i] (strings and comments are already blanked)""" + d = 0 + for j in range(i, len(text)): + c = text[j] + if c in _OPEN: d += 1 + elif c in _CLOSE: + d -= 1 + if d == 0: return j + 1 + return len(text) + + +def _split_args(text, lo, hi): + """[(start, end)] of the top-level comma-separated items in text[lo:hi]""" + out, d, s = [], 0, lo + for j in range(lo, hi): + c = text[j] + if c in _OPEN: d += 1 + elif c in _CLOSE: d -= 1 + elif c == ',' and d == 0: out.append((s, j)); s = j + 1 + if text[s:hi].strip(): out.append((s, hi)) + return out + + +def _fn_literal(t): + """`function …`, `async (a, b) => …`, `req => …`, `(req: Request): void => …`""" + t = t.lstrip() + if re.match(r'(async\s+)?function\b', t): return True + m = re.match(r'(async\s*)?', t); t = t[m.end():] + if re.match(rf'{_IDENT}\s*=>', t): return True + if t.startswith('('): + return bool(re.match(r'\s*(:[^=;{}]*)?=>', t[_match(t, 0):])) + return False + + +def route_args(q, code, site_file=None): + """{(caller, file, line, name)}: `name` written in a handler position of a route-shaped call made by `caller`.""" + if not _has(q, 'call_sites') or code is None: + return set() + sf = site_file or (lambda x: x) + out = set() + for c, name, fp, a, ac, b, bc in q("""SELECT caller_id, callee_name, file_path, start_line, start_column, end_line, end_column + FROM call_sites WHERE callee_name IS NOT NULL AND start_line > 0 AND file_path IS NOT NULL"""): + if name.split('.')[-1].lower() not in ROUTE_VERB: continue + b = b or a + if b < a or b - a > MAX_SITE_SPAN: continue + f = sf(fp); L = code(f) or [] + if b > len(L): continue + text = '\n'.join(L[a - 1:b]); starts = [0] + for ln in L[a - 1:b - 1]: starts.append(starts[-1] + len(ln) + 1) + end = starts[-1] + (bc - 1 if bc else len(L[b - 1])) # end_column is one past the call's `)` + close = text.rfind(')', 0, end) + if close < 0: continue + d, j = 0, close # back to the `(` that opens THIS call's arguments + while j >= 0: + if text[j] in _CLOSE: d += 1 + elif text[j] in _OPEN: + d -= 1 + if d == 0: break + j -= 1 + if j < 0: continue + line_of = lambda k: a + sum(1 for s0 in starts[1:] if s0 <= k) + # every argument is looked at: the path is a string (blanked), and a path or prefix held in a const is not a + # function, which `const_values` decides. `.route('/p').post(a, b)` has no path in its own argument list + todo = _split_args(text, j + 1, close) + while todo: + lo, hi = todo.pop() + t = text[lo:hi]; st = t.strip() + if st.startswith('[') and st.endswith(']'): # an array of handlers: the router flattens it + k = lo + t.index('['); todo += _split_args(text, k + 1, lo + t.rindex(']')); continue + m = _CHAIN.match(st) + if not m or st.startswith(('...', 'new ')): continue + last = re.search(rf'({_IDENT})\s*$', st) + out.add((c, f, line_of(lo + t.rindex(last.group(1))), last.group(1))) + return out + + +def const_values(q, code, decls, site_file=None): + """For each (file, line, name) const declaration: ({(file, line)} that hold a function, {(file, line, wrapper)} the + client callable its initializer calls, {(file, line, name)} the function it is an alias of).""" + callable_, wrap, alias = set(), set(), set() + if not decls or code is None: + return callable_, wrap, alias + sf = site_file or (lambda x: x) + fns = {n for (n,) in q("SELECT DISTINCT name FROM symbols WHERE method_id IS NOT NULL AND name IS NOT NULL AND name NOT LIKE '<%'")} + rets = {w for w, _m in returned_functions(q)} + callee_at = {} + for fp, l, col, m in q("""SELECT s.file_path, s.start_line, s.start_column, e.callee_method_id FROM call_sites s JOIN call_edges e + ON e.call_site_id = s.id WHERE e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL + AND s.file_path IS NOT NULL AND s.start_line > 0"""): + callee_at.setdefault((sf(fp), l, col), set()).add(m) + + def call_value(text, i, f, a, starts, depth=0): + """(callable?, wrappers) for the expression at text[i:]""" + m = re.compile(rf'\s*(await\s+|new\s+)?({_IDENT}(?:\s*\??\.\s*{_IDENT})*)\s*').match(text, i) + if not m or m.group(1): return False, set() + k = m.end(); chain = m.group(2); k0 = m.start(2) + ln = a + sum(1 for s0 in starts[1:] if s0 <= k0); col = k0 - starts[ln - a] + 1 + ws = callee_at.get((f, ln, col), set()) + if k >= len(text) or text[k] != '(': # a reference: an alias of a declared function + last = re.split(r'\s*\??\.\s*', chain)[-1] + return (last in fns and depth == 0 and not re.match(r'\s*[\[+\-*/%?`]', text[k:k + 2]), {('alias', last)}) + if chain.split('.')[-1].strip() in ('require', 'import'): return False, set() + ok = bool(ws & rets); groups = 0 + while k < len(text) and text[k] == '(' and groups < 3: # `wrap(fn)` and curried `wrap(opts)(fn)` + e = _match(text, k); groups += 1 + for lo, hi in _split_args(text, k + 1, e - 1): + t = text[lo:hi] + if _fn_literal(t): ok = True + elif depth < 3: + inner, _w = call_value(text, lo, f, a, starts, depth + 1) + if inner or (re.fullmatch(rf'\s*{_IDENT}(\s*\.\s*{_IDENT})*\s*', t) and re.split(r'\s*\.\s*', t.strip())[-1] in fns): ok = True + k = e + while k < len(text) and text[k] in ' \t': k += 1 + if k < len(text) and text[k] == '.': return False, set() # `express.Router().use(…)`: a method of the result + return ok, {('wrap', w) for w in ws} + + for f, l, n in decls: + L = code(f) or [] + if not (0 < l <= len(L)): continue + chunk = L[l - 1:l + 40]; text = '\n'.join(chunk); starts = [0] + for ln in chunk[:-1]: starts.append(starts[-1] + len(ln) + 1) + m = re.search(rf'(?])', text[:len(chunk[0])]) + if not m: continue + if _fn_literal(text[m.end():]): callable_.add((f, l)); continue + ok, how = call_value(text, m.end(), f, l, starts) + if ok: callable_.add((f, l)) + for kind, x in how: + if kind == 'wrap': wrap.add((f, l, x)) + elif ok: alias.add((f, l, x)) + return callable_, wrap, alias + + +def returned_functions(q): + """{(wrapper, fn)}: the engine's own `ext_return_value` — the function a callable returns. `catchAsync` returns + `(req, res, next) => …`, and that is the function the route line hands over for every const it wrapped.""" + if not _has(q, 'ext_return_value'): + return set() + return {(w, m) for w, k, m in q("SELECT c0, c1, c2 FROM ext_return_value") if k == 'func' and m} + + def value_ref_rows(q, names, ids, site_file=None, at=None, lines=None): """The SQL side of the rule: [(caller, 'uses', why, 'by name', file, line)] for a method target. @@ -210,9 +386,114 @@ def decoration_keys(q, site_file=None): why = (f'registered as a route "{key}" by @{short} — the router calls it, no call site does' if kind == 'route' else f'registered under "{key}" by @{short} — whoever writes that string reaches it, and no call site does') out.append((owner, sf(f) if f else '', l or 0, kind, key, why)) + out += _prefixed_routes(q, out, sf) return sorted(set(out)) +# A HANDLER'S ROUTE IS ITS TYPE'S PREFIX AND ITS OWN PATH. `@RequestMapping("/orders/{id}")` on the class and a bare +# `@GetMapping` on the method serve "/orders/{id}"; `@PostMapping("lines")` beside it serves "/orders/{id}/lines"; a +# path written without its leading slash (`@RequestMapping("widgets")`) is served from the root all the same. Read one +# decoration at a time, the prefix registered the CLASS, which no closure walks, and a relative path registered +# nothing, so a test driving such a handler over HTTP reached nothing: a request test for a class-mapped controller +# counted 0 tests. A decoration is a mapping when its NAME says so (…Mapping, JAX-RS @Path, a verb, ASP.NET's +# [Route] / [HttpGet]); a `@Transactional` beside it is not a route. ASP.NET's [controller] and [action] tokens are +# the type's name without its Controller suffix and the method's name. +import collections, re +_MAPPING = re.compile(r'^(\w*Mapping|Path|GET|POST|PUT|DELETE|PATCH|Http(Get|Post|Put|Delete|Patch)|Route)$') + + +def _first_path(text): + m = re.search(r'"([^"\s]{0,120})"', (text or '').replace('"""', '"')) + return m.group(1) if m else None + + +def _join(a, b): + return '/' + '/'.join(x.strip('/') for x in (a, b) if x and x.strip('/')) + + +def _prefixed_routes(q, rows, sf): + if not _has(q, 'symbols'): + return [] + seen = {(r[0], r[4]) for r in rows} + sym = {i: (d, n, o, bool(t) and not m) for i, d, n, o, t, m in q("SELECT id, display, name, owner, type_id, method_id FROM symbols WHERE display IS NOT NULL")} + prefix = collections.defaultdict(set) # type display -> the paths its own mapping serves + methods = [] + for owner, name, text, f, l in q("SELECT owner_id, name, text, file, line FROM decorations WHERE owner_id IS NOT NULL"): + short = (name or '').split('.')[-1] + if not _MAPPING.match(short) or owner not in sym: continue + p_ = _first_path(text) + d, n, o, is_type = sym[owner] + if is_type: + if p_: prefix[d].add(p_) + else: + methods.append((owner, short, p_, f, l)) + out = [] + for mid, short, p_, f, l in methods: + d, n, o, _t = sym[mid] + tname = (o or '').split('.')[-1] + for pre in sorted(prefix.get(o) or {''}): + key = _join(pre, p_ or '') + key = key.replace('[controller]', re.sub(r'Controller$', '', tname).lower()).replace('[action]', (n or '').lower()) + if key == '/' and not (pre or p_): continue + if (mid, key) in seen: continue + seen.add((mid, key)) + out.append((mid, sf(f) if f else '', l or 0, 'route', key, + f'registered as a route "{key}" by @{short}' + (" under its type's prefix" if pre else '') + ' — the router calls it, no call site does')) + return out + + +# THE HTTP METHOD A ROUTE ANSWERS, AND THE ONE A TEST SENDS. "/orders" is two handlers when one answers GET and the other +# POST, and a test that posts an order drives only the second. Joined on the path alone, every test that lists orders +# reached the handler that creates one, and a path five tests write (one per verb) was refused as a key that +# identifies nothing. The verb is read where it is written: the handler's decoration (`@GetMapping`, `[HttpPost]`, +# `@router.put`, `@RequestMapping(method = DELETE)`, JAX-RS `@DELETE` beside `@Path`) and the request call that +# carries the path literal (`.put("/orders/{id}", id)`, `client.delete(...)`, `DeleteAsync(...)`). Either side unknown +# joins as before. +_VERBS = ('GET', 'POST', 'PUT', 'DELETE', 'PATCH') +_VERB_NAME = re.compile(r'^(?:(Get|Post|Put|Delete|Patch)Mapping|Http(Get|Post|Put|Delete|Patch)|(GET|POST|PUT|DELETE|PATCH)|(get|post|put|delete|patch))$') + + +def route_verbs(q, site_file=None): + """{(decl, key, VERB)}: the HTTP method each decoration-registered route answers, where a decoration says it.""" + if not _has(q, 'decorations'): + return set() + verbs = collections.defaultdict(set) + for owner, name, text in q("SELECT owner_id, name, text FROM decorations WHERE owner_id IS NOT NULL"): + short = (name or '').split('.')[-1] + m = _VERB_NAME.match(short) + if m: + verbs[owner].add(next(g for g in m.groups() if g).upper()) + elif _MAPPING.match(short) or short == 'route': + for v in re.findall(r'\b(GET|POST|PUT|DELETE|PATCH)\b', text or ''): verbs[owner].add(v) + out = set() + for decl, _f, _l, kind, key, _w in decoration_keys(q, site_file): + if kind == 'route': + for v in verbs.get(decl, ()): out.add((decl, key, v)) + return out + + +def literal_verbs(q, at, site_file=None): + """{(caller, literal, VERB)}: a path literal written as the argument of a request call named for one HTTP method — + the innermost such call whose span holds the literal's line.""" + if not (_has(q, 'literals') and _has(q, 'call_sites')): + return set() + sf = site_file or (lambda x: x) + calls = collections.defaultdict(list) + for n, f, a, b in q("SELECT callee_name, file_path, start_line, end_line FROM call_sites WHERE callee_name IS NOT NULL AND start_line > 0"): + short = re.sub(r'Async$', '', (n or '').split('.')[-1].split('<')[0]).upper() + if short in _VERBS: calls[sf(f) if f else ''].append((a, b or a, short)) + out = set() + for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + if not (isinstance(v, str) and v.startswith('/')): continue + f2 = sf(f) if f else '' + hold = [(b - a, -a, verb) for a, b, verb in calls.get(f2, ()) if a <= l <= b] + if not hold: continue + verb = min(hold)[2] + c = at(f, l) + if c: out.add((c, v, verb)) + return out + + # ── a registration written as a CALL that no verb list knows ───────────────────────────────────────────────── # the Python web framework's `application.add_url_rule("/quote/", view_func=legacy_quote)` is a route registration whose verb # is not an HTTP verb, and the same shape appears wherever a framework takes (path, handler) under a name of its own @@ -236,10 +517,17 @@ def value_route_registrations(q, site_file=None): for n, f, l in q(f"SELECT name, file, line FROM refs WHERE line > 0 AND entity_kind IN ({ek})", *CALLABLE_EK): if n in once: ref_at.setdefault((f, l), once[n]) + routed = _routed_views(q) + served = _served_paths(q) + names_at = {} + if routed: + for n, f, l in q("SELECT name, file, line FROM refs WHERE line > 0 AND name IS NOT NULL"): + if (f, n) in routed: + names_at.setdefault((f, l), set()).update(routed[(f, n)]) import re out = [] for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): - if not (isinstance(v, str) and v.startswith('/') and len(v) < 160): + if not (isinstance(v, str) and 0 < len(v) < 160): continue # A RESOURCE IS NOT A ROUTE. Measured on the JVM parser: the pair fired on # `connect(url).onResponseProgress(progressListener)` — a path-shaped literal and a handed-over callable on @@ -249,13 +537,82 @@ def value_route_registrations(q, site_file=None): last = v.split('?')[0].split('#')[0].rstrip('/').rsplit('/', 1)[-1] if '?' in v or re.search(r'\.[A-Za-z0-9]{1,6}$', last): continue - d = ref_at.get((f, l)) - if d: - out.append((d, sf(f) if f else '', l or 0, 'route', v, - f'registered at "{v}" here — the framework calls it, no call site does')) + # the views the engine's own url_dispatch rule says a route on this line dispatches to (#1483): a view named as + # a module attribute (`views.order_list`) is a reference the parser gives no callable entity kind, and a + # class-based view is named by its class, not by the handler the framework calls + routed_here = names_at.get((f, l), ()) + if v.startswith('/'): + ds, key = ({ref_at[(f, l)]} if (f, l) in ref_at else set()) | set(routed_here), v + # A ROUTE TABLE WRITES ITS PATTERN WITHOUT THE LEADING SLASH (#1482): `path("orders//", view)` + # serves `/orders/5/`. Such a string is not path-shaped on its own, since `name="order-list"` sits on the + # same line, so it is read only where the engine has already said the line routes to a view, only when it + # has a segment separator, and never as a regular expression. A view whose served path the engine wrote on + # the edge is keyed by that path below, prefix and all; this is the fallback for one it could not compose. + elif routed_here and '/' in v and not re.search(r'[\s^$\\]', v): + ds, key = set(routed_here) - set(served), '/' + v + else: + continue + for d in ds: + out.append((d, sf(f) if f else '', l or 0, 'route', key, + f'registered at "{key}" here — the framework calls it, no call site does')) + # THE PATH THE ROUTE SERVES, AS THE ENGINE COMPOSED IT (#1511): `path("status/", views.api_status)` in a table that + # `path("api/v1/", include(api_patterns))` mounts is served at `/api/v1/status/`, which no single line spells. The + # registration sits on the line that names the view. A bare `/` is not a key: a table the engine could not see + # mounted (`include("app.urls")`) is served under a prefix it does not know, and its empty pattern would join + # every request for the site root. + for d, keys in served.items(): + at = sorted(fl for fl, ds in names_at.items() if d in ds) + if not at: + continue + f, l = at[0] + for key in keys: + if key != '/': + out.append((d, sf(f) if f else '', l or 0, 'route', key, + f'registered at "{key}" here — the framework calls it, no call site does')) return sorted(set(out)) +def _served_paths(q): + """{declaration: {path}} — the paths the engine's url_dispatch edges say each view is served at.""" + if not all(_has(q, t) for t in ('ext_framework_edge', 'symbols')): + return {} + out = {} + for d, p in q("""SELECT s.id, e.c3 FROM ext_framework_edge e JOIN symbols s ON s.method_id = e.c1 + WHERE e.c2 = 'url_dispatch' AND e.c3 LIKE '/%'"""): + out.setdefault(d, set()).add(p) + return out + + +def _routed_views(q): + """{(route_file, name): {declaration}} — the views the engine dispatches a route table in route_file to. + + Keyed by every name a route entry spells the view with: a function view by its own name, a class-based view's + handler by its class or any subclass (`WidgetView.as_view()` dispatches to a `get` WidgetView inherits). A name + that means two different views from one route file identifies neither and is dropped. + """ + if not all(_has(q, t) for t in ('ext_framework_edge', 'methods', 'symbols')): + return {} + rows = q("""SELECT fm.file_path, s.id, t.name, t.owner_type_id FROM ext_framework_edge e + JOIN methods fm ON fm.id = e.c0 JOIN methods t ON t.id = e.c1 JOIN symbols s ON s.method_id = e.c1 + WHERE e.c2 = 'url_dispatch'""") + if not rows: + return {} + tname = {i: n for i, n in q("SELECT id, name FROM types")} if _has(q, 'types') else {} + subs = {} + if _has(q, 'type_ancestors'): + for t, a in q("SELECT type_id, ancestor_type_id FROM type_ancestors"): + subs.setdefault(a, set()).add(t) + by = {} # (file, name) -> {(view identity, declaration)} + for f, d, n, owner in rows: + if owner: + for t in {owner} | subs.get(owner, set()): + if t in tname: + by.setdefault((f, tname[t]), set()).add((t, d)) + else: + by.setdefault((f, n), set()).add((d, d)) + return {k: {d for _i, d in v} for k, v in by.items() if len({i for i, _d in v}) == 1} + + # ── the two spellings of one path ──────────────────────────────────────────────────────────────────────────── # A test asks for `/orders/o-1/price`; the handler is registered at `/orders/{order_id}/price`. Neither string # contains the other and no call site joins them — the router does, at run time, by matching the path. A path @@ -274,7 +631,32 @@ def route_matches(written, registered): return False segs = lambda p: p.split('?')[0].split('#')[0].rstrip('/').split('/') w, r = segs(written), segs(registered) - return len(w) == len(r) and all(a == b or _PATH_PARAM.fullmatch(b) for a, b in zip(w, r)) + return len(w) == len(r) and all(_segment_matches(a, b) for a, b in zip(w, r)) + + +def _segment_matches(written, registered): + """One segment: equal, or a parameter that accepts what is written there. + + A TYPED CONVERTER NARROWS WHAT IT ACCEPTS. `` serves `/orders/5/` and never `/orders/new/`: the router + tries the next pattern instead, so a test requesting `/orders/new/` does not reach the detail view. A written + segment that is itself a placeholder (`{}`, `%s`, ``) could be any value and still matches. + """ + if written == registered: + return True + if not _PATH_PARAM.fullmatch(registered): + return False + if registered.startswith('/`, the way a router serves the + literal route rather than capturing `settings` as a value.""" + if written in registered_keys: + return [written] + return [k for k in registered_keys if route_matches(written, k)] def all_registrations(q, site_file=None): @@ -312,9 +694,9 @@ def key_edges(q, at, site_file=None, cap=None, use_cap=None): capped = {k for k, ds in reg.items() if len(ds) > cap} | {k for k, cs in writes.items() if len(cs) > use_cap} out = set() for v, callers in writes.items(): - for key, decls in reg.items(): - if key in capped or not (v == key or route_matches(v, key)): continue + for key in route_candidates(v, reg): + if key in capped: continue for c in callers: - for d in decls: + for d in reg[key]: if c != d: out.add((c, d, key)) return sorted(out) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index f4e6b18d9..d753d02ed 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -3,18 +3,20 @@ # # axiomcode index [] [--lang java|typescript|python|javascript|csharp] [--src ] [--library [,…]] # the pipeline: parser → engine → .axiomcode/out/graph.sqlite (+ index). defaults to the current directory. -# axiomcode context "" [] [--in [,…]]… [--budget N] [--source] [--json] [--fresh] +# axiomcode context "" [] [--in [,…]]… [--budget N] [--source] [--json] [--fresh] [--grep] # THE FRONT DOOR: the files and callables a task touches, from the problem statement — when there is no name # to ask about yet. Deterministic: task terms scored against the graph's vocabulary by inverse document # frequency, tests demoted, the closure walked from the best seed per term and ranked by nearest hop. # --in is repeatable and takes a list, and several are COMBINED, not intersected, so a change that # spans two roots is answerable in one call. # Ends by saying what it could not see. Every other verb needs a name you already have; this one does not. -# axiomcode graph [] [--out ] [same flags] -# the graph as one page. Runs the pipeline only when the repo has no up-to-date graph (~1 s otherwise). -# defaults to the current directory; the page goes to /.axiomcode/graph/graph.html, or to --out: a folder -# gets /.html, a .html path is used as given. The page embeds the sources, so a node opens its code. -# axiomcode path [] [--every | --paths N] [--all] [--limit N] [--in ] [--json] [--fresh] +# axiomcode graph [] [--out ] +# the graph as one page, every language the repository was indexed in. Drawn from the existing graph when it is up +# to date (seconds, no engine run); rebuilt first only when a source file changed, and then with the --lang, --src +# and --library it was indexed with, never for a language the index left out; indexed first when there is none. +# Prints what it drew and the page's absolute path: /.axiomcode/graph/graph.html, or --out (a folder gets +# /.html, a .html path is used as given). The page embeds the sources, so a node opens its code. +# axiomcode path [] [--every | --paths N] [--all] [--limit N] [--in ] [--json] [--fresh] [--grep] # the shortest chain of calls from A to B per reached target, and with --every all the routes; through what? # '*' as one endpoint: path '*' X = everything that can reach X, with the entry points among them; path X '*' = everything X reaches. Endpoints exactly as written in the code: Owner.method, # method (free function or any owner), Type (all its methods), file.ts:123, file.py. Datalog over the graph; every @@ -22,7 +24,7 @@ # Every hop carries the LINE THE CALL IS WRITTEN ON, how certain the edge is, and what kind of call it is — # an invocation, a construction, a constructor chain, a super call, a decorator, a property access, a method # reference — in one vocabulary across all five languages. A hop that is not a call is marked and not counted. -# axiomcode impact […] [] [--tests] [--depth N] [--in ] [--limit N] [--json] [--kind k] [--fresh] +# axiomcode impact […] [] [--tests] [--depth N] [--in ] [--limit N] [--json] [--kind k] [--fresh] [--grep] # what has to be looked at again when a declaration changes: a method, a field / constant / enum member, a type, a # parameter (Owner.m(p)), a type parameter (Type), a local (Owner.m:v), Type. / Type.. Prints what # must change with it (overrides, subtypes), what directly touches it with WHY and how sure ([resolved] / [in scope] / @@ -32,8 +34,9 @@ # axiomcode changed [] […] [--range a..b | --staged] [--impact] [--json] # which declarations an edit changed and HOW (signature: params added / removed / retyped, return type; field: its type, # name, initializer; type header; body only; removed; added) — the working tree against the commit the graph was built -# from, or two commits — each with the target `impact` takes; --impact runs impact on all of them as one change set. -# axiomcode test-impact [] [--range .. | --staged] [--in ] [--limit N] [--json] [--why] +# from, or a branch's commits (--range a..b reads from `git merge-base a b`) — each with the target `impact` takes; +# --impact runs impact on all of them as one change set. Without git, name the files. +# axiomcode test-impact [] […] [--range .. | --staged] [--in ] [--limit N] [--json] [--why] [--grep] # which tests actually have to run for this edit: the test files that reach any changed declaration, with the # chain, so a selection can be checked rather than trusted. Conservative by design — a test reached only # through an edge the graph does not encode (reflection, a service loader) will NOT appear. @@ -47,6 +50,9 @@ # language's, the one with the most files, first). --lang restricts it to one language or a comma list, the first the main one; # --src limits the analysed tree (e.g. src); --library names dependency roots. --fresh (context, path, impact) waits for # a refresh in flight and answers from the new graph; without it an answer from a graph older than an edit marks its rows. +# --grep (context, path, impact, test-impact) prints the answer's sites one per line, as grep does: `path:line: [resolved | one of a set | by name | text | hop N | test]`, the first 30 (--grep-limit N) and a count of +# the rest, then whether it was verified and its bound. The MCP tools answer this way by default; full=True is the prose. case "$0" in */*) H="${0%/*}";; *) H=.;; esac; H="$(cd "${H:-/}" && pwd)" # no `dirname`: see bin/axiomcode # PYTHON UNDER ANOTHER NAME (#1331). Every verb runs `python3`, which a python.org install on Windows does not @@ -98,6 +104,8 @@ while [ $# -gt 0 ]; do --src) export AXIOMCODE_SRC="$2"; shift 2 ;; --library) export AXIOMCODE_LIBRARY="$2"; shift 2 ;; --fresh) export AXIOMCODE_FRESH=1; shift ;; + --grep) GREP=1; shift ;; + --grep-limit) GREP=1; GREP_LIMIT="$2"; shift 2 ;; *) ARGS+=("$1"); shift ;; esac done @@ -115,8 +123,8 @@ case "${ARGS[0]:-}" in -h|--help) if [ -n "$cmd" ] && [ "${#ARGS[@]}" = 1 ]; the # check and an exec. The repository is the last argument that is a directory, as the verbs themselves take it, else the # working directory. No graph: nothing here, the verb builds its first one. # `changed` and `test-impact` ask about an edit, against the graph of the baseline (kept aside by a refresh), so they do -# not wait for the graph; they wait only when HEAD moved and the baseline has to follow it. `graph` builds (axiomcode-build) -# before it draws, so it is current by construction. +# not wait for the graph; they wait only when HEAD moved and the baseline has to follow it. `graph` checks the graph +# itself: it draws from it when it is current, and rebuilds it with its own recorded flags only when it is not. # A flag's VALUE is never the repository: `--in plugins/core` names a directory under it, and taking that for the # repository found no .axiomcode/lang there, so a repository in several languages was asked in its main graph alone and # a scope that only another language's graph holds was refused as absent. @@ -125,6 +133,11 @@ FR=.; skip=""; for a in ${ARGS[@]+"${ARGS[@]}"}; do case "$a" in --in|--from|--budget|--seeds|--depth|--limit|--tests-in|--kind|--range|--old|--new|--file|--page|--page-budget|--out) skip=1; continue ;; esac [ -d "$a" ] && FR="$a" done +# --grep (context, path, impact, test-impact): the answer's sites one per line, `path:line: code [tag]`, the way grep +# prints them (ax_grep.py). The verb answers with --json and the sites are printed from that document, so the two +# shapes are one answer. Without it the answer is the verb's own, unchanged. +G=() +case "$cmd" in context|path|impact|test-impact|tests) [ -n "${GREP:-}" ] && G=(python3 "$H/ax_grep.py" "$cmd" "$FR" --limit "${GREP_LIMIT:-30}" --) ;; esac # A REPOSITORY IN SEVERAL LANGUAGES has one graph per language (.axiomcode/lang/ beside the main one): a query # asks every one of them (ax_langs.py), so no language's code is left out of an answer. A graph named in # AXIOMCODE_GRAPH was chosen by the caller and is asked alone. @@ -132,18 +145,18 @@ Q=""; [ -d "$FR/.axiomcode/lang" ] && [ -z "${AXIOMCODE_GRAPH:-}" ] && Q="$H/ax_ # (-L too: a pointer whose target is gone is a graph to repair — the refresher does it — not a repository without one) if [ -f "$FR/.axiomcode/out/graph.sqlite" ] || [ -L "$FR/.axiomcode/out/graph.sqlite" ]; then case "$cmd" in - context|path|impact) exec python3 "$H/ax_fresh.py" query "$FR" "$cmd" -- python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-$cmd" ${ARGS[@]+"${ARGS[@]}"} ;; + context|path|impact) exec python3 "$H/ax_fresh.py" query "$FR" "$cmd" -- ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-$cmd" ${ARGS[@]+"${ARGS[@]}"} ;; changed|test-impact|tests) python3 "$H/ax_fresh.py" baseline "$FR" "${AXIOMCODE_BASELINE_WAIT:-30}" || true ;; esac fi case "$cmd" in index|build) exec bash "$H/axiomcode-build" ${ARGS[@]+"${ARGS[@]}"} ;; - context) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-context" ${ARGS[@]+"${ARGS[@]}"} ;; - path) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-path" ${ARGS[@]+"${ARGS[@]}"} ;; - impact) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-impact" ${ARGS[@]+"${ARGS[@]}"} ;; + context) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-context" ${ARGS[@]+"${ARGS[@]}"} ;; + path) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-path" ${ARGS[@]+"${ARGS[@]}"} ;; + impact) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-impact" ${ARGS[@]+"${ARGS[@]}"} ;; changed) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-changed" ${ARGS[@]+"${ARGS[@]}"} ;; - test-impact|tests) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-test-impact" ${ARGS[@]+"${ARGS[@]}"} ;; - graph) exec python3 "$H/axiomcode-graph" build ${ARGS[@]+"${ARGS[@]}"} ;; + test-impact|tests) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-test-impact" ${ARGS[@]+"${ARGS[@]}"} ;; + graph) exec python3 "$H/axiomcode-graph" ${ARGS[@]+"${ARGS[@]}"} ;; install) exec python3 "$H/axiomcode-install" ${ARGS[@]+"${ARGS[@]}"} ;; --verbs) verbs ;; # what this dispatcher answers to; bin/axiomcode asks, so the two cannot drift ""|-h|--help|help) if [ ${#ARGS[@]} -gt 0 ]; then verbhelp "${ARGS[0]}"; else helptext; fi ;; diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build index a2c2b97d2..83049cf50 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build @@ -5,7 +5,9 @@ # AXIOMCODE_ENGINE checkout of axiomcodegraph (default: the one this skill lives in) # AXIOMCODE_SRC subtree to analyse (e.g. one module of a monorepo); the graph still lives at /.axiomcode # AXIOMCODE_LANG restrict to java | typescript | python | javascript | csharp, or a comma list of them, the first being -# the main one (default: every language with files; the main one is the language with the most) +# the main one (default: every language with files; the main one is the language with the most). +# A rebuild that names no language keeps the ones an earlier --lang chose (and its --src); pass +# --lang again, or AXIOMCODE_REINDEX=1, to choose again # AXIOMCODE_LIBRARY comma-separated library roots (source trees or IR) passed as --library; without them, calls into # dependencies are declared unknown rather than guessed # A repository in several languages gets one graph per language. The main language's is the one it always had, @@ -15,7 +17,23 @@ # language's compile; until the last one is in, .axiomcode/out/building names the languages still to come. set -euo pipefail H="$(cd "$(dirname "$0")" && pwd)" -REPO="$(cd "${1:-.}" && pwd)"; SRC="$REPO/${AXIOMCODE_SRC:-}"; SRC="${SRC%/}" +REPO="$(cd "${1:-.}" && pwd)" +# A REBUILD THAT NAMES NO LANGUAGE KEEPS THE LANGUAGES THE INDEX CHOSE. Every rebuild comes here: an explicit `index`, +# the background refresh after an edit or a commit, a query that repairs a broken graph, `graph`. One that named no +# language detected every language in the tree, so on a repository indexed with --lang csharp it solved a few stray +# .ts and .js files too (a JavaScript Souffle compile of up to an hour), left a graph for each that later queries +# answered from, and recorded the detected list as the index's choice, so every refresh after it did the same. When +# the graph here was built with an explicit --lang (lang_auto false in its file table), a request with no language +# keeps that --lang, and the --src with it when none is given; a graph whose languages were detected is detected again. +if [ -z "${AXIOMCODE_LANG:-}" ] && [ -z "${AXIOMCODE_REINDEX:-}" ]; then + KEPT="$(python3 "$H/ax_fresh.py" chosen "$REPO" 2>/dev/null || true)" + if [ -n "$KEPT" ]; then + AXIOMCODE_LANG="${KEPT%%$'\t'*}"; export AXIOMCODE_LANG + [ -n "${AXIOMCODE_SRC:-}" ] || { AXIOMCODE_SRC="${KEPT#*$'\t'}"; export AXIOMCODE_SRC; } + [ -n "${AXIOMCODE_BACKGROUND:-}" ] || echo "keeping the languages this graph was indexed with (--lang $AXIOMCODE_LANG${AXIOMCODE_SRC:+ --src $AXIOMCODE_SRC}); pass --lang, or AXIOMCODE_REINDEX=1, to choose again" + fi +fi +SRC="$REPO/${AXIOMCODE_SRC:-}"; SRC="${SRC%/}" # THE ENGINE IS CHOSEN THE SAME WAY EVERY TIME, AND A BUILD THAT FINDS NONE SAYS WHERE IT LOOKED. The background # refresh runs this script from a hook, with the hook's PATH, the hook's Node and, installed from a marketplace, from a # plugin COPIED outside any checkout; an explicit `axiomcode index` runs it from inside the npm package. Each found an @@ -117,7 +135,13 @@ if ! python3 "$H/ax_fresh.py" lock 9 --try; then [ -n "${AXIOMCODE_BACKGROUND:-}" ] || echo "a graph build is already running for $REPO; waiting for it …" python3 "$H/ax_fresh.py" lock 9 || { echo "could not take the build lock ($REPO/.axiomcode/build.lock)"; exit 75; } fi +python3 "$H/ax_fresh.py" relink "$REPO" --locked || true # a pointer copied from another checkout is this one's again (#1605) BUILD_T0=$SECONDS # how long a build takes to a usable graph: a query decides from it whether to wait (#1595) +# THE QUERY RULES COMPILE BESIDE THE ENGINE, NOT AFTER THE GRAPH (#1606). They do not depend on the graph, only on this +# plugin's dl/*.dl, and impact.dl alone takes 45-140 s. Started once the graph was published, the compile raced the first +# query, which then paid it (44 s on a C# library). Now it starts here, detached, into the user's cache, where every +# plugin copy with the same rules finds it; a query that still comes first answers with the interpreter meanwhile. +python3 "$H/dl_program.py" --background 2>/dev/null || true GD="$(cd "$REPO" && git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)" [ -n "$GD" ] && mkdir -p "$GD/info" && { grep -qx '.axiomcode' "$GD/info/exclude" 2>/dev/null || echo '.axiomcode' >> "$GD/info/exclude"; } || true STAMP="$(cd "$REPO" && git rev-parse HEAD 2>/dev/null || echo nogit)-$LANGS-${AXIOMCODE_SRC:-.}-${AXIOMCODE_LIBRARY:-nolib}" @@ -201,6 +225,7 @@ build_head_graph(){ if env -u AXIOMCODE_BACKGROUND -u AXIOMCODE_KEEP_BASE AXIOMCODE_NO_REFRESH=1 AXIOMCODE_LIBRARY="$LIBS" bash "$H/axiomcode-build" "$T" > "$REPO/.axiomcode/base-build.log" 2>&1; then # graph.sqlite is a symlink, or (Windows without the symlink privilege) the copy Git Bash made instead of one D="$(readlink "$T/.axiomcode/out/graph.sqlite" 2>/dev/null || true)"; [ -n "$D" ] || D="$T/.axiomcode/out/graph.sqlite" + case "$D" in /*|[A-Za-z]:*) ;; *) D="$T/.axiomcode/out/$D" ;; esac # the pointer is relative to its directory if [ -f "$D" ]; then local NB="$REPO/.axiomcode/base.new"; rm -rf "$NB"; mkdir -p "$NB/out"; mv "$D" "$NB/out/graph.sqlite" for D in "$T"/.axiomcode/lang/*/out/graph.sqlite; do # and every other language's, beside it @@ -219,7 +244,7 @@ LANGDIR="$REPO/.axiomcode/lang" # one graph directory per language other th # file the parser reads with its hash; when none differs the graph is current whatever git says: a dirty tree whose # edits are already indexed is not rebuilt, and a tree edited and restored, which git calls clean against a graph built # from the edit, is. A commit that changed no file moves the stamp, not the graph. -if [ -f "$OUT/graph.sqlite" ] && [ -f "$OUT/stamp" ] && python3 "$H/ax_fresh.py" uptodate "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" "${STAMP#"$PREFIX"}"; then +if [ -f "$OUT/graph.sqlite" ] && [ -f "$OUT/stamp" ] && [ ! -f "$OUT/partial" ] && AXIOMCODE_ENGINE="$ENGINE" python3 "$H/ax_fresh.py" uptodate "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" "${STAMP#"$PREFIX"}"; then if [ "$(cat "$OUT/stamp")" != "$STAMP" ]; then echo "$STAMP" > "$OUT/stamp"; INDEXED_TREE="$OLD_INDEXED"; set_base "$OLD_INDEXED"; PREV=""; keep_base_graph; commit_base; fi has_symbols "$OUT/graph.sqlite" || python3 "$H/axiomcode-index" "$REPO" [ -f "$REPO/.axiomcode/engine" ] || printf '%s\n' "$ENGINE" > "$REPO/.axiomcode/engine" || true @@ -229,11 +254,9 @@ if [ ! -f "$OUT/files.json" ] && [ -f "$OUT/stamp" ] && [ "$(cat "$OUT/stamp")" has_symbols "$OUT/graph.sqlite" || python3 "$H/axiomcode-index" "$REPO" python3 "$H/axiomcode-impact" --warm "$REPO" || true # export the graph's facts now (~9 s on 1,373 files): the first # query pays it otherwise, and for the plugin that first query is inside hooks/changes.py's timeout=14, several at once. -python3 "$H/dl_program.py" || true # compile the query rules once, here where ~20 s is noise against the build: -# a later query under a caller's timeout (hooks/changes.py runs impact with timeout=14) cannot outlast the compile, so -# left to first use it is killed every time, caches nothing, and repeats on the next edit forever. +python3 "$H/dl_program.py" --no-wait || true # say whether the query rules are compiled (started at the top); never wait # a graph from before the file table: it was built from this clean commit, so the table of the files now IS its table - python3 "$H/ax_fresh.py" snapshot "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" > "$OUT/.files.json.new" 2>/dev/null && mv "$OUT/.files.json.new" "$OUT/files.json" || rm -f "$OUT/.files.json.new" + AXIOMCODE_BUILT_BY_UNKNOWN=1 python3 "$H/ax_fresh.py" snapshot "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" > "$OUT/.files.json.new" 2>/dev/null && mv "$OUT/.files.json.new" "$OUT/files.json" || rm -f "$OUT/.files.json.new" echo "graph up to date ($LANG_ARG) at $OUT/graph.sqlite"; exit 0; fi if [ -z "$OTHER_LANGS" ]; then echo "building $LANG_ARG graph for $SRC … (java $ja · typescript $ts · python $py · javascript $js · csharp $cs files)" else echo "building ${LANGS//,/ + } graphs for $SRC … (java $ja · typescript $ts · python $py · javascript $js · csharp $cs files)"; fi @@ -244,8 +267,9 @@ else echo "building ${LANGS//,/ + } graphs for $SRC … (java $ja · typescript # throwaway index: the user's index and checkout are not touched, and .axiomcode is excluded above. Kept only if the # build succeeds. # THE FILE TABLE, taken now for the same reason: an edit made while the parser runs must leave the graph stale, so the -# next refresh picks it up. Committed only if the build succeeds. -python3 "$H/ax_fresh.py" snapshot "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" > "$OUT/.files.json.new" 2>/dev/null || rm -f "$OUT/.files.json.new" +# next refresh picks it up. Committed only if the build succeeds. It records what built the graph, the engine above +# and this plugin's rules, so a graph an older axiomcode built is not "up to date" after an update. +AXIOMCODE_ENGINE="$ENGINE" python3 "$H/ax_fresh.py" snapshot "$REPO" "$LANGS" "${AXIOMCODE_SRC:-}" > "$OUT/.files.json.new" 2>/dev/null || rm -f "$OUT/.files.json.new" INDEXED_TREE="" if [ -n "$GD" ]; then TMPIDX="$(mktemp "${TMPDIR:-/tmp}/axiomcode-index.XXXXXX")" @@ -274,7 +298,10 @@ rm -rf "$PREV" # is seconds); only then does the build fail, and says why. swap(){ local i=0; until mv -f "$1" "$2" 2>/dev/null; do i=$((i+1)); [ $i -ge 120 ] && { echo "could not replace $2: another process holds it open" >&2; return 1; }; sleep 0.5; done; } keep_live(){ ln -f "$1" "$OUT/.live.sqlite" 2>/dev/null || cp -p "$1" "$OUT/.live.sqlite"; } # a hard link, or a copy where the file system has none -point(){ ln -sfn "$1" "$OUT/.graph.sqlite.new" && swap "$OUT/.graph.sqlite.new" "$OUT/graph.sqlite"; } +# THE POINTER IS RELATIVE TO ITS DIRECTORY (#1605). An absolute one survived a copy of the repository: the copy answered +# from the original's graph, and once the original moved it rebuilt itself as "interrupted". Made from inside $OUT, so +# Git Bash's copy in place of a link resolves the same relative name. +point(){ local t="${1#"$OUT"/}"; (cd "$OUT" && ln -sfn "$t" .graph.sqlite.new) && swap "$OUT/.graph.sqlite.new" "$OUT/graph.sqlite"; } real(){ python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$1" 2>/dev/null; } if [ -d "$OUT/$LANG_ARG" ]; then if [ -f "$OUT/$LANG_ARG/graph.sqlite" ]; then @@ -332,7 +359,22 @@ unpend(){ grep -v "^$1 " "$PENDING" > "$PENDING.new" 2>/dev/null || true; mv -f EPID="" stop_engine(){ if [ -n "$EPID" ] && kill -0 "$EPID" 2>/dev/null; then kill -TERM -- "-$EPID" 2>/dev/null || kill -TERM "$EPID" 2>/dev/null || true; fi; rm -f "$PROGRESS" "$PENDING" "$PENDING.new"; } trap stop_engine EXIT -trap 'exit 130' INT; trap 'exit 143' TERM HUP +# A BUILD STOPPED ONCE ITS MAIN LANGUAGE IS SOLVED PUBLISHES THAT GRAPH FIRST. The solved main graph is most of what the +# build cost; stopped while it was being indexed (a caller's timeout ends the whole process group, the indexer included), +# the build took the indexer's death for a failure, deleted the solved graph and left no graph.sqlite, so the next query +# built everything again, was stopped again, and a repository in several languages rebuilt forever. A signal that +# arrives after the engine names the main language solved, or while it is published, is held until graph.sqlite points +# at it (and the indexer runs in a process group of its own, which a signal to the build's does not reach); the build +# then stops, the other languages unpublished. One that arrives earlier stops the build at once, as it always did. +SIG=""; SIGS=0; PUBLISHING=""; MAIN_DONE="" +on_signal(){ SIGS=$((SIGS + 1)); SIG="$1" + if [ -z "$MAIN_DONE" ] && { [ -n "$PUBLISHING" ] || grep -q "^$LANG_ARG ok" "$PROGRESS" 2>/dev/null; }; then return 0; fi + exit "$1"; } +trap 'on_signal 130' INT; trap 'on_signal 143' TERM HUP +# run a command in a process group of its own and wait for it; a held signal interrupts `wait`, not the command +shielded(){ local p rc n; set -m; "$@" & p=$!; set +m + while :; do n=$SIGS; rc=0; wait "$p" || rc=$?; [ "$SIGS" != "$n" ] && [ "$rc" -gt 128 ] && kill -0 "$p" 2>/dev/null && continue; break; done + return "$rc"; } # an engine older than this script has no --progress and refuses an option it does not know: it gets none, and its # graphs are published together when it ends, as they always were PROG=(); grep -q -- '--progress' "$ENGINE/bin/axiomcode" 2>/dev/null && PROG=(--progress "$PROGRESS") @@ -340,10 +382,11 @@ set -m "$ENGINE/bin/axiomcode" all "$SRC" "$OUT" --language "$SOLVE" ${PROG[@]+"${PROG[@]}"} ${LIB[@]+"${LIB[@]}"} < /dev/null > "$REPO/.axiomcode/build.log" 2>&1 & EPID=$! set +m -MAIN_DONE=""; QUEUED=(); READY=(); FAILED=(); DONE=" "; SEEN=0 +QUEUED=(); READY=(); FAILED=(); DONE=" "; SEEN=0 # THE MAIN LANGUAGE'S GRAPH BECOMES THE GRAPH. Everything a finished build did for it happens here, as soon as it is # solved: index, stamp, file table, baseline, pointer. publish_main(){ + PUBLISHING=1 # the new database: a FILE under $OUT/$LANG_ARG. Matched as `*/$LANG_ARG/*` it was any path with the language's name # in it, the repository's own included: a repository at .../python/app matched the graph.sqlite SYMLINK, the link was # pointed at itself, the previous graph deleted, and every verb after it reported no graph. @@ -353,7 +396,7 @@ publish_main(){ # A QUERY NEVER READS A HALF-BUILT GRAPH. The new database gets its symbols, refs and views BEFORE graph.sqlite points # at it; until the pointer moves, every verb (and the hooks) keeps answering from the previous graph. Only then is the # old one removed: a reader that opened it keeps the file it opened. - AXIOMCODE_INDEX_DB="$DB" python3 "$H/axiomcode-index" "$REPO" || { restore; echo "indexing the new graph failed — the graph that was there is untouched"; exit 1; } + shielded env AXIOMCODE_INDEX_DB="$DB" python3 "$H/axiomcode-index" "$REPO" || { restore; echo "indexing the new graph failed — the graph that was there is untouched"; exit 1; } stamp_meta "$DB" echo "$STAMP" > "$OUT/stamp" # the engine that built this graph: the next build, the background refresh above all, starts its search there (see the top) @@ -375,7 +418,13 @@ publish_main(){ done commit_base rm -rf "$PREV" - MAIN_DONE=1 + MAIN_DONE=1; PUBLISHING="" + # the languages still unsolved are named in $OUT/partial: the graph is not up to date while it is there, so the next + # build (a refresh the next query starts, or an index) builds them rather than calling the file table current + if [ -n "$SIG" ]; then + [ -s "$PENDING" ] && cut -d' ' -f1 "$PENDING" > "$OUT/partial" + echo "$LANG_ARG graph published at $OUT/graph.sqlite; stopped before the other languages were built"; exit "$SIG" + fi python3 - "$OUT/graph.sqlite" <<'PY' import sqlite3, sys rows = sqlite3.connect(sys.argv[1]).execute("SELECT tier, count(*) FROM call_edges GROUP BY tier ORDER BY 2 DESC").fetchall() @@ -385,9 +434,9 @@ for t, n in rows: print(f"{t:<{w}} {n}") PY python3 "$H/axiomcode-impact" --warm "$REPO" || true # export the graph's facts now (~9 s on 1,373 files): the first # query pays it otherwise, and for the plugin that first query is inside hooks/changes.py's timeout=14, several at once. - python3 "$H/dl_program.py" || true # compile the query rules once, here where ~20 s is noise against the build: - # a later query under a caller's timeout (hooks/changes.py runs impact with timeout=14) cannot outlast the compile, so - # left to first use it is killed every time, caches nothing, and repeats on the next edit forever. + # the query rules: their compile started at the top of the build, detached (a caller's timeout cannot kill it half-way, + # which cached nothing and repeated on every edit); this only says where it is. A query never waits for it. + python3 "$H/dl_program.py" --no-wait || true [ -n "$BASE_SAVED" ] && AXIOMCODE_GRAPH="$REPO/.axiomcode/base" python3 "$H/axiomcode-impact" --warm "$REPO" >/dev/null 2>&1 || true [ -z "$OTHER_LANGS" ] || [ ! -s "$PENDING" ] || echo "$LANG_ARG graph ready at $OUT/graph.sqlite; still building: $(cut -d' ' -f1 "$PENDING" | paste -sd, - | sed 's/,/, /g')" } @@ -424,7 +473,7 @@ while :; do running=1; kill -0 "$EPID" 2>/dev/null || running=0 drain [ "$running" = 1 ] || break - sleep 1 + sleep 1 || true # a held signal ends the sleep, not the build done ENGINE_RC=0; wait "$EPID" || ENGINE_RC=$?; EPID="" drain @@ -438,7 +487,7 @@ fi for l in ${OTHER_LANGS//,/ }; do case "$DONE" in *" $l "*) ;; *) publish_other "$l" ok;; esac done -rm -f "$PROGRESS" "$PENDING" +rm -f "$PROGRESS" "$PENDING" "$OUT/partial" if [ ${#FAILED[@]} -gt 0 ]; then for l in "${FAILED[@]}"; do if [ -f "$LANGDIR/$l/out/graph.sqlite" ]; then echo "⚠️ the $l graph did not build: $l questions are answered from its PREVIOUS graph, which predates this tree; see $REPO/.axiomcode/build.log" diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index 379b72c42..3ee46475a 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -29,6 +29,62 @@ G, die = P.G, P.die def sh(*a, cwd=None): r = subprocess.run(a, capture_output=True, text=True, cwd=cwd); return r.stdout if r.returncode == 0 else None +# the extensions a graph can hold a declaration in; any other changed file is outside every indexed language +CODE_EXT = r'\.(java|ts|tsx|js|mjs|cjs|py|cs)$' + +def is_git(repo): + return sh('git', 'rev-parse', '--git-dir', cwd=repo) is not None + +def resolve_range(repo, rng): + """(old rev, new rev, note) for `--range`. `a..b` means what an agent asking about ITS commits means: what b added since + it forked from a, so the old side is `git merge-base a b`, as `a...b` already is. Diffing the two TIPS, as this did, + reported every commit a received after the fork (the base branch moving under a feature branch) as the agent's own + change, and test-impact selected their tests. `a` alone is a..HEAD. The note says when the base is not a itself.""" + if '...' in rng: a, b, dots = *rng.split('...', 1), 3 + elif '..' in rng: a, b, dots = *rng.split('..', 1), 2 + else: a, b, dots = rng, 'HEAD', 2 + a, b = a or 'HEAD', b or 'HEAD' + ha = (sh('git', 'rev-parse', '--verify', '-q', f'{a}^{{commit}}', cwd=repo) or '').strip() + hb = (sh('git', 'rev-parse', '--verify', '-q', f'{b}^{{commit}}', cwd=repo) or '').strip() + if not ha or not hb: + die(f"--range {rng}: {a if not ha else b!r} is not a commit in {repo}" + ('' if is_git(repo) else ' (it is not a git checkout)') + + " — name the edited files instead: `changed …` / `test-impact …`") + mb = (sh('git', 'merge-base', ha, hb, cwd=repo) or '').strip() + if not mb: + return ha, hb, f"range: {a} and {b} share no history, so this compares the two tips" + if mb == ha: return ha, hb, None # a is an ancestor of b: exactly the old answer + moved = (sh('git', 'rev-list', '--count', f'{mb}..{ha}', cwd=repo) or '?').strip() + return mb, hb, (f"range base: merge-base {mb[:10]} of {a} and {b}, not {a} itself — {a} has moved {moved} commit(s) since " + f"{b} forked from it, and those are not changes of {b}'s; this answers for {b}'s own commits" + + (" (a...b already means this)" if dots == 3 else '')) + +def branch_suggestion(repo): + """when the working tree matches the baseline but HEAD holds commits of its own: the nearest ref HEAD forked from, and + how many commits HEAD has since, so the answer can say `--range ..HEAD` instead of a bare 'no change'. The + nearest is the candidate ref (the remote's default, the usual base names, then recent branches; never this branch's + own remote copy, which is where its commits are pushed, not where it forked) with the fewest commits between it and + HEAD, and at least one.""" + head = (sh('git', 'rev-parse', '-q', '--verify', 'HEAD', cwd=repo) or '').strip() + if not head: return None + fmt = '--format=%(refname:short) %(objectname)' + usual = [f'refs/{k}/{n}' for k in ('heads', 'remotes/origin') for n in ('main', 'master', 'dev', 'develop')] + rows = (sh('git', 'for-each-ref', fmt, *usual, cwd=repo) or '').split('\n') + rows += (sh('git', 'for-each-ref', '--sort=-committerdate', '--count=40', fmt, 'refs/heads', 'refs/remotes', cwd=repo) or '').split('\n') + cur = (sh('git', 'rev-parse', '--abbrev-ref', 'HEAD', cwd=repo) or '').strip() + # on a base branch itself (or its remote's default) there is no fork to name: its commits are the base's own + dflt = (sh('git', 'symbolic-ref', '-q', '--short', 'refs/remotes/origin/HEAD', cwd=repo) or '').strip() + if cur in ('main', 'master', 'dev', 'develop', 'trunk') or (dflt and cur == dflt.split('/', 1)[-1]): return None + best, seen = None, set() + for row in rows: + if ' ' not in row: continue + r, h = row.split(' ', 1) + if h in seen or h == head or r in (cur, f'origin/{cur}') or r.endswith('/HEAD'): continue + seen.add(h) + # commits HEAD has that the ref does not: HEAD's own since it forked from the ref (one git call per ref) + n = int((sh('git', 'rev-list', '--count', f'{h}..{head}', cwd=repo) or '0').strip() or 0) + if n >= 1 and (best is None or n < best[1]): best = (r, n) + return best + def strip_code(t, strings=True, hash_comments=True): """the text with comments and string literals blanked, same length and line breaks — a brace in a string is not a brace. strings=False blanks only the comments: a string is still skipped, so a `//` or `/*` inside one is not a comment. @@ -121,7 +177,8 @@ class Changed: # ── the two texts of a file ──────────────────────────────────────────────────────────────────────────────── def texts(self, rel, mode, rng): if mode == 'range': - a, b = rng.split('..'); return sh('git', 'show', f'{a}:{rel}', cwd=self.repo) or '', sh('git', 'show', f'{b}:{rel}', cwd=self.repo) or '' + a, b = self.range_old, self.range_new # the merge-base, not a's tip (resolve_range) + return sh('git', 'show', f'{a}:{rel}', cwd=self.repo) or '', sh('git', 'show', f'{b}:{rel}', cwd=self.repo) or '' base = self.base_tree or (self.built_at if self.built_at and self.built_at != 'nogit' and sh('git', 'cat-file', '-e', self.built_at, cwd=self.repo) is not None else 'HEAD') old = sh('git', 'show', f'{base}:{rel}', cwd=self.repo) or '' if mode == 'staged': new = sh('git', 'show', f':{rel}', cwd=self.repo) or '' @@ -130,8 +187,12 @@ class Changed: except OSError: new = '' return old, new def files(self, mode, rng, given): - if given: return [self.rel(f) for f in given] - if mode == 'range': out = sh('git', 'diff', '--name-only', rng, cwd=self.repo) + self.outside = [] + if given: + rels = [self.rel(f) for f in given] + self.outside = [r for r in rels if not self.is_code(r)] + return [r for r in rels if self.is_code(r)] + if mode == 'range': out = sh('git', 'diff', '--name-only', self.range_old, self.range_new, cwd=self.repo) elif mode == 'staged': out = sh('git', 'diff', '--name-only', '--cached', *([self.base_tree] if self.base_tree else []), cwd=self.repo) else: base = self.base_tree or (self.built_at if self.built_at and self.built_at != 'nogit' else 'HEAD') @@ -142,15 +203,28 @@ class Changed: # counts when the baseline does not have it, or has it with other content (an explicit index records # untracked files in the tree it indexed). extra = [] + other = 0 for u in (sh('git', 'ls-files', '--others', '--exclude-standard', cwd=self.repo) or '').split('\n'): - if not re.search(r'\.(java|ts|tsx|js|mjs|cjs|py|cs)$', u): continue + if not u.strip() or u.startswith('.axiomcode/'): continue + if not self.is_code(u): # a new non-source file: named, not analysed + other += 1 + if other > 200: continue had = (sh('git', 'rev-parse', '--verify', '-q', f'{base}:{u}', cwd=self.repo) or '').strip() if not had or had != (sh('git', 'hash-object', u, cwd=self.repo) or '').strip(): extra.append(u) out = (out or '') + ''.join('\n' + u for u in extra) # the extension list is the ONLY gate on what a diff can contain, so a language missing from it is # not degraded here, it is absent: `changed` answers [] for every edit and test-impact, which is # `changed` plus impact --tests, can never select anything from it (#1083) - return [l for l in (out or '').split('\n') if re.search(r'\.(java|ts|tsx|js|mjs|cjs|py|cs)$', l)] + # A FILE OUTSIDE EVERY INDEXED LANGUAGE IS STILL A CHANGE (#1470). Fixtures, case data, a schema, a template: + # dropped here, an edit to them answered "no change" and test-impact "no test can be selected", which reads as + # "nothing to test". They are kept apart, named, and test-impact looks for the tests that load them. + lines = [l for l in (out or '').split('\n') if l.strip()] + self.outside = [l for l in lines if not self.is_code(l)] + return [l for l in lines if self.is_code(l)] + def is_code(self, rel): + """a file a graph can hold declarations in: a source extension, or a file this graph holds whatever its name (a + script with a python shebang and no extension, #1376)""" + return bool(re.search(CODE_EXT, rel)) or rel in self.spans # ── the text-level structure of a declaration ───────────────────────────────────────────────────────────── @staticmethod @@ -182,6 +256,23 @@ class Changed: res.append((name.lstrip('*.'), re.sub(r'\s+', ' ', typ))) return res @staticmethod + def before_expression_body(h): + """a header's text up to an expression body's `=>` written after its parameter list, at bracket depth 0 (a + lambda passed as a default value inside the parameters stays in them)""" + depth = 0; seen = False + for k, ch in enumerate(h): + if ch in '([{<': depth += 1; seen = seen or ch == '(' + elif ch in ')]}>' and not (ch == '>' and k and h[k - 1] == '='): depth = max(0, depth - 1) + elif ch == '=' and h[k + 1:k + 2] == '>' and depth == 0 and seen: return h[:k].rstrip() + return h + @staticmethod + def lambda_header(line): + """the parameter list of the lambda written on a line, as text: `lambda x, y:` (Python), `(x, y) =>` / `x =>` + (C#, Java's `->`); None when the line holds no lambda header""" + t = strip_code(line) + m = re.search(r'\blambda\b([^:]*):', t) or re.search(r'(\([^()]*\)|[A-Za-z_]\w*)\s*(?:=>|->)', t) + return m.group(1).strip().strip('()').strip() if m else None + @staticmethod def field_parts(line): """(declared type, name) of a field line, in either spelling: `Type name = …;` or `name: Type = …`""" t = re.sub(r'\s*(//.*|/\*.*?\*/\s*|#.*)$', '', line).strip() @@ -212,16 +303,40 @@ class Changed: got = {i + 1 for i in range(i1, i2) if not noise(OL[i])}; old_changed |= got; removed_lines |= got elif tag == 'insert': if any(not noise(NL[j]) for j in range(j1, j2)): added.append((i1, j1 + 1, j2)) # after old line i1, new lines j1+1..j2 + # AN INSERTION IS PLACED AS LATE AS THE TEXT ALLOWS. When a block's first line equals the line after it, the same + # diff can put it one line later; difflib picks the earliest, so a type added below another began with that + # type's own `}` and sat INSIDE it: its interface and fields were named as members of the type above. Sliding + # past the equal lines puts it after the closing brace (git's diff does the same with its sliders) + slid = [] + for after, s_, e_ in added: + while e_ < len(NL) and after < len(OL) and NL[s_ - 1] == NL[e_] and OL[after] == NL[e_]: + new_of[after + 1] = s_; after += 1; s_ += 1; e_ += 1 + slid.append((after, s_, e_)) + added = slid decls = self.decl_spans(rel, getattr(self, 'mode', 'worktree')) - if not decls and not old.strip() and new.strip(): # a new file: its declarations, all added - decls = []; added = [(0, 1, len(NL))]; old_changed = set(); removed_lines = set(); new_of = {}; replaced = [] - elif not decls: return [], [(rel, 'no declarations known here (not in the graph, or a different file spelling)', None)] + if not old.strip() and new.strip(): return self.new_file(rel, new, decls, is_py), [] + if not decls: return [], [(rel, 'no declarations known here (not in the graph, or a different file spelling)', None)] hits = {} - def narrowest(ln, kinds=None): + # A LAMBDA IS PART OF WHAT ENCLOSES IT. Every lambda carries one name (``), so taken as the declaration + # an edit changed, a body edit inside one came back as "signature -x", handed to impact as ``, + # which matched every lambda in the graph (hundreds, most of them in unrelated fixtures), and test-impact ran + # their tests. An edited line is charged to the narrowest declaration that is not a lambda; a lambda stands on + # its own only where no method or field encloses it (top-level code), named `Enclosing.` and + # targeted by file:line (lambda_decl). + is_lam = lambda d: d[5] in P.LAMBDA_NAMES and d[3] not in ('field', 'const', 'enum_member', 'variable') + def narrowest(ln, kinds=None, lambdas=False): best = None for a, b, i, k, d, n in decls: + if is_lam((a, b, i, k, d, n)) != lambdas: continue if a <= ln <= b and (kinds is None or k in kinds) and (best is None or (b - a) < (best[1] - best[0])): best = (a, b, i, k, d, n) return best + def lambda_decl(ln, m): + """the lambda that stands on its own at ln: one spans the line, and no method (a module body is not one) + holds it, nor a field whose initializer it is""" + lam = narrowest(ln, None, lambdas=True) + if not lam or (m and m[3] != 'module' and m[0] <= lam[0] and lam[1] <= m[1]): return None + if any(k in ('field', 'const', 'enum_member', 'variable') and a <= lam[0] <= max(b, a) for a, b, i, k, d, n in decls): return None + return lam def decl_after(ln, L): """the declaration whose header starts within 3 lines after ln (an annotation / decorator line belongs to it)""" for a, b, i, k, d, n in decls: @@ -239,8 +354,13 @@ class Changed: for ln in sorted(old_changed): if OL[ln - 1].strip().startswith('@') and any(ln in range(1, dd[0]) for dd in decorated): continue f = next(((a, b, i, k, d, n) for a, b, i, k, d, n in decls if a == ln and k in ('field', 'const', 'enum_member', 'variable')), None) + # a lambda's lines inside a field's initializer are the field's + if not f: f = next(((a, b, i, k, d, n) for a, b, i, k, d, n in decls if k in ('field', 'const', 'enum_member', 'variable') and a < ln + and any(is_lam(x) and x[0] <= ln <= x[1] and a <= x[0] <= max(b, a) for x in decls)), None) m = narrowest(ln, {'method', 'function', 'constructor', 'module'}) t = narrowest(ln, {'class', 'interface', 'enum', 'type', 'namespace'}) + lam = None if f else lambda_decl(ln, m) + if lam: hits.setdefault(('lambda', lam), set()).add(ln); continue key = None # a callable that starts on the type's own header line and ends within the type is one the front end # synthesised for it (an implicit constructor, a Python class body): the header is the TYPE's, or a new class @@ -273,6 +393,15 @@ class Changed: decs = decorated.get((a, b, i, k, d, n)) if a > len(OL): continue # starts past the committed file: nothing of it is there entry = dict(kind=kind, symbol=d, id=i, file=rel, line=a, end=b, old_lines=sorted(x for x in lines if x > 0), target_kind=('param' if kind == 'signature' else KIND(k))) + if kind == 'lambda': + # a lambda at the top level: its own body. Named by where it is, targeted by file:line in the graph's own + # lines; its parameter list is compared as written, so an unchanged header is never a parameter diff + entry.update(kind='body', symbol=self.g.lambda_label(i), target=self.g.lambda_target(i) or f"{rel}:{a}", target_kind='method', lambda_=True) + na = new_of.get(a) + oh, nh = self.lambda_header(OL[a - 1] if a <= len(OL) else ''), self.lambda_header(NL[na - 1] if na and na <= len(NL) else '') + if oh is not None and nh is not None and re.sub(r'\s', '', oh) != re.sub(r'\s', '', nh): entry['detail'] = f"lambda parameters ({oh}) → ({nh})" + if a in removed_lines and all(x in removed_lines for x in range(a, min(b, len(OL)) + 1) if not noise(OL[x - 1])): entry['kind'] = 'removed' + out.append(entry); continue span_lines = [x for x in range(a, min(b, len(OL)) + 1) if not noise(OL[x - 1])] gone = a in removed_lines and span_lines and sum(1 for x in span_lines if x in removed_lines) >= 0.6 * len(span_lines) and \ not any(re.search(rf'\b{re.escape(n)}\s*\(', NL[j - 1]) and ndepth_ok(j) for j in range(max(1, (new_of.get(a) or a) - 3), min(len(NL), (new_of.get(a) or a) + 3) + 1)) @@ -290,8 +419,13 @@ class Changed: if na and na <= len(NL): # the new header: from the mapped line, the first line holding the name, to its end for s in range(max(1, na - 2), min(len(NL), na + 6) + 1): - if re.search(rf'\b{re.escape(n)}\b', NL[s - 1]): + # read with comments blanked: a comment line above the header that mentions the name is not it + if re.search(rf'\b{re.escape(n)}\b', strip_code(NL[s - 1], strings=False, hash_comments=is_py)): nh_raw = ' '.join(x.strip() for x in NL[s - 1:self.header_end(NL, s)]); nh = uncomment(NL[s - 1:self.header_end(NL, s)]); break + # AN EXPRESSION BODY IS NOT HEADER. `int Count() => xs.Count(x => x > 0);` is one line, and read to its `;` + # the whole body was header text: an edit inside the lambda it holds came back as a signature change + body_arrow = self.before_expression_body + oh, oh_raw, nh, nh_raw = body_arrow(oh), body_arrow(oh_raw), body_arrow(nh), body_arrow(nh_raw) # the header may start on an annotation line: its arguments are not a parameter list, and its text is # not a return type. Read the signature from the first line that is not a decoration sig = lambda h: re.sub(r'^\s*(@\w[\w.]*\s*(\([^)]*\))?\s*)+', '', h).strip() @@ -337,6 +471,7 @@ class Changed: else: entry['target'] = None out.append(entry) adds = [] + SL = strip_code(new, hash_comments=is_py).split('\n') # the new text, strings and comments blanked ndepth = [0] * (len(NL) + 2) # brace depth at the START of each new line for i, text in enumerate(strip_code(new).split('\n'), 1): ndepth[i] = ndepth[i - 1] + text.count('{') - text.count('}') for after, j1, j2 in added + [(a, b, c) for a, b, c in replaced]: @@ -354,12 +489,25 @@ class Changed: # a Python docstring is the first statement of the body, so an insertion right after it is inside the # callable just as the brace above is; rejecting it charged the edit to the class (#1358) if after <= len(OL) and owner and owner[3] != 'module' and owner[0] < after and OL[after - 1].strip().startswith(TRIPLE): anchored = True + # STRICTLY INSIDE A CALLABLE IS INSIDE IT, whatever the anchor line holds. An insertion after the `}` that + # closes an inner block, a blank line or a comment in the body is still that callable's body; rejected as + # "not anchored" it came back as "N new line(s) at file:518" and changed --impact / test-impact never + # followed it. The anchor test still decides at the callable's first line, and a module is never a body + if owner and owner[3] != 'module' and owner[0] < after < owner[1]: anchored = True in_body = bool(is_add and owner and owner[0] <= after < owner[1] and anchored) if in_body: # strictly inside a callable's body: that callable changed if not any(e['id'] == owner[2] for e in out): out.append(dict(kind='body', symbol=owner[4], id=owner[2], file=rel, line=owner[0], end=owner[1], old_lines=[after], target=owner[4], target_kind='method')) # and the inserted lines may still declare something at the type's level (a method extracted from this body) t = narrowest(after, {'class', 'interface', 'enum', 'type', 'namespace'}) if after else None + # an insertion after the line that CLOSES a type is outside it: a new interface written below a class was + # named as a member of that class, and its fields with it. (Python has no closing line: a type's last line is + # body, so there the inserted text's own indentation decides, below) + if t and after >= t[1] and not is_py: t = None + if t and after >= t[1] and is_py: + first = next((NL[j - 1] for j in range(j1, j2 + 1) if NL[j - 1].strip()), '') + hdr = OL[t[0] - 1] if t[0] <= len(OL) else '' + if len(first) - len(first.lstrip()) <= len(hdr) - len(hdr.lstrip()): t = None # a declaration the new text introduces: named from its header, owner = the type around the insertion point names = []; hdrs = {} # the type's body level in the NEW text: brace depth just after its header (indentation for Python); a declaration @@ -371,18 +519,23 @@ class Changed: else: body_depth = 0 for j in range(j1, j2 + 1): text = NL[j - 1]; st = re.sub(r'\s*(//.*|/\*.*?\*/\s*)$', '', text).strip() + # a line wholly inside a docstring, a string or a comment declares nothing: read as code, a docstring + # sentence at the body's indentation (`works (see below)`) was listed as an added method + if not SL[j - 1].strip(): continue st = re.sub(r'^(@[\w.]+(\([^)]*\))?\s+)+(?=\w)', '', st) # `@Override public …` on one line here = (ndepth[j - 1] if not py else len(text) - len(text.lstrip())) if here != body_depth or not st or st.startswith(('//', '*', '/*', '@', '#', 'return', 'if', 'for', 'while', 'switch', 'else', 'throw', 'new ', 'case', 'try', 'catch')): continue + # a file's own header lines declare nothing: `package a.b.c;` and `import a.b.C;` read as a field `c` / `C` + if re.match(r'(package|import|using|namespace|from|module|export\s+\*)\b', st): continue tm = re.match(r'^(?:(?:public|private|protected|static|final|abstract|sealed)\s+)*(class|interface|enum|record|@interface)\s+([A-Za-z_$][\w$]*)', st) - if tm: names.append((tm.group(2), tm.group(1).lstrip('@') + ' (with its members)')); hdrs[tm.group(2)] = st; continue - m = re.match(r'^(?:def|function|async function)\s+([A-Za-z_$][\w$]*)\s*\(', st) or \ + if tm: names.append((tm.group(2), tm.group(1).lstrip('@') + ' (with its members)', j)); hdrs[tm.group(2)] = st; continue + m = re.match(r'^(?:def|async def|function|async function)\s+([A-Za-z_$][\w$]*)\s*\(', st) or (not py and ( re.match(r'^(?:[\w<>\[\],.?$ ]+\s+)?([A-Za-z_$][\w$]*)\s*\([^;=]*\)\s*(?:throws[\w.,\s]+)?\s*(\{|;|$)', st) or \ - re.match(r'^(?:[\w<>\[\],.?$ ]+\s+)?([A-Za-z_$][\w$]*)\s*\([^;={]*$', st) + re.match(r'^(?:[\w<>\[\],.?$ ]+\s+)?([A-Za-z_$][\w$]*)\s*\([^;={]*$', st))) if m and not re.match(r'^(if|for|while|switch|catch|synchronized|return|new|super|this|else)$', m.group(1)) and '=' not in st.split('(')[0]: - names.append((m.group(1), 'method')); hdrs[m.group(1)] = ' '.join(x.strip() for x in NL[j - 1:self.header_end(NL, j)]); continue + names.append((m.group(1), 'method', j)); hdrs[m.group(1)] = ' '.join(x.strip() for x in NL[j - 1:self.header_end(NL, j)]); continue ty, nm = self.field_parts(st) - if nm and ty and st.endswith(';') and '(' not in st.split('=')[0]: names.append((nm, 'field')) + if nm and ty and st.endswith(';') and '(' not in st.split('=')[0]: names.append((nm, 'field', j)) # a declaration the old file already has — same owner, same name, same arity — is not new (an added OVERLOAD is) def types_of(hdr): """the parameter types of a header, as simple names: `List xs, int n` → [List, int]; a signature without names too""" @@ -407,7 +560,7 @@ class Changed: if ('...' in old_hdr) != ('...' in hdr): continue return True return False - for nm, k in names: + for nm, k, jn in names: if old_has(nm, hdrs.get(nm, '')): continue # an added method whose name already exists on this owner is an OVERLOAD: the compiler may rebind # existing call sites of that name to it, and a call site records no argument types, so which of them @@ -419,14 +572,14 @@ class Changed: sib += [r[0] for r in self.g.q("SELECT DISTINCT s.display FROM call_edges e JOIN symbols s ON s.id = e.caller_id WHERE e.callee_method_id = ?", sy['method_id'])] note = f'new {k}' if sib: note += f"; an overload: {len(set(sib))} existing call site(s) of {(t[4] + '.') if t else ''}{nm} may rebind to it (a call site records no argument types, so which of them is NOT decidable here): " + ', '.join(sorted(set(sib))[:4]) + (' …' if len(set(sib)) > 4 else '') - out.append(dict(kind='added', symbol=(t[4] + '.' if t else '') + nm, id=None, file=rel, line=j1, end=j2, old_lines=[], detail=note, + out.append(dict(kind='added', symbol=(t[4] + '.' if t else '') + nm, id=None, file=rel, line=jn, end=jn, old_lines=[], detail=note, target=((t[4] + '.' if t else '') + nm) if sib else None)) if 'members' in k: # the new type's own declarations, one level deeper — its whole body in the new text hj = next((j for j in range(j1, j2 + 1) if re.search(rf'\b{re.escape(nm)}\b', NL[j - 1])), j1); j = hj + 1 while j <= len(NL) and (ndepth[j - 1] > body_depth if not py else (not NL[j - 1].strip() or len(NL[j - 1]) - len(NL[j - 1].lstrip()) > body_depth)): st2 = re.sub(r'^(@[\w.]+(\([^)]*\))?\s+)+(?=\w)', '', re.sub(r'\s*(//.*|/\*.*?\*/\s*)$', '', NL[j - 1]).strip()); here2 = (ndepth[j - 1] if not py else len(NL[j - 1]) - len(NL[j - 1].lstrip())); j += 1 - if here2 != body_depth + (1 if not py else 4) or not st2 or st2.startswith(('//', '*', '/*', '@', '#', '}', '{')): continue - mm = re.match(r'^(?:[\w<>\[\],.?$ ]+\s+)?([A-Za-z_$][\w$]*)\s*\([^;={]*', st2) or re.match(r'^(?:def|function)\s+([A-Za-z_$][\w$]*)\s*\(', st2) + if here2 != body_depth + (1 if not py else 4) or not st2 or st2.startswith(('//', '*', '/*', '@', '#', '}', '{')) or not SL[j - 2].strip(): continue + mm = re.match(r'^(?:async\s+)?(?:def|function)\s+([A-Za-z_$][\w$]*)\s*\(', st2) or (None if py else re.match(r'^(?:[\w<>\[\],.?$ ]+\s+)?([A-Za-z_$][\w$]*)\s*\([^;={]*', st2)) if mm and mm.group(1) not in ('if', 'for', 'while', 'switch', 'catch', 'return', 'new', 'super', 'this'): out.append(dict(kind='added', symbol=f"{(t[4] + '.' if t else '')}{nm}.{mm.group(1)}", id=None, file=rel, line=j - 1, end=j - 1, old_lines=[], detail='new method', target=None)); continue ty2, nm2 = self.field_parts(st2) if nm2 and ty2 and st2.endswith(';') and '(' not in st2.split('=')[0]: out.append(dict(kind='added', symbol=f"{(t[4] + '.' if t else '')}{nm}.{nm2}", id=None, file=rel, line=j - 1, end=j - 1, old_lines=[], detail='new field', target=None)) @@ -446,6 +599,53 @@ class Changed: q = self.qualified(e.get('id'), e.get('kind')) if t and q and t.startswith(e['symbol']): e['target'] = q + t[len(e['symbol']):]; e['shown_target'] = t return out, adds + def new_file(self, rel, new, decls, is_py): + """A FILE THE BASE DOES NOT HAVE is one change: `added (N declarations)`. Read line by line as an insertion, + a new module listed every function at line 1, each parameter list as a change and docstring words as methods. What + can be analysed is what already has a caller outside the file (the graph was built from the new text): those + declarations are listed with their impact target; the rest have no dependents yet. A new test file is named as a + test, so test-impact runs it.""" + callables = [d for d in decls if d[3] in ('method', 'function', 'constructor')] + types = [d for d in decls if d[3] in ('class', 'interface', 'enum', 'type', 'namespace')] + n = len(callables) + len(types) + if not decls: # not in the graph yet: count the headers in the text + code = strip_code(new, hash_comments=is_py) + n = len(re.findall(r'^\s*(?:async\s+)?(?:def|class)\s+\w', code, re.M)) if is_py else \ + len(re.findall(r'^\s*(?:[\w<>\[\],.?]+\s+)*(?:class|interface|enum|record|struct)\s+\w', code, re.M)) + is_test = bool(re.search(r'(^|[/_.-])(test|tests|spec|specs|__tests__|it)([/_.-]|$)', rel, re.I)) or \ + any(self.g.all_sym.get(d[2], {}).get('is_test') for d in callables) + out = [dict(kind='added', symbol=rel, id=None, file=rel, line=1, end=len(new.split('\n')), old_lines=[], target=None, new_file=True, + is_test=is_test, detail=f"new file, {n} declaration(s)" + ('' if decls else ' by its text (the graph does not hold it yet)') + + (' — a test file: run it' if is_test else ''))] + ids = [d[2] for d in callables if isinstance(d[2], (int, str)) and not str(d[2]).startswith('f:')] + mids = {self.g.all_sym.get(i, {}).get('method_id'): i for i in ids if self.g.all_sym.get(i, {}).get('method_id') is not None} + if mids: + q = ','.join('?' * len(mids)) + callers = {} + for r in self.g.q(f"SELECT e.callee_method_id m, s.file f, s.display d FROM call_edges e JOIN symbols s ON s.id = e.caller_id WHERE e.callee_method_id IN ({q})", *mids): + if r['f'] != rel: callers.setdefault(r['m'], set()).add(r['d']) + for m, cs in callers.items(): + i = mids[m]; sy = self.g.all_sym[i]; lam = self.g.is_lambda(i) + out.append(dict(kind='added', symbol=self.g.lambda_label(i) if lam else sy['display'], id=i, file=rel, line=sy['line'], end=sy['end_line'] or sy['line'], old_lines=[], + detail=f"new, and already called from outside this file ({len(cs)} caller(s): {', '.join(sorted(cs)[:3])}{' …' if len(cs) > 3 else ''})", + target=(self.g.lambda_target(i) if lam else None) or self.qualified(i, 'method') or sy['display'], target_kind='method', referenced=True)) + return out + def whole_file(self, rel, why): + """every callable the graph declares in a NAMED file, as changed: what `test-impact ` means when there is no + edit to diff (no git, or the file matches its base). The types stand in for a file that declares no callable.""" + spans = self.spans.get(rel, []) + pick = [d for d in spans if d[3] in ('method', 'function', 'constructor')] or [d for d in spans if d[3] in ('class', 'interface', 'enum', 'type')] + out = [] + for a, b, i, k, d, n in pick: + if n in P.LAMBDA_NAMES: + # a lambda inside a method of this file is counted with that method; one at the top level by its line + if self.g.enclosing(i): continue + out.append(dict(kind='named', symbol=self.g.lambda_label(i), id=i, file=rel, line=a, end=b, old_lines=[], detail=why, + target=self.g.lambda_target(i) or f"{rel}:{a}", target_kind='method')); continue + out.append(dict(kind='named', symbol=d, id=i, file=rel, line=a, end=b, old_lines=[], detail=why, + target=self.qualified(i, 'method') or d, target_kind='type' if k in ('class', 'interface', 'enum', 'type') else 'method')) + if not out: out.append(dict(kind='named', symbol=rel, id=None, file=rel, line=1, end=1, old_lines=[], detail=why + '; the graph declares nothing in it', target=None)) + return out def qualified(self, i, kind): if i is None: return None r = self.g.all_sym.get(i) @@ -459,9 +659,36 @@ class Changed: def target(kind, d, k, n): return d +NO_GIT = ("no git base: {repo} is not a git checkout (a copy without .git), so there is no earlier version to diff the " + "working tree against, and nothing can say what changed. Name the files you edited: `axiomcode changed …` " + "or `axiomcode test-impact …` (MCP files=[…]) counts every declaration in each named file as changed.") + +def outside_note(files, suite=None): + """one line naming the changed files no graph here can read; test-impact adds the tests that load them""" + fs = sorted(files) + return (f"{len(fs)} changed file(s) outside every indexed language, so no declaration in them is analysed: " + + ', '.join(fs[:8]) + (f' … +{len(fs) - 8}' if len(fs) > 8 else '') + + " — `test-impact` names the tests that load them by name; an edit here is not 'no change'") + +def held_extensions(repo): + """the file extensions some graph of this repository holds (the main one and every per-language one)""" + import sqlite3, glob + ext = set() + for db in [os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')] + glob.glob(os.path.join(repo, '.axiomcode', 'lang', '*', 'out', 'graph.sqlite')): + try: + con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) + ext |= {os.path.splitext(r[0])[1] for r in con.execute("SELECT DISTINCT rel FROM paths") if r[0]} + con.close() + except Exception: pass + return ext + def main(argv): a = list(argv); mode = 'worktree'; rng = None; want_impact = '--impact' in a; as_json = '--json' in a - a = [x for x in a if x not in ('--impact', '--json')] + whole = '--whole' in a # a NAMED file with no diff counts whole (test-impact : "the tests of this file") + # the branch suggestion costs a few git calls per candidate ref: asked for by a reader (prose) or by test-impact + # (--suggest), never by the edit hooks, which read --json on every prompt + want_suggest = not as_json or '--suggest' in a + a = [x for x in a if x not in ('--impact', '--json', '--whole', '--suggest')] old_f = new_f = one = None for flag in ('--range', '--old', '--new', '--file'): if flag in a: @@ -473,6 +700,16 @@ def main(argv): if '--staged' in a: mode = 'staged'; a.remove('--staged') repo = '.' if a and os.path.isdir(a[0]): repo = a.pop(0) + rrepo = os.path.realpath(repo) + # NO GIT, NO BASE. On a copy without .git every file read as new against an empty old text, so `changed` listed every + # function as added at line 1 and docstring words as methods. Without named files there is no answer to give: say so + # and say what to pass. With named files, each counts whole (below). + git = is_git(rrepo) + if not git and not (old_f or new_f) and not a: + msg = NO_GIT.format(repo=rrepo) + if as_json: print(json.dumps({'built_at': None, 'changed': [], 'notes': [], 'refused': msg, 'no_git': True}, indent=1)) + else: print(msg) + return 2 # an EDIT is measured against the baseline, and read from the graph that describes it (#1305): after a background # refresh that is the graph kept in .axiomcode/base, not the current one. Given two texts (--old/--new, the edit # hook's before-the-edit check) the current graph is the one whose spans are in the current file's lines. @@ -481,41 +718,57 @@ def main(argv): baseline = None; fanout_empty = False sys.path.insert(0, HERE); import ax_fresh if mode != 'range' and not (old_f or new_f): - bg = ax_fresh.baseline_graph(os.path.realpath(repo)); given = os.environ.get('AXIOMCODE_GRAPH') + bg = ax_fresh.baseline_graph(rrepo); given = os.environ.get('AXIOMCODE_GRAPH') # the dispatcher names the CURRENT graph of the language it is asking (AXIOMCODE_GRAPH_LANG): that is not a # graph chosen for this edit, so the language's baseline is still looked for, as it is for the main one - if given and os.path.realpath(given) == os.path.realpath(ax_fresh.graph_dir(os.path.realpath(repo))): given = None + if given and os.path.realpath(given) == os.path.realpath(ax_fresh.graph_dir(rrepo)): given = None if bg and (not given or os.path.realpath(given) == os.path.realpath(bg)): baseline = bg; os.environ['AXIOMCODE_GRAPH'] = bg # also for --impact, which runs impact below C = Changed(repo, baseline=bool(baseline)); C.mode = 'files' if (old_f or new_f) else mode - results = []; notes = [] + range_note = None + if mode == 'range': C.range_old, C.range_new, range_note = resolve_range(C.repo, rng) + results = []; notes = []; outside = []; suggest = None + main_graph = not ax_fresh.graph_lang() if old_f or new_f: if not one: die("--old/--new need --file ") rel = C.rel(one); old = open(old_f, errors='replace').read() if old_f else ''; new = open(new_f, errors='replace').read() if new_f else '' r, n = C.file_changes(rel, old, new); results += r; notes += n else: files = C.files(mode, rng, a) + outside = list(C.outside) + # a source file in a language no graph here holds (a .java file beside a Python-only index) is outside the index too + held = held_extensions(C.repo) + if held: + outside += [f for f in files if os.path.splitext(f)[1] not in held and f not in C.spans] + files = [f for f in files if f not in outside] # A REPOSITORY IN SEVERAL LANGUAGES has a graph per language, and the dispatcher asks each (ax_langs.py). Each # answers for the files it owns, so an edit is reported once, by the graph of its language, and not also as - # "no declarations known here" by every other. + # "no declarations known here" by every other. Files outside every language are named once, by the main graph. if os.path.isdir(os.path.join(C.repo, '.axiomcode', 'lang')): import ax_langs mine = ax_fresh.graph_lang() files = [f for f, o in ax_langs.owners(C.repo, files).items() if o == mine] - if not files and os.environ.get('AXIOMCODE_FANOUT'): fanout_empty = True + if not main_graph: outside = [] + if not files and not outside and os.environ.get('AXIOMCODE_FANOUT'): fanout_empty = True # the graph describes ONE commit. For a range whose newer side is that commit, the declarations are read on the new # text and the classification runs with the two texts swapped, then its direction is turned around flipped = False - if mode == 'range' and C.built_at and rng: - b = rng.split('..')[1]; hb = sh('git', 'rev-parse', b, cwd=C.repo) - flipped = bool(hb) and hb.strip().startswith(C.built_at[:10]) is False and hb.strip() == C.built_at - if hb and not flipped and hb.strip()[:10] == C.built_at[:10]: flipped = True + if mode == 'range' and C.built_at: + hb = C.range_new + flipped = bool(hb) and hb[:10] == C.built_at[:10] for rel in files: + if a and not git: # a named file, no git: every declaration in it + results += C.whole_file(rel, 'no git base to diff against'); continue old, new = C.texts(rel, mode, rng) - if old == new: continue - if flipped: + if old == new: + if a and whole: results += C.whole_file(rel, f"no edit against {'the index' if mode == 'staged' else 'the baseline'}: the file was named") + continue + if flipped and not old.strip() and new.strip(): # a file the range adds: one line, not its decls reversed + results += C.new_file(rel, new, C.decl_spans(rel, mode), rel.endswith(('.py', '.pyi')) or bool(re.match(r'#![^\n]*\bpython', new))) + elif flipped: r, n = C.file_changes(rel, new, old) for e in r: + if e.get('new_file'): e['kind'] = 'removed'; e['detail'] = 'the file is gone at the newer side'; continue if e['kind'] == 'removed': e['kind'] = 'added (declaration)'; e['target'] = None if e.get('detail'): e['detail'] = ' → '.join(reversed(e['detail'].split(' → '))) if ' → ' in e['detail'] and ':' not in e['detail'] else re.sub(r'(\w+): (.+?) → (.+?)(,|$)', r'\1: \3 → \2\4', e['detail']) @@ -525,25 +778,54 @@ def main(argv): else: r, n = C.file_changes(rel, old, new); results += r; notes += n if flipped: notes.append(('', f"the graph is built at the newer commit {C.built_at[:10]}: declarations are the new text's; a declaration the range removed is not in it", None)) - if as_json: print(json.dumps({'built_at': C.built_at, 'changed': results, 'notes': [x[1] for x in notes]}, indent=1)); return 3 if fanout_empty else 0 - if not results and not notes: print("no change to a declaration the graph knows" + (f" (working tree against {C.built_at[:10]})" if C.built_at and mode == 'worktree' else '')); return 3 if fanout_empty else 0 + # COMMITTED WORK IS NOT IN THE WORKING TREE. After a commit the baseline follows HEAD, so the working tree matches + # it and the answer was a bare "no change", while the agent's changes sat in commits a --range would have read + if want_suggest and mode == 'worktree' and not a and not results and not notes and not outside and git and main_graph: + s_ = branch_suggestion(C.repo) + if s_: suggest = dict(ref=s_[0], commits=s_[1], range=f"{s_[0]}..HEAD") + if outside: notes.append(('', outside_note(outside), None)) + base_desc = (f"working tree against {C.built_at[:10] if C.built_at and C.built_at != 'nogit' else 'HEAD'}" if mode == 'worktree' else + f"{rng} (from {C.range_old[:10]} to {C.range_new[:10]})" if mode == 'range' else mode) + sugg_line = (f"your commits are not in the working tree: HEAD is {suggest['commits']} commit(s) ahead of {suggest['ref']} — " + f"ask `changed --range {suggest['range']}` (MCP range='{suggest['range']}') for them") if suggest else None + if as_json: + print(json.dumps({'built_at': C.built_at, 'changed': results, 'notes': [x[1] for x in notes], 'outside_index': sorted(outside), + 'range_base': (C.range_old if mode == 'range' else None), 'range_note': range_note, + 'suggest_range': suggest, 'suggest_note': sugg_line}, indent=1)) + return 3 if fanout_empty else 0 + if range_note: print(f"note: {range_note}") + if not results and not notes: + print(f"no change to a declaration the graph knows ({base_desc})") + if sugg_line: print(f"next: {sugg_line}") + return 3 if fanout_empty else 0 against = (f" — against the tree the graph was indexed from at the last `axiomcode index` (commit {C.built_at[:10]} plus the edits that were uncommitted then)" if C.tree_differs - else f" — against the graph's commit {C.built_at[:10]}") - print(f"changed declarations ({len(results)})" + (against if C.built_at and mode == 'worktree' else (f" — {rng}" if rng else '')) + ":") - for e in results: - print(f" {e['kind']:<10} {e['symbol']} {e['file']}:{e['line']}" + (f" — {e['detail']}" if e.get('detail') else '') + (f" → impact {e.get('shown_target') or e['target']}" if e.get('target') else (' (no declaration of that name existed before, so nothing in the old tree names it)' if e['kind'] == 'added' else ''))) + else f" — against the graph's commit {C.built_at[:10]}" if C.built_at != 'nogit' else " — against HEAD (the graph was built before this was a git checkout)") + named = [e for e in results if e['kind'] == 'named']; shown = [e for e in results if e['kind'] != 'named'] + print(f"changed declarations ({len(results)})" + (" — the two texts given" if (old_f or new_f) else against if C.built_at and mode == 'worktree' and not named else (f" — {rng}" if rng else '')) + ":") + for e in shown: + tail = (f" → impact {e.get('shown_target') or e['target']}" if e.get('target') else + ('' if e.get('new_file') else ' (no declaration of that name existed before, so nothing in the old tree names it)' if e['kind'] == 'added' else '')) + print(f" {e['kind']:<10} {e['symbol']}" + ('' if e.get('new_file') else f" {e['file']}:{e['line']}") + (f" — {e['detail']}" if e.get('detail') else '') + tail) + by_file = {} + for e in named: by_file.setdefault(e['file'], []).append(e) + for f, es in by_file.items(): + print(f" {'named':<10} {f} — {len(es)} declaration(s) count as changed ({es[0]['detail']}): " + + ', '.join(e['symbol'] for e in es[:10]) + (f" … +{len(es) - 10}" if len(es) > 10 else '')) # a note with a file is an addition in that file; one without is a caveat about the whole answer, and printing it # under `added` made "the graph is built at the newer commit …" read as a new declaration for f, n, _ in notes: print(f" {'added':<10} {n}" if f else f"note: {n}") groups = {} for e in results: - if e.get('target') and e['kind'] != 'added': + if e.get('target') and (e['kind'] != 'added' or e.get('referenced')): k = 'method' if e.get('target_kind') == 'param' and '(' not in e['target'] else e.get('target_kind', 'method') groups.setdefault('' if '(' in e['target'] else k, []).append(e['target']) if want_impact and groups: for k, ts in groups.items(): # one impact run per kind (a field named like a method needs to be told apart) - print(); sys.stdout.flush() - subprocess.run([sys.executable, os.path.join(HERE, 'axiomcode-impact'), *dict.fromkeys(ts), C.repo] + (['--kind', k] if k else [])) + # captured and printed here, so it lands AFTER the list above: written straight to the terminal it came out + # before this process's own (paged, buffered) lines, and a range note or the list itself sank to page 2 + r = subprocess.run([sys.executable, os.path.join(HERE, 'axiomcode-impact'), *dict.fromkeys(ts), C.repo, '--page', 'all'] + (['--kind', k] if k else []), + capture_output=True, text=True) + print(); print(r.stdout.rstrip('\n')); sys.stderr.write(r.stderr) return 0 if __name__ == '__main__': diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index 2bc36685c..b40b2b5af 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -209,7 +209,7 @@ def rank_dirs(g, terms, depth=3, topk=DIR_EVIDENCE): return ranked -def pick_seeds(g, scored, terms, want, named=()): +def pick_seeds(g, scored, terms, want, named=(), prefer=frozenset()): """The entry points, in four passes, each only filling what the one before left: 1. what the question NAMES: a declaration it spells out (`IRouter.RouteAsync`), a route it quotes @@ -221,6 +221,18 @@ def pick_seeds(g, scored, terms, want, named=()): 3. the best symbol for each term still uncovered, as before, so a task naming unrelated concepts still enters the graph at each of them; 4. the best overall. + + Equal scores are common: `findByCode` on a mapper, a repository, a service and a query class all match the same + two words the same way. A tie used to fall to the graph's symbol ids, which are hashes that change with the + directory a project is indexed in, so the same question on the same code picked a different four of nine from one + copy to the next. A tie now goes first to `prefer` (a declaration bound by a text file of the kind the question + asks about: the mapper method whose XML holds the SQL, when the question asks which SQL runs), then to the one + whose own name holds more of the matched words, then to one a reader can go to (below), then to its name and place, + which do not move. + + Name order alone is not neutral: `"node:sqlite".StatementSync.run` (a typing), `` and `AllMethodExamples` + (test data) sort before `TsLocalResolver.run`, so a tie went to what has no code to read. Before the name, a tie + goes to source over test code, then to a declaration with a body over a typing (`.d.ts`) or an anonymous one. """ seeds, seen, covered = [], set(), set() def take(sid, why, sc): @@ -228,10 +240,19 @@ def pick_seeds(g, scored, terms, want, named=()): seen.add(sid); seeds.append((sid, why, sc)); covered.update(scored.get(sid, (0, []))[1]); return True for sid, why in named: take(sid, why, scored.get(sid, (0, []))[0]) - ranked = sorted(scored.items(), key=lambda kv: -kv[1][0]) + # the task's words in the declaration's OWN name beat the same words reached through its owner or its path: + # `OrderCreatedEvent` says more about "where is an order created" than `Order.getCreatedAt` does + own = lambda sid: sum(t in ((g.sym.get(sid) or {}).get('name') or '').lower() for t in scored.get(sid, (0, []))[1]) + def readable(sid): + sy = g.sym.get(sid) or {} + f, nm = sy.get('file') or '', sy.get('name') or '' + anonymous = nm.startswith(('<', '"', "'")) and nm != '' # a constructor has a body to read + return (bool(sy.get('is_test') or TESTY.search(f)), f.endswith('.d.ts') or anonymous) + tie = lambda sid: (sid not in prefer, -own(sid)) + readable(sid) + (g.disp(sid) or '', g.loc(sid) or '', sid) + ranked = sorted(scored.items(), key=lambda kv: (-kv[1][0],) + tie(kv[0])) floor = 0.05 * ranked[0][1][0] if ranked else 0 multi = [(sid, sc, m) for sid, (sc, m) in ranked if len(m) >= 2 and sc >= floor] - multi.sort(key=lambda r: (-len(r[2]), -r[1])) + multi.sort(key=lambda r: (-len(r[2]), -r[1]) + tie(r[0])) for i, (sid, sc, m) in enumerate(multi): if len(seeds) >= want: break if not set(m) - covered: continue @@ -241,7 +262,7 @@ def pick_seeds(g, scored, terms, want, named=()): if sib >= FLOW_FAN: break if m2 == m and sc2 >= 0.5 * sc and take(sid2, "matches '" + "' + '".join(m2) + "'", sc2): sib += 1 best = {} - for sid, (sc, matched) in scored.items(): + for sid, (sc, matched) in ranked: for t in matched: cur = best.get(t) if cur is None or sc > cur[0]: best[t] = (sc, sid) @@ -292,14 +313,15 @@ def named_declarations(g, text): return out -ROUTE = re.compile(r'(?` (a Python route converter) + return [('*' if (p.startswith('{') or p.startswith(':') or p.startswith('<') or p == '*') else p) for p in r.strip('/').split('/') if p] def _route_eq(a, b): @@ -321,7 +343,19 @@ def route_seeds(g, text): hits = [] # (symbol id, route as written) def serve(mid, route): if mid in g.sym: hits.append((mid, route)) - for tbl in ('ext_remote_unserved', 'ext_remote_unsent'): + # the path a url_dispatch edge says its view is served at: the engine composed it through every include() that + # mounts the table, so it is the one source that names the handler of `/api/v1/status/` when no line spells it + tables = set() # route files whose every literal path the engine already composed + if g.has('ext_framework_edge') and g.has('symbols'): + for r in g.q("""SELECT s.id AS sid, e.c3 AS path, t.file AS tf FROM ext_framework_edge e + JOIN symbols s ON s.method_id = e.c1 JOIN symbols t ON t.method_id = e.c0 + WHERE e.c2 = 'url_dispatch' AND e.c3 LIKE '/%'"""): + tables.add(r['tf']) + if any(_route_eq(_route_parts(r['path']), a) for a in asked): serve(r['sid'], r['path']) + # a route the engine answered is not searched again as text: the literal scan below is the fallback for a route + # no edge carries, and on an answered one it only adds the table's module and every literal that spells a suffix + asked = [a for a in asked if not any(_route_eq(_route_parts(r), a) for _s, r in hits)] + for tbl in ('ext_remote_unserved', 'ext_remote_unsent') if asked else (): if g.has(tbl): for r in g.q(f"SELECT c0, c2 FROM {tbl}"): if any(_route_eq(_route_parts(r['c2']), a) for a in asked): serve(r['c0'], r['c2']) @@ -329,10 +363,10 @@ def route_seeds(g, text): # A class-level prefix (@RequestMapping("/api") over @GetMapping("/widgets")) is met by accepting a suffix match # when the rest of the asked route is itself a literal in the same file. lits = [] - if g.has('decorations'): + if asked and g.has('decorations'): for r in g.q("SELECT owner_id, text, file, line FROM decorations WHERE text LIKE '%/%' OR text LIKE '%\"%'"): for v in re.findall(r'"([^"]*)"', r['text'] or ''): lits.append((v, r['file'], r['line'], r['owner_id'])) - if g.has('literals'): + if asked and g.has('literals'): for r in g.q("SELECT value, file, line FROM literals WHERE value LIKE '%/%' OR length(value) < 40"): lits.append(((r['value'] or '').strip('"'), r['file'], r['line'], None)) by_file = collections.defaultdict(set) @@ -342,6 +376,9 @@ def route_seeds(g, text): if s.get('method_id') and s.get('line') and not (s.get('file') or '').startswith('<'): spans[s['file']].append((s['line'], s.get('end_line') or s['line'], sid)) for v, f, l, owner in lits: + # a pattern in a route table the engine read is one segment of a longer served path, not the path itself: + # `path("status/", ...)` under `include()` at `api/v1/` is not served at `/status/` + if f in tables: continue vp = _route_parts(v) if not vp or ('/' not in v and not owner): continue # a bare word in a string is not a route ok = any(_route_eq(vp, a) for a in asked) or any( @@ -349,7 +386,9 @@ def route_seeds(g, text): if not ok: continue if owner and owner in g.sym: serve(owner, v); continue inner = sorted((e - b, sid) for b, e, sid in spans.get(f, ()) if b <= (l or 0) <= e) - if inner: serve(inner[0][1], v) + # A TEST REQUESTS A ROUTE, IT DOES NOT SERVE ONE. `self.client.get("/api/v1/status/")` puts the literal inside + # a test method, and labelling that method "serves the route" sent a reader to the caller instead of the view. + if inner and not g.sym[inner[0][1]].get('is_test') and not TESTY.search(f or ''): serve(inner[0][1], v) out, seen = [], set() for sid, route in hits: if sid not in seen: seen.add(sid); out.append((sid, f"serves the route {route.strip()}")) @@ -542,7 +581,9 @@ def text_bindings(g, sids, under=(), want_ext=()): except Exception: return [] ns_of = {} out, seen = [], set() + prose = getattr(nonsource(g), 'prose', set()) # a common word written as a word is not a binding (ax_nonsource.py) for nm, f, line in hits: + if (nm, f, line) in prose: continue if under and not any(f.startswith(d.rstrip('/') + '/') for d in under): continue if f.endswith('.xml'): if f not in ns_of: @@ -567,6 +608,19 @@ def text_bindings(g, sids, under=(), want_ext=()): return [r for _k, r in sorted(out)] +def asked_bound(g, scored, text, under=(), cap=300): + """{symbol id} among the multi-term matches that a text file of the kind the question asks about binds by name. + Empty unless the question asks about such a file (SQL, configuration, a template): only then is the tree walked, + and only then does it break a tie between equally scored entry points (pick_seeds).""" + kinds = asked_exts(text) + if not kinds or not NONSOURCE_ASK.search(text or ''): return frozenset() + cands = sorted((sid for sid, (_sc, m) in scored.items() if len(m) >= 2), key=lambda s: -scored[s][0])[:cap] + by_disp = collections.defaultdict(set) + for sid in cands: by_disp[g.disp(sid)].add(sid) + return frozenset(sid for f, _l, d, _n in text_bindings(g, cands, under=under, want_ext=kinds) + if f.endswith(kinds) for sid in by_disp.get(d, ())) + + _SRC = {} def source_lines(repo, f): """The file's lines, read once. The graph stores repo-relative paths; a run may be started anywhere.""" @@ -972,7 +1026,7 @@ def main(argv): # themselves, and on Python every class has one, so they can be a third of this list while # naming nowhere to go. scored = {sid: v for sid, v in scored.items() if not is_synthetic(g.sym.get(sid, {}).get('name'))} - seeds = pick_seeds(g, scored, terms, seeds_wanted, named) + seeds = pick_seeds(g, scored, terms, seeds_wanted, named, prefer=asked_bound(g, scored, body, text_dirs)) RESULT['terms'] = list(terms) RESULT['scope'], RESULT['scope_offered'] = (scopes[0] if scopes else None), bool(scope_offered) @@ -1085,10 +1139,12 @@ def main(argv): if nm not in cur or sc > cur[nm][0]: cur[nm] = (sc, len(matched), nm) if sy.get('line'): span_of[(f, nm)] = (sy['line'], sy.get('end_line') or sy['line']) + def decls_of(f): + return [nm for _sc, _m, nm in sorted(best_in_file.get(f, {}).values(), key=lambda x: (-x[0], -x[1], x[2]))[:DECLS_PER_FILE]] + # `declared_at` beside the names: the line each one starts on, so a site list (ax_grep.py) can point at it RESULT['files'] = [{'file': f, 'how': ('name only, no call path' if d == 99 else f'hop {d}'), - 'symbols': n, 'task_terms_matched': hit, - 'declarations': [nm for _sc, _m, nm in - sorted(best_in_file.get(f, {}).values(), key=lambda x: (-x[0], -x[1], x[2]))[:DECLS_PER_FILE]]} + 'symbols': n, 'task_terms_matched': hit, 'declarations': decls_of(f), + 'declared_at': [f"{f}:{span_of[(f, nm)][0]}" if (f, nm) in span_of else None for nm in decls_of(f)]} for f, (d, hit, n) in shown] for f, (d, hit, n) in shown: mark = f" [{hit} task term(s)]" if hit else "" @@ -1105,10 +1161,10 @@ def main(argv): sp = span_of.get((f, nm)) if not sp: continue a, b = sp - body = source_lines(g.repo, f)[a - 1:min(b, a + SOURCE_LINES)] - if not body: continue + code = source_lines(g.repo, f)[a - 1:min(b, a + SOURCE_LINES)] # not `body`: that is the question + if not code: continue print(f" {'':22} {nm} ({f}:{a})") - for k, ln in enumerate(body, a): + for k, ln in enumerate(code, a): print(f" {'':22} {k:5} | {ln.rstrip()[:110]}") if b > a + SOURCE_LINES: print(f" {'':22} {'':5} | … +{b - a - SOURCE_LINES} more line(s)") if foot and budget > 0: print(foot + ", tests and benchmarks not listed") @@ -1126,9 +1182,13 @@ def main(argv): if g.has('overrides') and seed_ids: ph = ','.join('?' * len(seed_ids)) impls = {r[0] for r in g.q(f"SELECT overriding_method_id FROM overrides WHERE method_id IN ({ph})", *seed_ids)} - seed_ids + # nearer through the implementation than through the seeds' own closure counts as nearer: the mapper call + # in the impl is one hop from it however far the closure walk happened to place it (hop 3 is outside the ring) for x, d in neighbourhood(g, [i for i in impls if i in g.sym], cap=1).items(): - if x not in seed_ids and x in g.sym and x not in depth: depth[x] = d + 1; ring.append(x) - near = list(seed_ids) + sorted(ring, key=lambda x: (not bodiless(g, x), depth[x], x))[:300] + if x in seed_ids or x not in g.sym or depth.get(x, d + 2) <= d + 1: continue + if depth.get(x, 3) > 2: ring.append(x) + depth[x] = d + 1 + near = list(seed_ids) + sorted(ring, key=lambda x: (not bodiless(g, x), depth[x], g.disp(x) or '', x))[:300] kinds = asked_exts(body) binds = text_bindings(g, near, under=text_dirs, want_ext=kinds) # the question named a kind of file (SQL: mapper XML or .sql): when some binding is of that kind, the others diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph index bd414596c..478b64f18 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph @@ -1,13 +1,25 @@ #!/usr/bin/env python3 -"""axiomcode-graph — the graph of a codebase, from the engine, as one page. +"""axiomcode graph — the graph of a codebase as one interactive page. - axiomcode-graph build [] [--library [,…]] [--out ] run the engine (axiomcode-build) then write the page - axiomcode-graph export [] [--out ] write the page from an existing graph.sqlite (no engine run) + axiomcode graph [] [--out ] + axiomcode graph [] --lang [,…] [--src ] [--library [,…]] [--out …] ---library (or AXIOMCODE_LIBRARY) names the dependency roots — for Java the JDK and the third-party sources/IR. With them a call -into a dependency resolves to a BLACK BOX (one per library type, drawn outside the disc in its own colour); without them it -is unresolved, and the resolution rate is understated. The rate the page reports is over calls into client code; external -calls are counted separately. +With no flags the page is drawn from the graph `axiomcode index` built, and the engine does not run: + - the graph is up to date (no source file differs from the ones it was built from): the page is written from it + in seconds. + - it is out of date: it is rebuilt first exactly as it was indexed, with the same --lang, --src and --library, so + only the languages the index chose are solved and no graph appears for a language it left out. + - there is no graph yet: the repository is indexed first (every language present, or the flags given). +Flags given here are a request for that graph, as `axiomcode index` takes them. + +A repository indexed in several languages is drawn in all of them, on one page. The command prints what it drew as +prose, and the page's absolute path last. `axiomcode graph export []` writes the page from the graph as it is, +with no freshness check. + +--library names the dependency roots (for Java the JDK and the third-party sources/IR), and is given to +`axiomcode index`. With them a call into a dependency resolves to a BLACK BOX (one per library type, drawn outside the +disc in its own colour); without them it is unresolved, and the resolution rate is understated. The rate the page +reports is over calls into client code; external calls are counted separately. The page (default /.axiomcode/graph/graph.html; one file, no network) draws the codebase as a disc: directories in the centre, their files on the next ring, the types in those files, and every method on the @@ -31,57 +43,29 @@ def _b36(n): return s -def export(repo, page=None): +def graph_dbs(repo, gdir): + """every graph to draw: the main language's, then each other language's (.axiomcode/lang/), as the query verbs + ask them. A graph named in AXIOMCODE_GRAPH was chosen by the caller and is drawn alone.""" + dbs = [os.path.join(gdir, 'out', 'graph.sqlite')] + if not os.environ.get('AXIOMCODE_GRAPH'): + ld = os.path.join(repo, '.axiomcode', 'lang') + for l in sorted(os.listdir(ld)) if os.path.isdir(ld) else (): + d = os.path.join(ld, l, 'out', 'graph.sqlite') + if os.path.exists(d): dbs.append(d) + return dbs + + +def export(repo, page=None, how=''): repo = os.path.realpath(repo or '.') gdir = os.environ.get('AXIOMCODE_GRAPH') or os.path.join(repo, '.axiomcode') db = os.path.join(gdir, 'out', 'graph.sqlite') # the same door every other verb goes through: built on demand under the CLI, refused in words # that name the one command that helps under anything else. if not os.path.exists(db) and not ax_contract.ensure_graph(repo, db): sys.exit(ax_contract.no_graph(repo, db)) - con = sqlite3.connect(db); con.row_factory = sqlite3.Row - q = lambda sql: con.execute(sql).fetchall() - has = lambda t: bool(con.execute("SELECT 1 FROM sqlite_master WHERE name=?", (t,)).fetchone()) - run = {r['key']: r['value'] for r in q("SELECT key, value FROM run")} - src = run.get('source_dir') or repo - def rel(p): - a = p if os.path.isabs(p) else os.path.join(src, p) - a = os.path.realpath(a) if os.path.exists(a) else os.path.normpath(a) - r = os.path.relpath(a, repo).replace(os.sep, '/'); return p.lstrip('/') if r.startswith('..') else r - - # ── declarations: types and methods, with the display names the query skill uses when the index is there ── - nodes = {} - owner = {r['id']: r['owner_type_id'] for r in q("SELECT id, owner_type_id FROM methods WHERE provenance='client'")} - if has('symbols'): - for r in q("SELECT * FROM symbols WHERE (method_id IS NOT NULL OR type_id IS NOT NULL)"): - if r['method_id'] and r['kind'] in METHOD_KINDS: - nodes[r['method_id']] = dict(id=r['method_id'], k='method', sk=r['kind'], l=r['display'], f=r['file'], a=r['line'], b=r['end_line'], t=int(bool(r['is_test'])), sig=r['signature'] or '') - elif r['type_id'] and r['kind'] in TYPE_KINDS: - nodes[r['type_id']] = dict(id=r['type_id'], k='type', sk=r['kind'], l=r['display'], f=r['file'], a=r['line'], b=r['end_line'], t=int(bool(r['is_test']))) - else: - cat = lambda c: 'interface' if 'INTERFACE' in c else 'enum' if 'ENUM' in c else 'namespace' if 'NAMESPACE' in c else 'type' if 'ALIAS' in c or 'TYPEDEF' in c else 'class' - for r in q("SELECT * FROM types WHERE provenance='client'"): - f = rel(r['file_path']); nodes[r['id']] = dict(id=r['id'], k='type', sk=cat(r['category']), l=r['name'].replace('$', '.'), f=f, a=r['start_line'], b=r['end_line'], t=int(bool(TESTRE.search(f)))) - for r in q("SELECT * FROM methods WHERE provenance='client'"): - f = rel(r['file_path']); o = (r['owner_qualified_name'] or '').replace('#', '.').split('.')[-1].replace('$', '.') - sk = 'constructor' if 'CONSTRUCTOR' in r['kind'] else 'module' if 'MODULE' in r['kind'] else 'method' if r['owner_type_id'] else 'function' - nodes[r['id']] = dict(id=r['id'], k='method', sk=sk, l=f"{o}.{r['name']}" if o else r['name'], f=f, a=r['start_line'], b=r['end_line'], t=int(bool(TESTRE.search(f))), sig=r['signature'] or '') - lib = {r['id']: (r['owner_qualified_name'] or r['qualified_name'].rsplit('.', 1)[0], r['name']) for r in q("SELECT id, qualified_name, name, owner_qualified_name FROM methods WHERE provenance<>'client'")} - - # ── containment: dir → file → type → method (nested types under their outer; free functions under the file) ── - nesting = {r['type_id']: r['outer_type_id'] for r in q("SELECT type_id, outer_type_id FROM nesting")} if has('nesting') else {} - for n in list(nodes.values()): - fid = 'F:' + n['f'] - if fid not in nodes: - d = os.path.dirname(n['f']) or '.' - nodes[fid] = dict(id=fid, k='file', l=n['f'], f=n['f'], t=int(bool(TESTRE.search(n['f']))), p='D:' + d) - if 'D:' + d not in nodes: nodes['D:' + d] = dict(id='D:' + d, k='dir', l=d, f='', t=int(bool(TESTRE.search(d + '/')))) - if n['k'] == 'type': n['p'] = nesting[n['id']] if nesting.get(n['id']) in nodes else fid - elif n['k'] == 'method': o = owner.get(n['id']); n['p'] = o if o in nodes else fid - - # ── calls: one edge per (caller, callee) with the best tier and the site count; unresolved → a count on the caller ── - # only an ambiguous_* tier is unresolved. boundary_lib is a call into a library; the *_terminal, implicit_constructor and - # fan_capped tiers are correct ends (the platform, a synthesised constructor, a computed target) — the same rule as the - # query skill, so the two never disagree about what is a blind spot + # A REPOSITORY IN SEVERAL LANGUAGES is drawn in all of them: one page, each language's graph merged into it (their ids + # carry the language, so none collide; a directory holding two languages' files is one node). No edge joins two + # languages, as no query follows one. + nodes, owners, lib, nesting, anc, langs, runs = {}, {}, {}, {}, collections.defaultdict(set), [], [] E, unresolved, terminal, LE = {}, collections.Counter(), collections.Counter(), {} boxes = collections.defaultdict(lambda: dict(sites=0, callers=set(), methods=collections.Counter())) # one black box per library type def box(c, owner, name): @@ -90,39 +74,92 @@ def export(repo, page=None): sites = collections.defaultdict(list) # per caller: [line, written name, target id ('' = none), tier] — what makes a call in the source a link def site(c, r, target): if r['line'] and len(sites[c]) < 400: sites[c].append([r['line'], r['written'] or '', target, r['tier']]) - for r in q("SELECT e.caller_id, e.callee_method_id, e.tier, e.callee_label, s.start_line AS line, s.callee_name AS written FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id"): - c, t = r['caller_id'], r['callee_method_id'] - if c not in nodes: continue - if not t or t not in nodes: - if t in lib: box(c, *lib[t]); site(c, r, 'L:' + lib[t][0]) - elif r['tier'] == 'boundary_lib' and r['callee_label']: - # Python / JavaScript: the library target is a label — builtin:len, external:asyncio.get_running_loop — not a method row - prov, _, name = r['callee_label'].partition(':'); owner, _, meth = name.rpartition('.') - owner = owner or (('builtins' if prov == 'builtin' else name)); box(c, owner, meth or name); site(c, r, 'L:' + owner) - elif (r['tier'] or '').startswith('ambiguous'): unresolved[c] += 1; site(c, r, '') - else: terminal[c] += 1 - continue - site(c, r, t) - e = E.get((c, t)) - if e is None: E[(c, t)] = dict(s=c, t=t, tier=r['tier'], w=1) + skipped, tiers = None, collections.Counter() + for db in graph_dbs(repo, gdir): + con = sqlite3.connect(db); con.row_factory = sqlite3.Row + q = lambda sql: con.execute(sql).fetchall() + has = lambda t: bool(con.execute("SELECT 1 FROM sqlite_master WHERE name=?", (t,)).fetchone()) + run = {r['key']: r['value'] for r in q("SELECT key, value FROM run")}; runs.append(run) + if run.get('language') and run['language'] not in langs: langs.append(run['language']) + src = run.get('source_dir') or repo + def rel(p, src=src): + a = p if os.path.isabs(p) else os.path.join(src, p) + a = os.path.realpath(a) if os.path.exists(a) else os.path.normpath(a) + r = os.path.relpath(a, repo).replace(os.sep, '/'); return p.lstrip('/') if r.startswith('..') else r + + # ── declarations: types and methods, with the display names the query skill uses when the index is there ── + owners.update({r['id']: r['owner_type_id'] for r in q("SELECT id, owner_type_id FROM methods WHERE provenance='client'")}) + if has('symbols'): + for r in q("SELECT * FROM symbols WHERE (method_id IS NOT NULL OR type_id IS NOT NULL)"): + if r['method_id'] and r['kind'] in METHOD_KINDS: + nodes[r['method_id']] = dict(id=r['method_id'], k='method', sk=r['kind'], l=r['display'], f=r['file'], a=r['line'], b=r['end_line'], t=int(bool(r['is_test'])), sig=r['signature'] or '') + elif r['type_id'] and r['kind'] in TYPE_KINDS: + nodes[r['type_id']] = dict(id=r['type_id'], k='type', sk=r['kind'], l=r['display'], f=r['file'], a=r['line'], b=r['end_line'], t=int(bool(r['is_test']))) else: - e['w'] += 1 - if r['tier'] == 'known_edge': e['tier'] = 'known_edge' - # a call through a holder the engine could name (#1206): the caller reaches every function the holder may hold. The call - # site itself resolves to the holder's bodiless base (Function.apply), so without these the stored function has no caller - if has('ext_fn_value_call'): - for r in q("SELECT DISTINCT c0, c1 FROM ext_fn_value_call"): - c, t = r[0], r[1] - if c in nodes and t in nodes and (c, t) not in E: E[(c, t)] = dict(s=c, t=t, tier='fn_value', w=1) + cat = lambda c: 'interface' if 'INTERFACE' in c else 'enum' if 'ENUM' in c else 'namespace' if 'NAMESPACE' in c else 'type' if 'ALIAS' in c or 'TYPEDEF' in c else 'class' + for r in q("SELECT * FROM types WHERE provenance='client'"): + f = rel(r['file_path']); nodes[r['id']] = dict(id=r['id'], k='type', sk=cat(r['category']), l=r['name'].replace('$', '.'), f=f, a=r['start_line'], b=r['end_line'], t=int(bool(TESTRE.search(f)))) + for r in q("SELECT * FROM methods WHERE provenance='client'"): + f = rel(r['file_path']); o = (r['owner_qualified_name'] or '').replace('#', '.').split('.')[-1].replace('$', '.') + sk = 'constructor' if 'CONSTRUCTOR' in r['kind'] else 'module' if 'MODULE' in r['kind'] else 'method' if r['owner_type_id'] else 'function' + nodes[r['id']] = dict(id=r['id'], k='method', sk=sk, l=f"{o}.{r['name']}" if o else r['name'], f=f, a=r['start_line'], b=r['end_line'], t=int(bool(TESTRE.search(f))), sig=r['signature'] or '') + lib.update({r['id']: (r['owner_qualified_name'] or r['qualified_name'].rsplit('.', 1)[0], r['name']) for r in q("SELECT id, qualified_name, name, owner_qualified_name FROM methods WHERE provenance<>'client'")}) + if has('nesting'): nesting.update({r['type_id']: r['outer_type_id'] for r in q("SELECT type_id, outer_type_id FROM nesting")}) + + # ── calls: one edge per (caller, callee) with the best tier and the site count; unresolved → a count on the caller ── + # only an ambiguous_* tier is unresolved. boundary_lib is a call into a library; the *_terminal, implicit_constructor and + # fan_capped tiers are correct ends (the platform, a synthesised constructor, a computed target) — the same rule as the + # query skill, so the two never disagree about what is a blind spot + for r in q("SELECT e.caller_id, e.callee_method_id, e.tier, e.callee_label, s.start_line AS line, s.callee_name AS written FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id"): + c, t = r['caller_id'], r['callee_method_id'] + if c not in nodes: continue + if not t or t not in nodes: + if t in lib: box(c, *lib[t]); site(c, r, 'L:' + lib[t][0]) + elif r['tier'] == 'boundary_lib' and r['callee_label']: + # Python / JavaScript: the library target is a label — builtin:len, external:asyncio.get_running_loop — not a method row + prov, _, name = r['callee_label'].partition(':'); owner, _, meth = name.rpartition('.') + owner = owner or (('builtins' if prov == 'builtin' else name)); box(c, owner, meth or name); site(c, r, 'L:' + owner) + elif (r['tier'] or '').startswith('ambiguous'): unresolved[c] += 1; site(c, r, '') + else: terminal[c] += 1 + continue + site(c, r, t) + e = E.get((c, t)) + if e is None: E[(c, t)] = dict(s=c, t=t, tier=r['tier'], w=1) + else: + e['w'] += 1 + if r['tier'] == 'known_edge': e['tier'] = 'known_edge' + # a call through a holder the engine could name (#1206): the caller reaches every function the holder may hold. The call + # site itself resolves to the holder's bodiless base (Function.apply), so without these the stored function has no caller + if has('ext_fn_value_call'): + for r in q("SELECT DISTINCT c0, c1 FROM ext_fn_value_call"): + c, t = r[0], r[1] + if c in nodes and t in nodes and (c, t) not in E: E[(c, t)] = dict(s=c, t=t, tier='fn_value', w=1) + for r in q("SELECT method_id, reason FROM entry_points"): + if r['method_id'] in nodes: nodes[r['method_id']].setdefault('e', []).append(r['reason']) + for r in q("SELECT type_id, ancestor_type_id FROM type_ancestors"): anc[r['type_id']].add(r['ancestor_type_id']) + # two shapes share the name: the bundle's (file_path, reason, construct, start_line, …) and the index's (file, reason), which + # the index writes over the bundle's — read whichever is there + if has('skipped'): + cols = {r[1] for r in q("PRAGMA table_info(skipped)")}; col = lambda c: c if c in cols else 'NULL' + fc = 'file_path' if 'file_path' in cols else 'file' + skipped = (skipped or []) + [dict(f=rel(r[0]), r=r[1] or '', a=r[2], d=r[3] or '') for r in q(f"SELECT {fc}, reason, {col('start_line')}, {col('detail')} FROM skipped WHERE {fc} <> '' ORDER BY reason, {fc}")] + tiers.update(r[0] for r in q("SELECT tier FROM call_edges")) + con.close() + + # ── containment: dir → file → type → method (nested types under their outer; free functions under the file) ── + for n in list(nodes.values()): + fid = 'F:' + n['f'] + if fid not in nodes: + d = os.path.dirname(n['f']) or '.' + nodes[fid] = dict(id=fid, k='file', l=n['f'], f=n['f'], t=int(bool(TESTRE.search(n['f']))), p='D:' + d) + if 'D:' + d not in nodes: nodes['D:' + d] = dict(id='D:' + d, k='dir', l=d, f='', t=int(bool(TESTRE.search(d + '/')))) + if n['k'] == 'type': n['p'] = nesting[n['id']] if nesting.get(n['id']) in nodes else fid + elif n['k'] == 'method': o = owners.get(n['id']); n['p'] = o if o in nodes else fid for m, n in unresolved.items(): nodes[m]['u'] = n for m, ss in sites.items(): nodes[m]['cs'] = ss for owner, b in boxes.items(): nodes['L:' + owner] = dict(id='L:' + owner, k='lib', l=owner, f='', sites=b['sites'], callers=len(b['callers']), methods=[f"{k}×{v}" if v > 1 else k for k, v in b['methods'].most_common(8)]) - for r in q("SELECT method_id, reason FROM entry_points"): - if r['method_id'] in nodes: nodes[r['method_id']].setdefault('e', []).append(r['reason']) X = [] # extends / implements: direct ancestors only (type_ancestors is a closure) - anc = collections.defaultdict(set) - for r in q("SELECT type_id, ancestor_type_id FROM type_ancestors"): anc[r['type_id']].add(r['ancestor_type_id']) for t, A in anc.items(): if t in nodes: for a in A: @@ -133,21 +170,13 @@ def export(repo, page=None): for n in nodes.values(): if deg[n['id']]: n['d'] = deg[n['id']] - # two shapes share the name: the bundle's (file_path, reason, construct, start_line, …) and the index's (file, reason), which - # the index writes over the bundle's — read whichever is there - skipped = None - if has('skipped'): - cols = {r[1] for r in q("PRAGMA table_info(skipped)")}; col = lambda c: c if c in cols else 'NULL' - fc = 'file_path' if 'file_path' in cols else 'file' - skipped = [dict(f=rel(r[0]), r=r[1] or '', a=r[2], d=r[3] or '') for r in q(f"SELECT {fc}, reason, {col('start_line')}, {col('detail')} FROM skipped WHERE {fc} <> '' ORDER BY reason, {fc}")] - tiers = collections.Counter(r[0] for r in q("SELECT tier FROM call_edges")) sites = sum(tiers.values()); unres = sum(v for t, v in tiers.items() if t.startswith('ambiguous')) client = tiers.get('known_edge', 0) + tiers.get('multi_inferred', 0); external = sum(b['sites'] for b in boxes.values()) terminal_n = sites - unres - client - external - data = dict(meta=dict(repo=os.path.basename(repo), language=run.get('language', '?'), built=time.strftime('%Y-%m-%d %H:%M'), sites=sites, + data = dict(meta=dict(repo=os.path.basename(repo), language=', '.join(langs) or '?', built=time.strftime('%Y-%m-%d %H:%M'), sites=sites, client_sites=client, external_sites=external, terminal_sites=terminal_n, unresolved=unres, boxes=len(boxes), client_rate=round(100 * client / max(1, sites - external - terminal_n)), overall_rate=round(100 * (sites - unres) / max(1, sites)), - tiers=dict(tiers), library_roots=run.get('library_roots', '') or (run.get('language') != 'java' and 'n/a') or '', counts=dict(collections.Counter(n['k'] for n in nodes.values())), edges=len(E)), + tiers=dict(tiers), library_roots=runs[0].get('library_roots', '') or ('java' not in langs and 'n/a') or '', counts=dict(collections.Counter(n['k'] for n in nodes.values())), edges=len(E)), nodes=list(nodes.values()), calls=list(E.values()), libcalls=list(LE.values()), extends=X, skipped=skipped) data['meta']['fn_value_edges'] = sum(1 for e in E.values() if e['tier'] == 'fn_value') short = {} @@ -174,29 +203,93 @@ def export(repo, page=None): data['files'] = files; data['meta']['repo_path'] = repo; data['meta']['sources'] = bool(files) tpl = open(os.path.join(HERE, 'viewer.html'), encoding='utf-8').read() open(page, 'w', encoding='utf-8').write(tpl.replace('/*__DATA__*/null', json.dumps(data, separators=(',', ':')).replace(',`")) - print(f"page: {os.path.relpath(page)}") + m, n = data['meta'], data['meta']['counts'] + # PROSE, NOT A DICT: an agent relays this line to a person, and a raw {'dir': …} or a ../../../ path it had to resolve + # itself was what it relayed + if how: print(how) + k = lambda v, one, many: f"{v:,} {one if v == 1 else many}" + print(f"graph of {repo} ({m['language']}): {k(n.get('method', 0), 'function or method', 'functions and methods')}, " + f"{k(n.get('type', 0), 'type', 'types')}, {k(n.get('file', 0), 'file', 'files')} in {k(n.get('dir', 0), 'directory', 'directories')}; " + f"{k(m['edges'], 'call edge', 'call edges')}, {len(X):,} extends/implements.") + print(f"calls: {k(m['sites'], 'call site', 'call sites')}. Of the calls into this repository's own code {m['client_rate']}% resolved " + f"({m['client_sites']:,} resolved, {m['unresolved']:,} unresolved); {m['external_sites']:,} go into " + f"{k(m['boxes'], 'library type', 'library types')}, drawn as black boxes; {m['terminal_sites']:,} end at the platform.") + if not m['library_roots']: + print(f"indexed without library roots: calls into the JDK and other dependencies cannot resolve and count as unresolved. " + f"To resolve them, index again with them: axiomcode index {repo} --library ,") + print(f"page: {os.path.abspath(page)}") -def build(repo, page, library=None): - repo = os.path.realpath(repo or '.') - if library: os.environ['AXIOMCODE_LIBRARY'] = ','.join(os.path.abspath(os.path.expanduser(x)) for x in library.split(',')) +def run_build(repo, env=None): + """axiomcode-build on repo, its progress on this process's stdout; exits with its status when it fails""" cands = [os.path.join(HERE, 'axiomcode-build'), os.path.join(os.environ.get('AXIOMCODE_ENGINE', ''), 'skills', 'axiomcode', 'scripts', 'axiomcode-build')] b = next((os.path.realpath(c) for c in cands if os.path.isfile(c)), None) if not b: sys.exit("axiomcode-build not found beside this script (set AXIOMCODE_ENGINE to the engine checkout)") - r = subprocess.run([os.environ.get('AXIOMCODE_BASH') or 'bash', b, repo]) # a bash script: Windows cannot exec it itself + sys.stdout.flush() + r = subprocess.run([os.environ.get('AXIOMCODE_BASH') or 'bash', b, repo], env=env) # a bash script: Windows cannot exec it itself if r.returncode: sys.exit(r.returncode) - export(repo, page) + + +def recorded(repo): + """the file table of the graph that is there: the languages, --src and --library it was indexed with. A graph from + before the table has its main language, --src and --library in its run table; its other languages are the graphs + beside it. None when there is nothing to read them from.""" + import ax_fresh + t = ax_fresh.load_table(repo) + if t: return t + t = ax_fresh.legacy_params(repo) + if not t: return None + ld = os.path.join(repo, '.axiomcode', 'lang') + others = sorted(l for l in (os.listdir(ld) if os.path.isdir(ld) else ()) if os.path.exists(os.path.join(ld, l, 'out', 'graph.sqlite'))) + return dict(t, lang=','.join([t['lang']] + [l for l in others if l != t['lang']]), lang_auto=False, legacy=True) + + +def build(repo, page, library=None): + """the page, from the graph that is there when it is current, else from a rebuild of it with the flags it was indexed + with. THE ENGINE RUNS ONLY WHEN IT HAS TO, AND ONLY FOR THE LANGUAGES THE INDEX CHOSE. This called axiomcode-build with + no flags, which detects every language in the tree: on a repository indexed with --lang python its answer never matched + the recorded graph, so every call rebuilt the graph from scratch in every language present, a JavaScript or TypeScript + compile for a few stray files included, and left a graph for each of them that later queries answered from.""" + import ax_fresh + repo = os.path.realpath(repo or '.') + if library: os.environ['AXIOMCODE_LIBRARY'] = ','.join(os.path.abspath(os.path.expanduser(x)) for x in library.split(',')) + db = os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite') + asked = [f"{k}={os.environ[v]}" for k, v in (('--lang', 'AXIOMCODE_LANG'), ('--src', 'AXIOMCODE_SRC'), ('--library', 'AXIOMCODE_LIBRARY')) if os.environ.get(v)] + if os.environ.get('AXIOMCODE_GRAPH'): return export(repo, page) # a graph someone else built and placed: drawn as it is + ax_fresh.relink(repo) # never another checkout's graph (#1605) + if not os.path.lexists(db): + # no graph yet: the first index, with what was asked for (every language present, when nothing was) + t0 = time.time(); run_build(repo) + return export(repo, page, how=f"no graph yet: indexed {repo}{' with ' + ' '.join(asked) if asked else ''} in {time.time() - t0:.0f} s.") + if asked: + # flags given here are a request for that graph: axiomcode-build keeps the one there when it already is that graph + t0 = time.time(); run_build(repo) + return export(repo, page, how=f"graph built with {' '.join(asked)} ({time.time() - t0:.0f} s).") + t = recorded(repo) + if t is None: + return export(repo, page, how="drawn from the graph as it is: it records no index flags to rebuild it with.") + c = None if t.get('legacy') else ax_fresh.changes(repo, t) + langs = (t.get('lang') or '?').replace(',', ', ') + if c is not None and not any(c) and not ax_fresh.graph_broken(repo): + return export(repo, page, how=f"graph up to date ({langs}): drawn from it, no rebuild.") + n = sum(len(x) for x in c) if c else 0 + flags = ' '.join(f for f in (('--lang ' + t['lang']) if not t.get('lang_auto', True) else 'every language present', + ('--src ' + t['src_arg']) if t.get('src_arg') else '', ('--library ' + t['library']) if t.get('library') else '') if f) + print(f"the graph is out of date ({n} file(s) changed since it was built): rebuilding it as it was indexed ({flags}) …" if n else + f"rebuilding the graph as it was indexed ({flags}) …", flush=True) + t0 = time.time(); run_build(repo, ax_fresh.rebuild_env(t, AXIOMCODE_REFRESH_REASON='axiomcode graph')) + return export(repo, page, how=f"graph rebuilt ({flags}) in {time.time() - t0:.0f} s.") if __name__ == '__main__': a = sys.argv[1:]; page = None; library = None if '--out' in a: i = a.index('--out'); page = os.path.abspath(os.path.expanduser(a[i + 1])); del a[i:i + 2] if '--library' in a: i = a.index('--library'); library = a[i + 1]; del a[i:i + 2] - verb, pos = (a[0] if a else ''), a[1:] - if verb == 'build': build(pos[0] if pos else '.', page, library) - elif verb == 'export': export(pos[0] if pos else '.', page) - else: print(__doc__.strip()); sys.exit(0 if verb in ('', '-h', '--help') else 2) + if a and a[0] in ('-h', '--help'): print(__doc__.strip()); sys.exit(0) + # `axiomcode graph []` is the verb; `build` and `export` are the older spellings, still taken. A directory + # named `build` or `export` is a repository, not a subcommand + verb = a[0] if a and a[0] in ('build', 'export', 'draw') and not os.path.isdir(a[0]) else 'draw' + pos = a[1:] if a and verb == a[0] else a + if len(pos) > 1 or any(x.startswith('-') for x in pos): print(__doc__.strip(), file=sys.stderr); sys.exit(2) + if verb == 'export': export(pos[0] if pos else '.', page, how="drawn from the graph as it is (export: no freshness check, no rebuild).") + else: build(pos[0] if pos else '.', page, library) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 467ec300e..8ded6586c 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -78,7 +78,7 @@ PER_QUERY = {'target', 'textuse', 'importuse', 'inside_target', 'nonsource', 'qu # it obtained itself, so an added proxied annotation does not apply") — the differential test caught that immediately. # `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', +_REF_LAYER = {'qualifier', 'typeref', 'typeref_file', 'type_alias', 'jsx_props', 'jsx_tag', 'discriminant', 'keyed_literal', 'literal', 'dec_literal', 'field', 'accessor', 'faccess', 'gen_table', '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 @@ -112,8 +112,23 @@ def ckey(k): # and it is not a call to the declaration at all, so it cannot outrank a target set. # `framework` (#1509) sits directly below `remote` for the same reason: the engine joined both ends on the # framework's own registration, so it outranks every name match, and it is not a call to the declaration. +# `stubs it` (a call inside a mock's stub or verification) is the engine's resolution, so it outranks every name match, +# and runs nothing, so it sits below every hop that does: a rename breaks it, a body change never does. CERT = {'resolved': 0, 'one of a set': 1, 'registered': 2, 'capped set': 3, 'remote': 4, 'framework': 5, - 'in scope': 6, 'by name': 7, 'text': 8, 'alongside': 9} + 'stubs it': 6, 'in scope': 7, 'by name': 8, 'text': 9, 'alongside': 10} +def also_text(whys, shown=2): + """the other reasons a row's callable has, said on the same line: `; also: ` (one row per dependent)""" + if not whys: return '' + return '; also: ' + '; '.join(whys[:shown]) + (f" (+{len(whys) - shown} more)" if len(whys) > shown else '') + + +def merge_reasons(why, others): + """-> (why, others left): reasons that differ from the row's only in their last word -- `using the same field a`, + `... field b` -- are said once, `using the same field a, b`, rather than as a sentence each""" + head = why.rsplit(' ', 1)[0] + ' ' if ' ' in why else None + same = [o for o in others if head and o.startswith(head) and ' ' not in o[len(head):]] + if not same: return why, others + return why + ', ' + ', '.join(o[len(head):] for o in same), [o for o in others if o not in same] LOCAL_KINDS = {'LOCAL_VARIABLE', 'PARAMETER', 'LAMBDA_PARAMETER', 'VARIABLE', 'PARAM'} # a ref the parser says is a local, not a member QUALIFIED_KINDS = {'FIELD_ACCESS', 'PROPERTY_ACCESS', 'ATTRIBUTE_ACCESS'} MEMBER_KINDS = {'FIELD', 'PROPERTY', 'ATTRIBUTE', 'METHOD', 'FUNCTION'} # written `x.name`, against a receiver; the rest are bare names @@ -129,8 +144,11 @@ TEST_DECOR = re.compile(r'(^|\.)(\w*Test\w*|Fact|Theory|it|test)$') # `RFC2616PolicyTestMixin`, `StorageTestMixin`, `TestBase` -- declares real tests, collected # through a subclass named Test*. Anchoring the name dropped every one of them. TEST_OWNER = re.compile(r'(Test|Spec|ITCase)') -# a decoration that says the method is a dependency the framework builds, not a test -NON_TEST_DECOR = re.compile(r'^(Bean|Configuration|Component|Provides|Produces|TestConfiguration)$') +# a decoration that says the method is a dependency the framework builds, not a test. A pytest `@fixture` is one +# whatever its name: `def test_image()` under it is built for the tests that request it, never collected (#1531). +NON_TEST_DECOR = re.compile(r'^(Bean|Configuration|Component|Provides|Produces|TestConfiguration|fixture)$') +# a file the runner imports for its fixtures and hooks and never collects tests from +NON_TEST_FILE = re.compile(r'(^|/)conftest\.py$') RET_TYPE = re.compile(r'\)\s*:\s*(.+)$') # a URL path as a router accepts it: path-absolute (RFC 3986 §3.3) with no whitespace and no quote in it. A path # parameter — `{id}`, ``, `:id`, `*` — is left exactly as written: its spelling is per framework, and the @@ -276,6 +294,8 @@ class Impact: resolver already matched that path against the index, so there is no prefix left to check.""" m = re.match(r'^(.+?):(\d+)$', typed.strip().strip('`"\'')) if m and ('/' in m.group(1) or '.' in m.group(1)): return '' + # `Owner.m.`: the resolver matched the enclosing member it names, so its prefix is checked + if re.search(r'<\w+@L\d+>$', typed.strip()): return '' typed = re.sub(r'\(.*\)$', '', typed).replace('#', '.').strip('.') if typed.count('.') < 2: return '' # Owner.member carries no prefix to validate names = [] @@ -358,6 +378,21 @@ class Impact: f" properties file, a YAML, an XML). Do not read this as 'nothing uses it'.") return [('string', f"the string {raw} ({n} literal site(s), {d} decoration site(s))", v)] s = raw.strip('`"\'') + # FILE:LINE ON A FIELD, A PROPERTY OR A TYPE HEADER is that declaration. The line resolver takes callables, so + # such a line answered `no callable spans` in every language (a class line in C#, #1426); the declaration at + # the line is asked about as its name would be, and a callable written on the line still wins (G.decl_at_line) + mfl = re.match(r'^(.+?):(\d+)(?::\d+)?$', s) + if mfl and ('/' in mfl.group(1) or '.' in mfl.group(1)) and kind in (None, 'field', 'type'): + f, ln = self.g.repo_file(mfl.group(1))[0], int(mfl.group(2)) + files = sorted({x['file'] for x in self.g.q("SELECT DISTINCT file FROM symbols WHERE file = ? OR file LIKE ?", f, f"%/{f}") + if x['file'] == f or (x['file'] or '').endswith('/' + f)}) + if f in files: files = [f] + d = self.g.decl_at_line(files[0], ln) if len(files) == 1 else None + if d and d[0] == 'field' and kind in (None, 'field'): + row = self.fields.get(d[1]['rowid']) or d[1] + return [('field', f"{row['kind']} {row['display']} (at {s})", [row])] + if d and d[0] == 'type' and kind in (None, 'type'): + return [('type', f"{d[1]['kind']} {d[1]['display']} (at {s})", [d[1]['id']])] # a lowercase dotted name is read as a configuration key when the graph has configuration facts, or when its last # segment names nothing the code declares; `util.square` ending in a real function is a declaration that missed if (kind == 'config' or (not kind and self.CONFIG_RE.match(s) and (self.has_config() or not self.declared_name(s.rsplit('.', 1)[-1])) @@ -431,6 +466,12 @@ class Impact: if kind in (None, 'type'): tids = self.types(base, soft=True) if tids: out.append(('type', f"{self.g.sym[tids[0]]['kind']} {self.g.disp(tids[0])}" + (f" (+{len(tids)-1})" if len(tids) > 1 else ''), tids)) + # a const declared ON the line is the declaration written there: the enclosing module spans the line and a + # function in its initializer (`const h = wrap(async (req, res) => …)`) starts on it, and neither is what was asked + # — unless that function IS the const (`const h = async (req, res) => …` is named `h`), which stays the method + if kind is None and out and out[0][0] == 'field' and re.fullmatch(r'.+\.\w+:\d+', s): + named = {self.g.sym[i]['name'] for i in self.methods(s, soft=True) if self.g.sym[i].get('line') == out[0][2][0]['line']} + if not named & {f['name'] for f in out[0][2]}: return out if kind in (None, 'method'): mids = self.methods(s, soft=True) # `Type` written alone is the type, and its construction is Type., so a bare type name must not @@ -445,7 +486,9 @@ class Impact: # a bodiless signature beside the function it annotates (file:line on `const f: T = …`) is ONE declaration bodied = [i for i in mids if self.g.method_kind(i) not in BODILESS_KINDS] if mids else [] one = len(mids) == 1 or (len(bodied) == 1 and ':' in s) - if mids: out.append(('method', (self.g.disp((bodied or mids)[0]) + (f" (at {s})" if ':' in s else '') if one else f"{s} ({len(mids)} declarations)"), mids)) + # a lambda is named by where it is (G.lambda_label), not by the one name every lambda carries + if mids: nm0 = (bodied or mids)[0]; shown = self.g.name(nm0) + if mids: out.append(('method', (shown + (f" (at {s})" if ':' in s else '') if one else f"{s} ({len(mids)} declarations)"), mids)) if not out: # an inherited member is written under the name the code uses: Sub.member, declared in a base. Answer for the # declaration and say where it is, instead of falling through to a name match that no rule can act on @@ -488,7 +531,8 @@ class Impact: if soft: return [] print(buf.getvalue(), end=''); raise last = re.sub(r'\(.*\)$', '', s).replace('#', '.').replace('$', '.').rstrip('.').split('.')[-1] - ids = [i for i in ids if self.g.sym.get(i, {}).get('method_id') and self.g.sym[i]['kind'] not in ('library', 'written') and (self.g.sym[i]['name'] == last or ':' in s)] + lam = bool(re.search(r'<\w+@L\d+>$', s)) # Owner.m.: the lambda at that line + ids = [i for i in ids if self.g.sym.get(i, {}).get('method_id') and self.g.sym[i]['kind'] not in ('library', 'written') and (self.g.sym[i]['name'] == last or ':' in s or lam)] if not ids and not soft: die(f"{s} is not a method in the graph") return ids def types(self, s, soft=False): @@ -504,6 +548,12 @@ class Impact: if soft: return [] die(f"no type named {s}") def field_rows(self, s): + # `file.js:12` — the field or const DECLARED on that line. Split on `.` it named a field `js:12`, so a const was + # the one declaration a file:line could not target, and its bare name answered for every const so named + fl = re.fullmatch(r'(.+\.\w+):(\d+)', s) + if fl: + p, n = fl.group(1), int(fl.group(2)) + return [f for f in self.fields.values() if f['line'] == n and (f['file'] == p or (f['file'] or '').endswith('/' + p))] parts = s.split('.'); name = parts[-1]; owner = '.'.join(parts[:-1]) rows = [f for f in self.fields.values() if f['name'] == name and (not owner or (f['owner'] or '') == owner or (f['owner'] or '').endswith('.' + owner) or (f['qualified_name'] or '').endswith('.' + s))] if not rows and '.' in s: # the dotted spelling, only after the written one @@ -680,7 +730,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 = '29' # 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 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 + IMPACT_VERSION = '37' # 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 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 @@ -782,6 +832,7 @@ class Impact: # the type written on the field's own declaration line. Without it `holds` is file scoped: one field of type T # in a file makes every type declared in that file a holder of T ftypes = [] + import graph_sql for rid, f in self.fields.items(): # THE SAME ID THE `field` FACT USED. This built `f:` unconditionally while `field` used the graph's # own id where there is one, so on any bundle whose fields carry ids the two relations never joined: every @@ -791,11 +842,8 @@ class Impact: fid = f['id'] or f"f:{rid}" if not f.get('file') or not f.get('line'): continue L = self.lines(f['file']); line = L[f['line'] - 1] if f['line'] <= len(L) else '' - m = re.search(rf'([A-Za-z_$][\w$.]*)\s*(?:<[^;=]*>)?\s*(?:\[\s*\])*\s+{re.escape(f["name"])}\s*[;=,)]', line) - if m: ftypes.append((fid, m.group(1).split('.')[-1])) - else: - m = re.search(rf'{re.escape(f["name"])}\s*:\s*([A-Za-z_$][\w$.]*)', line) # name: Type - if m: ftypes.append((fid, m.group(1).split('.')[-1])) + t = graph_sql.field_decl_type(line, f['name']) # `Type name;` or `name: Type` + if t: ftypes.append((fid, t)) W('field_type', ftypes) W('extends', [(r[0], r[1]) for r in g.q("SELECT type_id, ancestor_type_id FROM type_ancestors")]) W('nested', [(r[0], r[1]) for r in g.q("SELECT type_id, outer_type_id FROM nesting")] if g.has('nesting') else []) @@ -854,13 +902,31 @@ class Impact: # EVERY TIER IN THIS GRAPH GETS A ROW, including one the table has never seen — the call rules join # on cert_tier, and a tier with no row would drop its dependents out of the answer entirely rather # than mislabel them, which is the worse of the two failures. ax_edges.direct_cert has the default. - W('cert_tier', sorted({(t, ax_edges.direct_cert(t), ax_edges.DIRECT_WHY.get(ax_edges.direct_cert(t), 'calls it')) - for (t,) in g.q("SELECT DISTINCT tier FROM call_edges")})) - W('calls', [(r['caller_id'], r['callee_method_id'], r['tier'], g.site_file(r['file_path']) if r['file_path'] else '', r['start_line'] or 0) - for r in g.q("SELECT e.caller_id, e.callee_method_id, e.tier, s.file_path, s.start_line FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id WHERE e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL")]) + W('cert_tier', sorted({(t, ax_edges.direct_cert(t), ax_edges.direct_why(t)) + for t in {r[0] for r in g.q("SELECT DISTINCT tier FROM call_edges")} | {ax_edges.STUB_TIER}})) + # a call written inside a mock's stub or verification keeps its row and loses its tier: "stub" (ax_edges.stub_sites) + stubs = ax_edges.stub_sites(lambda s, p: g.q(s, *p)) if g.has('call_sites') else set() + W('calls', [(r['caller_id'], r['callee_method_id'], ax_edges.STUB_TIER if r['call_site_id'] in stubs else r['tier'], g.site_file(r['file_path']) if r['file_path'] else '', r['start_line'] or 0) + for r in g.q("SELECT e.call_site_id, e.caller_id, e.callee_method_id, e.tier, s.file_path, s.start_line FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id WHERE e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL")]) + # a hand-off on EVERY line its call site spans: a chained registration (`router\n .route('/')\n .post(auth(), ctrl.h)`) + # records its edges on the statement's first line, while the handler is named on a later one + W('handoff_at', sorted({(r['caller_id'], r['callee_method_id'], g.site_file(r['file_path']), l) for r in g.q( + """SELECT e.caller_id, e.callee_method_id, s.file_path, s.start_line, s.end_line FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id + WHERE e.tier = 'callback_registered' AND e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL AND s.file_path IS NOT NULL""") + for l in ax_edges.site_lines(r['start_line'], r['end_line'])})) + # a name in a handler position of a route call, and the consts of those names that hold a function + rargs = ax_registration.route_args(g.q, self.code, g.site_file) + rnames = {n for *_x, n in rargs} + fcall, fwrap, falias = ax_registration.const_values(g.q, self.code, sorted({(f['file'], f['line'], f['name']) for f in self.fields.values() + if f['name'] in rnames and f.get('file') and f.get('line')}), g.site_file) + W('route_arg', sorted(rargs)) + W('callable_const', sorted(fcall)); W('init_wrapper', sorted(fwrap)); W('init_alias', sorted(falias)) + W('returns_fn', sorted(ax_registration.returned_functions(g.q))) CTOR = ('new', 'anon_new', 'CONSTRUCTOR_CALL') - W('unresolved', [(r['caller_id'], (r['callee_name'] or '').split('.')[-1], 'new' if r['kind'] in CTOR else 'method', g.site_file(r['file_path']) if r['file_path'] else '', r['start_line'] or 0) - for r in g.q("SELECT s.caller_id, s.callee_name, s.kind, s.file_path, s.start_line FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.callee_name IS NOT NULL AND s.callee_name <> ''")]) + # an unresolved site inside a mock's stub or verification (a Moq lambda whose parameter the engine did not type) + # is the kind "stub": still a name match for the rows, never a by-name seed of the closure + W('unresolved', [(r['caller_id'], (r['callee_name'] or '').split('.')[-1], 'new' if r['kind'] in CTOR else ax_edges.STUB_TIER if r['id'] in stubs else 'method', g.site_file(r['file_path']) if r['file_path'] else '', r['start_line'] or 0) + for r in g.q("SELECT s.id, s.caller_id, s.callee_name, s.kind, s.file_path, s.start_line FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.callee_name IS NOT NULL AND s.callee_name <> ''")]) # a call site with a name that the engine did NOT bind to a client declaration: unresolved, or sent to a library # because the callee is generated (a dataclass constructor is a call to the class itself). The generated-member # rules look here, so a generated accessor or constructor is found whichever way the engine classified the site @@ -881,10 +947,24 @@ class Impact: W('ref', refs); W('qualifier', sorted(set(quals))) W('registration', ax_registration.registrations(g.q, g.site_file)) trs = []; trf = set() + # a reference on a module-level type alias's own lines belongs to the alias, not the module (#784) + import graph_sql + aspans, anames = graph_sql.type_aliases(g.q) + W('type_alias', sorted((a,) for a in anames)) + # an intrinsic element and the props type its tag maps to: `

` is checked against FormElementProps (#784) + props = graph_sql.jsx_props(g.q) + W('jsx_props', sorted(props)) + W('jsx_tag', graph_sql.jsx_tags({s['file'] for s in g.sym.values() if s.get('file')}, self.code, self.at, + {tag for tag, _n in props})) + # a discriminated type's tag, and the object literals that write it: `{ kind: 'LiteralNode', … }` (#784) + disc = graph_sql.discriminants(g.q) + W('discriminant', sorted(disc)) + W('keyed_literal', graph_sql.keyed_literals(g.q, self.code, self.at, {v for _t, _k, v in disc})) + holder = graph_sql.typeref_holder(self.at, aspans, mods) if g.has('type_refs'): for r in g.q("SELECT name, file, line, context FROM type_refs"): if r['line'] and r['line'] > 0: - c = self.at(r['file'], r['line']) + c = holder(r['file'], r['line']) if c: trs.append((c, r['name'], r['context'], r['file'], r['line'])) else: trf.add((r['file'], r['name'], r['context'])) W('typeref', trs); W('typeref_file', sorted(trf)) @@ -928,6 +1008,9 @@ class Impact: + ax_registration.registrations(g.q, g.site_file)): if rd in g.sym and key: regk.append((rd, kind, key)) W('reg_key_fact', sorted(set(regk))) + # the HTTP method each side names, where it names one (ax_registration.route_verbs / literal_verbs) + W('reg_verb', sorted(x for x in ax_registration.route_verbs(g.q, g.site_file) if x[0] in g.sym)) + W('lit_verb', sorted(ax_registration.literal_verbs(g.q, self.at, g.site_file))) # THE NAME A DECORATOR REBINDS. `@audited def summarise(...)` leaves the name `summarise` denoting what # `audited(summarise)` RETURNED — the wrapper — and the engine resolves that (ext_decorated_name_target). # Every caller written `summarise(...)` therefore runs the wrapper, and a change to the wrapper reaches @@ -945,8 +1028,8 @@ class Impact: # string contains the other, and no call site joins them — the router does, at run time, by matching the # path. The segments are matched here because Soufflé cannot split a string; WHAT the match means is a rule. # A path parameter is whatever the framework spells it (`{id}` FastAPI, `` a Python web framework, `:id` Express), - # so a registered segment in any of those forms matches any written segment, and nothing is normalised. - PARAM = re.compile(r'\{.*\}|<.*>|:.+|\*.*') + # so a registered segment in any of those forms matches a written segment it accepts, and nothing is normalised. + # The segment rule and the precedence of a verbatim key are ax_registration's, shared with the SQL port. segs = lambda p: p.split('?')[0].split('#')[0].rstrip('/').split('/') written = collections.defaultdict(set); regd = collections.defaultdict(set) for c, v, f, l in lits: @@ -956,10 +1039,12 @@ class Impact: for _d, _k, v in regk: if v.startswith('/'): regd[len(segs(v))].add(v) alias = set() + verbatim = {v for _d, _k, v in regk} for n, ws in written.items(): # bucketed by segment count: a mismatch there is no match for w in ws: + if w in verbatim: continue # a path registered verbatim is served by that route alone for r in regd.get(n, ()): # and only a REGISTERED path is ever the right-hand side - if w != r and all(a == b or PARAM.fullmatch(b) for a, b in zip(segs(w), segs(r))): alias.add((w, r)) + if w != r and ax_registration.route_matches(w, r): alias.add((w, r)) W('route_alias', sorted(alias)) # the BINDING SITE of a configuration key: the one line that connects the key to the code, written as a # placeholder inside a decoration (@Value("${app.server.prefix}"), @ConditionalOnProperty(name = "...")). @@ -983,6 +1068,7 @@ class Impact: # pytest function, a module-level `function testX()`), or a type that is a test class. if not s['name'].startswith(('test', 'it')): return False if any(NON_TEST_DECOR.match(d) for d in decs.get(i, ())): return False + if NON_TEST_FILE.search(s.get('file') or ''): return False own = (s.get('owner') or '').split('.')[-1] if own and not TEST_OWNER.search(own): return False # JUnit 3 reads the convention on `public void testX()`. A method that DECLARES a return @@ -1024,24 +1110,133 @@ class Impact: # Three facts, so the JOIN stays a rule: the parameter names a callable takes, the name and path a fixture # serves under, and the prefixes a file lies under. A fixture in conftest.py serves its directory subtree # (pytest's own rule); any other fixture serves its own file. + # + # WHERE THE ENGINE RESOLVED THE INJECTION, ITS ANSWER IS THE ONE USED. The Python engine's fixture_injection + # edge applies the runner's own resolution: the fixture name the decorator registers (`name=`), the fixtures + # a conftest star-imports or a `pytest_plugins` list loads, the nearest conftest winning, and an argument the + # test's own parametrize supplies directly requesting nothing. The name join below knows none of that, so + # for a file the engine covered it is not written at all: on a Python graph it credited `make_repo`, reached + # through a star-imported fixture, to 0 tests, and a directly parametrized `user` to its fixture (#1537, #1529). fdir = lambda f: f.rsplit('/', 1)[0] + '/' if '/' in f else '' + dtext = collections.defaultdict(list) + for r in (g.q("SELECT owner_id, name, text FROM decorations") if g.has('decorations') else []): dtext[r[0]].append((r[1], r[2] or '')) + engine_fixtures = g.has('ext_framework_edge') + is_py = lambda i: re.search(r'\.pyi?$', (g.sym.get(i) or {}).get('file') or '') is not None + inj = [] + if engine_fixtures: + for c0, c1 in g.q("SELECT c0, c1 FROM ext_framework_edge WHERE c2 = 'fixture_injection'"): + if c0 in g.sym and c1 in g.sym: inj.append((c0, c1)) + W('injects', sorted(set(inj))) params = [] for i, sy in g.sym.items(): if not sy.get('method_id') or sy['kind'] == 'module': continue + if engine_fixtures and is_py(i): continue sig = sy.get('signature') or '' for n in re.findall(r'[A-Za-z_]\w*', sig[sig.find('(') + 1:sig.rfind(')')] if '(' in sig else ''): if n != 'self' and n != 'cls': params.append((i, n)) W('takes_param', sorted(set(params))) + # A fixture pytest INJECTS BY NAME runs for the tests that request it, and for no other test beside it. + # The rules that carry a fixture to every test of its file or its class are right for a setUp, a module + # setup or an autouse fixture, and wrong for this one: a module-level `@pytest.fixture def cart()` was + # credited to every test in its file, requested or not (#1527). Such a fixture reaches tests through + # uses_fixture only. An autouse fixture is not in this set: it does run for every test in its scope. + FIX_NAME = re.compile(r'\bname\s*=\s*[\'"]([^\'"]+)[\'"]') + named = [] + for i, ds in dtext.items(): + if i not in g.sym or not is_py(i): continue + fd = [t for n, t in ds if (n or '').split('.')[-1] == 'fixture'] + if fd and not any(re.search(r'autouse\s*=\s*True', t) for t in fd): named.append((i,)) + W('injected_fixture', sorted(set(named))) + # A fixture is ALSO visible where pytest registers the module holding it: a conftest's `from X import *` makes + # X's fixtures that conftest's (its directory), and a module named in `pytest_plugins` is loaded for the whole + # session (every path). The parameter join reads the engine's resolution; a usefixtures marker names the fixture + # as a string and joins here, so without these rows a marker naming a plugin's fixture reached nothing (#1537). + import ast + pyfiles = sorted({sy.get('file') for sy in g.sym.values() if (sy.get('file') or '').endswith('.py')}) + def module_file(dotted, base=''): + tails = (dotted.replace('.', '/') + '.py', dotted.replace('.', '/') + '/__init__.py') + return [f for f in pyfiles if any(f == base + t or (not base and f.endswith('/' + t)) for t in tails)] + extra_scope = collections.defaultdict(set) + for f in pyfiles: + conf = f.rsplit('/', 1)[-1] == 'conftest.py' + src = '\n'.join(self.lines(f)) + if not conf and 'pytest_plugins' not in src: continue + if conf and 'import' not in src and 'pytest_plugins' not in src: continue + try: tree = ast.parse(src) + except (SyntaxError, ValueError): continue + for st in tree.body: + if conf and isinstance(st, ast.ImportFrom) and any(a.name == '*' for a in st.names): + base = fdir(f) + if st.level: + for _ in range(st.level - 1): base = fdir(base.rstrip('/')) + tgt = module_file(st.module or '', base) if st.module else [] + else: + tgt = module_file(st.module or '') + for t in tgt: extra_scope[t].add(fdir(f)) + tg = st.targets if isinstance(st, ast.Assign) else [] + if any(isinstance(t, ast.Name) and t.id == 'pytest_plugins' for t in tg): + for c in ast.walk(st.value): + if isinstance(c, ast.Constant) and isinstance(c.value, str): + for t in module_file(c.value): extra_scope[t].add('') scope = []; auto = [] - dtext = collections.defaultdict(list) - for r in (g.q("SELECT owner_id, name, text FROM decorations") if g.has('decorations') else []): dtext[r[0]].append((r[1], r[2] or '')) - for i in fx: + # a pytest fixture outside a test file (a plugin module, a fixtures module a conftest star-imports) is still + # one a marker can name: `fx` holds only test-file declarations + for i in sorted(set(fx) | {i for (i,) in named} | {i for i, ds in dtext.items() if i in g.sym and is_py(i) and any((n or '').split('.')[-1] == 'fixture' for n, _ in ds)}): sy = g.sym.get(i) or {} f = sy.get('file') or '' if not sy.get('name'): continue - scope.append((i, sy['name'], fdir(f) if f.rsplit('/', 1)[-1] == 'conftest.py' else f)) + # the name a marker or a parameter asks for is the one the decorator registers, when it gives one + reg = next((m.group(1) for n, t in dtext.get(i, ()) if (n or '').split('.')[-1] == 'fixture' for m in [FIX_NAME.search(t)] if m), sy['name']) + scope.append((i, reg, fdir(f) if f.rsplit('/', 1)[-1] == 'conftest.py' else f)) + scope.extend((i, reg, p) for p in extra_scope.get(f, ())) if any(re.search(r'autouse\s*=\s*True', t) for _, t in dtext.get(i, ())): auto.append((i,)) W('fixture_scope', sorted(set(scope))); W('autouse_fixture', sorted(set(auto))) + # A usefixtures marker that decorates NO declaration: a module's `pytestmark = pytest.mark.usefixtures("x")` + # (or a list of marks) applies to every test in that module, and the ini option `usefixtures = x y` (pytest.ini, + # tox.ini, setup.cfg, pyproject.toml) to every test under the directory holding that file. Neither is a + # decoration, so the marker rule never saw them and the fixture's callees reached no test (#1528). Written as + # (path prefix, fixture name): a file for pytestmark, a directory for the ini. + mark_scope = set() + py_test_files = sorted({(g.sym.get(m) or {}).get('file') or '' for m in tm} - {''}) + py_test_files = [f for f in py_test_files if f.endswith('.py')] + def usefixture_names(node): + for c in ast.walk(node): + if isinstance(c, ast.Call) and (getattr(c.func, 'attr', None) or getattr(c.func, 'id', None)) == 'usefixtures': + for a in c.args: + if isinstance(a, ast.Constant) and isinstance(a.value, str): yield a.value + for f in py_test_files: + src = '\n'.join(self.lines(f)) + if 'pytestmark' not in src: continue + try: tree = ast.parse(src) + except (SyntaxError, ValueError): continue + for st in tree.body: + tg = st.targets if isinstance(st, ast.Assign) else [st.target] if isinstance(st, ast.AnnAssign) and st.value else [] + if any(isinstance(t, ast.Name) and t.id == 'pytestmark' for t in tg): + mark_scope |= {(f, n) for n in usefixture_names(st.value)} + dirs = set() + for f in py_test_files: + parts = f.split('/')[:-1] + for k in range(len(parts) + 1): dirs.add('/'.join(parts[:k])) + import configparser + for d in sorted(dirs): + pre = d + '/' if d else '' + for name, sect in (('pytest.ini', 'pytest'), ('tox.ini', 'pytest'), ('setup.cfg', 'tool:pytest'), ('pyproject.toml', None)): + fp = os.path.join(g.repo, pre + name) + if not os.path.isfile(fp): continue + val = None + try: + if sect: + cp = configparser.RawConfigParser(); cp.read(fp) + if cp.has_option(sect, 'usefixtures'): val = cp.get(sect, 'usefixtures') + else: + t = open(fp, errors='replace').read() + blk = re.search(r'^\[tool\.pytest\.ini_options\]\s*$(.*?)(?=^\[|\Z)', t, re.M | re.S) + mv = blk and re.search(r'^usefixtures\s*=\s*(\[[^\]]*\]|"[^"]*"|\'[^\']*\')', blk.group(1), re.M) + if mv: val = ' '.join(re.findall(r'[\'"]([^\'"]*)[\'"]', mv.group(1))) + except (OSError, configparser.Error, UnicodeDecodeError): + val = None + if val: mark_scope |= {(pre, n) for n in re.split(r'[\s,]+', val) if re.fullmatch(r'[A-Za-z_]\w*', n)} + W('usefixtures_scope', sorted(mark_scope)) fsc = set() for i, sy in g.sym.items(): f = sy.get('file') or '' @@ -1108,6 +1303,17 @@ class Impact: if hit: sw.append((i, tid, str(len(hit)))) W('throws_', sorted(set(thr))); W('catches', sorted(set(cat_))); W('switch_over', sorted(set(sw))) W('config', cfg); W('config_key_known', [(k,) for k in sorted(keys)]); W('bean', beans); W('injected', sorted(set(inj))) + # a bean another class registers by naming it in an annotation: the query is graph_sql's, so the rules and the + # hook's fast path read the same rows + import graph_sql + W('registers', sorted({(t, by, kind) for t, rows in graph_sql._registers(g.q).items() for by, kind in rows})) + # the bean an injection point was WIRED to, whatever type the point declares (#1384), and the callers that reach + # a declaration through a base it is override-equivalent to (#1542): both graph_sql's, so the rules and the SQL + # port read the same rows + W('injected_bean', graph_sql.bean_injections(g.q)) + via, via_sites = graph_sql.via_base_rows(g.q, self.lines, stubs) + W('via_base', sorted({(c, m, why, cert, g.site_file(f) if f else '', l, e) for c, m, why, cert, f, l, e in via})) + W('via_site', sorted({(c, m, g.site_file(f) if f else '', l) for c, m, f, l in via_sites})) W('bean_factory', sorted(set(factories))) # A CROSS-PROCESS HOP IS A DEPENDENT, AND IT WAS IN THE DATABASE UNREAD (#1108). # framework-behavior emits remote_edge for a hop no call site expresses -- a queue, a route, a @@ -1274,7 +1480,7 @@ class Impact: prof('query facts written'); r = subprocess.run(prog + ['-F', F, '-D', O], capture_output=True, text=True); prof('souffle returned') if r.returncode: die("souffle failed:\n" + r.stderr[-1200:]) out = {} - for n in ('contract', 'direct', 'direct_edge', 'seed', 'seed_byname', 'reach', 'reach_sure', 'parent_up', 'test_near', 'test_hit', 'inherited_test', 'extbind', 'gen_fired', 'caller_handles', 'caller_unhandled', 'target_throws'): + for n in ('contract', 'direct', 'direct_edge', 'seed', 'seed_byname', 'reach', 'reach_sure', 'parent_up', 'test_near', 'test_hit', 'test_stub', 'inherited_test', 'extbind', 'gen_fired', 'caller_handles', 'caller_unhandled', 'target_throws'): rows = [] for l in open(os.path.join(O, n + '.csv')): p = l.rstrip('\n').split('\t') @@ -1527,7 +1733,20 @@ def main(argv): for c, why, q_ in res['contract']: contract_of[(c, why)].add(LAB[q_]) # sorted once, here: the JSON and the printed answer both read this list, and a relation has no inherent # order — leaving it as the solver emitted it made the same answer come out shuffled between engines. - contract = sorted((k for k in contract_of if k[0] in g.sym), key=lambda cw: (g.disp(cw[0]), g.loc(cw[0]), cw[1])) + # ONE ROW PER DECLARATION. The relation is keyed on (declaration, reason), so an override that is also matched by + # name printed twice, once as "overrides it" and once as "name match", and the header counted it as both. The + # surest reason names the row (a contract the engine resolved before a name match) and the others follow it as + # `also:`; the targets it is bound for are the union of both. + _cwhy = collections.defaultdict(set) + for (c, why) in contract_of: + if c in g.sym: _cwhy[c].add(why) + contract_also, contract_for = {}, {} + contract = [] + for c, whys in _cwhy.items(): + ws = sorted(whys, key=lambda w: ('name match' in w, w)) + contract.append((c, ws[0])); contract_also[c] = ws[1:] + contract_for[c] = set().union(*(contract_of[(c, w)] for w in ws)) + contract.sort(key=lambda cw: (g.disp(cw[0]), g.loc(cw[0]), cw[1])) seeds = {x[0] for x in res['seed']} # `direct` is a SET of (q, c, role, why, cert, f, l) tuples, so two calls to the target on ONE LINE derive one # row and the counter below saw one site: `shape.getCenter().getX() + "," + shape.getCenter().getY()` read as @@ -1577,7 +1796,24 @@ def main(argv): best[k] = (best[k][0], best[k][1], _lower(best[k][2], loc), best[k][3] + ns) # same certainty: the lowest line else: best[k] = (best[k][0], best[k][1], best[k][2], best[k][3] + ns) # a worse one: counted, not located - D = [(c, role, why, cert, loc, n) for (c, why), (role, cert, loc, n) in best.items()] + # ONE ROW PER DEPENDENT IN EACH SECTION. `best` is keyed on (callable, reason) so that each reason keeps its own + # site count and first line, but a callable with two reasons -- a resolved caller that is also a sibling on the + # same field, a framework hop that is also a by-name match, a writer that writes it twice over -- was printed once + # per reason, and the section header counted it once per rung, so the rungs added up to more than the callables + # (#1423). The surest reason is the row; the others are said on it as `also:`. A callable that WRITES and READS a + # field stays in both sections: those are two different answers. + _grp = lambda role: 'w' if role in ('produces', 'writes') else 'r' + by_c = collections.defaultdict(list) + for (c, why), (role, cert, loc, n) in best.items(): by_c[(c, _grp(role))].append((CERT[cert], why, role, cert, loc, n)) + D = []; direct_also = {}; direct_for = {}; direct_reasons = {} + for (c, gk), rs in by_c.items(): + rs.sort() + _r, why, role, cert, loc, n = rs[0] + D.append((c, role, why, cert, loc, n)) + direct_also[(c, gk)] = [w for _x, w, *_ in rs[1:] if w != why] + direct_for[(c, gk)] = set().union(*(from_target[(c, w)] for _x, w, *_ in rs)) + # --json keeps every reason with its own certainty, first line and site count: nothing the old rows said is lost + direct_reasons[(c, gk)] = [{'why': w, 'certainty': ct, 'at': lc, 'sites': nn} for _x, w, _ro, ct, lc, nn in rs] # `alongside` is unbounded by nature — a 200-member type would bury the rest of the answer. Keep the ones that share a # field with the target first, then the nearest by file, and say how many were left out (#762). ALONG_CAP = int(os.environ.get('AXIOMCODE_ALONGSIDE_CAP', '0')) # 0 = keep them all: the PRINTED answer is already bounded by --limit @@ -1615,18 +1851,70 @@ def main(argv): inh = {} for s_, m, d in {(x[0], x[1], int(x[2])) for x in res['inherited_test']}: if m in tests and int(d) <= depth and s_ in g.sym: inh.setdefault(s_, []).append(m) + # a test that only STUBS the change on a mock (`when(repo.find(1))`, `mock.Setup(r => r.Find(1))`): it names the + # declaration, so a rename or a new parameter breaks it, and never runs it, so it is not a route and not counted + stubbed = sorted({x[0] for x in res.get('test_stub', []) if x[0] in g.sym and x[0] not in tests}, + key=lambda m: (g.sym[m]['file'] or '', g.sym[m]['display'] or '', m)) byname_seeds = {x[0] for x in res['seed_byname'] if x[0] in g.sym} + # the callers on an untyped receiver are SAMPLED in the text answer, so their order decides which are shown. Symbol + # ids are not stable between two builds of the same tree, so ordering by id showed a different sample each build: + # they go in source order (file, line, then name), which only an edit changes. + def by_place(c): + f, _, ln = g.loc(c).rpartition(':') + return (f, int(ln), g.disp(c), c) if f and ln.isdigit() else (g.loc(c), 0, g.disp(c), c) sure_set = {x[0] for x in res['reach_sure']} # reached from the dependents that ARE an exact edge universe = sum(1 for _ in open(os.path.join(g.facts, 'impact', 'test_method.facts'))) # an `alongside` row is not a place the change is observed from: it must stay out of the entry points, as it stays out # of the seeds — a reader is promised that an entry point reaches the change (#762) - direct_ids = {c for c, role, why, cert, loc, n in D if cert != 'alongside'} | {c for c, _ in contract} + # …nor is a stub on a mock: it names the declaration and runs none of it + direct_ids = {c for c, role, why, cert, loc, n in D if cert != 'alongside' and cert != 'stubs it' + and not why.startswith('stubs a method of this name')} | {c for c, _ in contract} direct_ids = {c for c in direct_ids if c in g.sym} - ent = [(m, d) for m, d in {**{c: 0 for c in direct_ids}, **reached}.items() if not callers_of.get(m) or g.sym[m]['is_test']] + # a type alias holds the hop to its users (#784) but is nothing a framework or a runner invokes + import graph_sql + aliases = graph_sql.type_aliases(g.q)[1] + ent = [(m, d) for m, d in {**{c: 0 for c in direct_ids}, **reached}.items() + if (not callers_of.get(m) or g.sym[m]['is_test']) and m not in aliases] inside = list({*reached, *seeds, *direct_ids}) # the direct rows are not in seed for a method target u = g.q(f"SELECT count(*) n FROM unresolved_sites WHERE caller_id IN ({','.join('?' * len(inside))})", *inside)[0]['n'] if inside else 0 # the check that means something: every printed chain hop, and every resolved direct entry, is looked up again in graph.sqlite chains = {m: I.chain(m, parent) for m in tests} + # A TEST THAT HOLDS A MOCK OF A TYPE ON ITS ROUTE never runs that type's methods (ax_edges.mocked_types): a web test + # with `@MockBean OrderService` reaches the controller over HTTP and stops at the mock, so a route through + # OrderService is not a route, whatever its rung. Read off the route the answer prints (the nearest one); the test + # moves to the stub listing, named for the type it mocks. + mock_via = {} + if tests: + def _line_of(f, l): + try: return I.lines(g.site_file(f))[l - 1] + except Exception: return '' + mocks = ax_edges.mocked_types(lambda s_, p_: g.q(s_, *p_), _line_of) + if mocks: + tsimple = {r['type_id']: (r['display'] or '').split('.')[-1] for r in g.q("SELECT type_id, display FROM symbols WHERE type_id IS NOT NULL AND method_id IS NULL")} + anc = collections.defaultdict(set) + for r in g.q("SELECT type_id, ancestor_type_id FROM type_ancestors") if g.has('type_ancestors') else []: + if r[0] in tsimple and r[1] in tsimple: anc[tsimple[r[0]]].add(tsimple[r[1]]) + up_names = lambda n: {n} | anc.get(n, set()) + for m in list(tests): + own = (g.sym[m]['owner'] or '').split('.')[-1] + held = set().union(*(mocks.get(x, set()) for x in up_names(own))) + if not held: continue + via = tests[m][1] + ch = (chains.get(m) or []) if not via else [m] + I.chain(via, parent) + # the hop's OWN type, or a supertype of it only where the hop is a dispatch choice (the caller's call was + # resolved to the mocked interface, and the implementation is one of its candidates). A real CachingStore + # wrapping a mocked IStore implements that interface too, and a test that calls the real one + # directly reaches it through a resolved call, which is not through the mock. + for a_, n in zip(ch, ch[1:]): + ow = ((g.sym.get(n) or {}).get('owner') or '').split('.')[-1] + if not ow: continue + ts = {t for x, t in parent.get(a_, ()) if x == n} + hit = ({ow} | (anc.get(ow, set()) if ts & {'dispatch', 'multi_inferred'} else set())) & held + if hit: + mock_via[m] = sorted(hit)[0]; break + for m in mock_via: + tests.pop(m, None); chains.pop(m, None) + stubbed = sorted(set(stubbed) | set(mock_via), key=lambda m: (g.sym[m]['file'] or '', g.sym[m]['display'] or '', m)) # how sure is each test's route to the change? the worst hop on its chain — what makes the answer wide is visible here: # a call resolved to a SET of targets, or an override reached through its base, is the engine's over-approximation # a tier no rung is written for, collected as the routes are labelled so it can be SAID rather than @@ -1743,7 +2031,10 @@ def main(argv): hops += 1 # a `by key` hop is not in call_edges BY CONSTRUCTION — it is the hop the framework makes and the engine # never resolved — so it is verified the same way `dispatch` and `defines` are: the rules asserted it - if (a, b) not in real and not any(x == b and t in ('dispatch', 'defines', 'by key', 'decorator') for x, t in parent.get(a, [])): bad += 1 + # `decorator by name` is the rules' own hop from a decorated def to the decorator it names + # (dl/impact.dl fw_edge), asserted exactly as `decorator` is; leaving it out printed "✗ not found" on + # every answer whose test route passes through a project decorator + if (a, b) not in real and not any(x == b and t in ('dispatch', 'defines', 'by key', 'decorator', 'decorator by name') for x, t in parent.get(a, [])): bad += 1 asserted = collections.defaultdict(set) # the callee each direct row claims an edge to for c, callee in {(x[0], x[1]) for x in res['direct_edge']}: asserted[c].add(callee) for c, role, why, cert, loc, n in D: @@ -1754,18 +2045,39 @@ def main(argv): or cert in ('registered', 'capped set'): hops += 1 if not any((c, t) in real for t in asserted.get(c, ())): bad += 1 + prose_hits = getattr(getattr(I, '_ns', None), 'prose', set()) + # the closure's own framework-entered entry points (not the target: `unmod` speaks for it), with the route each + # serves when a decoration registers one, and one search for the tests that drive them + fw_ent, fw_grep = [], '' + _ent_ids = [m for m, _d in sorted(ent, key=lambda x: (x[1], g.disp(x[0]))) if m not in set(seeds) and not g.sym[m]['is_test']] if not tests else [] + if _ent_ids: + _routes = collections.defaultdict(set) + for rd, _f, _l, kind, key, _w in ax_registration.decoration_keys(g.q, g.site_file): + if kind == 'route' and rd in _ent_ids: _routes[rd].add(key) + for m in _ent_ids: + own = [sig for _i, sig in P.unmodelled_entry(g, [m]) if ' on its type ' not in sig and 'does not contain' not in sig] + if own or _routes.get(m): + fw_ent.append((m, (own[0].split(' on ')[0] if own else '') + (f' "{sorted(_routes[m])[0]}"' if _routes.get(m) else ''))) + pre = sorted({re.split(r'[{<:]', k)[0] for m, _ in fw_ent for k in _routes.get(m, ()) if len(re.split(r'[{<:]', k)[0]) > 1})[:4] + names = sorted({g.sym[m]['name'] for m, _ in fw_ent if g.sym[m].get('name')})[:4] + fw_grep = ("grep -rnF " + ' '.join(f"-e '\"{p_}'" for p_ in pre) + f" {P.grep_roots(g)}") if pre else \ + ("grep -rnw " + ' '.join(f'-e "{n}"' for n in names) + f" {P.grep_roots(g)}" if names else '') if as_json: print(json.dumps({'targets': [{'kind': k, 'label': lab} for k, lab, _ in targets], - 'contract': [{'id': c, 'display': g.disp(c), 'why': why, 'at': g.loc(c), 'for': sorted(contract_of[(c, why)])} for c, why in contract], + 'contract': [{'id': c, 'display': g.disp(c), 'why': why, 'also': contract_also.get(c, []), 'at': g.loc(c), 'for': sorted(contract_for[c])} for c, why in contract], # the sort key ends in x[0], the id: two distinct anon-class methods can share a # display AND every other component of the key, and without the id the order is # whatever the solver happened to emit — which is not the same between backends. - 'direct': [{'id': c, 'display': g.disp(c), 'role': role, 'why': why, 'certainty': cert, 'at': loc, 'sites': n, 'for': sorted(from_target[(c, why)])} for c, role, why, cert, loc, n in sorted(D, key=lambda x: (CERT[x[3]], x[1], g.disp(x[0]), x[2], x[4], x[0]))], + 'direct': [{'id': c, 'display': g.disp(c), 'role': role, 'why': why, 'also': direct_also.get((c, _grp(role)), []), 'reasons': direct_reasons.get((c, _grp(role)), []), 'certainty': cert, 'at': loc, 'sites': n, 'for': sorted(direct_for[(c, _grp(role))])} for c, role, why, cert, loc, n in sorted(D, key=lambda x: (CERT[x[3]], x[1], g.disp(x[0]), x[2], x[4], x[0]))], 'reached': [{'id': m, 'display': g.disp(m), 'hops': d, 'for': sorted(reach_from[m]), 'at': g.loc(m), 'test': bool(g.sym[m]['is_test'])} for m, d in sorted(reached.items(), key=lambda x: (x[1], g.disp(x[0]), g.loc(x[0]), x[0]))], 'tests': [{'id': m, 'display': g.disp(m), 'owner': g.sym[m]['owner'], 'name': g.sym[m]['name'], 'hops': d, 'via': g.disp(fx) if fx else None, 'at': g.loc(m), 'certainty': test_cert.get(m), 'chain': [g.disp(x) for x in chains.get(m, [])]} for m, (d, fx) in sorted(tests.items(), key=lambda kv: (kv[1][0], g.disp(kv[0]), g.loc(kv[0]), kv[0]))], + 'framework_entries': [{'id': m, 'display': g.disp(m), 'signal': sig, 'at': g.loc(m)} for m, sig in fw_ent], + 'framework_grep': fw_grep, + 'stub_tests': [{'id': m, 'display': g.disp(m), 'owner': g.sym[m]['owner'], 'name': g.sym[m]['name'], 'at': g.loc(m), 'certainty': 'stubs it', + **({'mocks': mock_via[m]} if m in mock_via else {})} for m in stubbed], 'inherited_tests': [{'id': s_, 'display': g.disp(s_), 'at': g.loc(s_), 'inherits': sorted(g.disp(m) for m in ms)} for s_, ms in sorted(inh.items(), key=lambda kv: (g.disp(kv[0]), g.loc(kv[0])))], - 'byname_callers': [{'id': c, 'display': g.disp(c), 'at': g.loc(c)} for c in sorted(byname_seeds, key=lambda x: (g.disp(x), x))], - 'external': [{'at': f'{f}:{l}', 'name': n, 'how': how} for f, l, n, how in sorted({(r[0], int(r[1]), r[2], r[3]) for r in res.get('extbind', [])})], + 'byname_callers': [{'id': c, 'display': g.disp(c), 'at': g.loc(c)} for c in sorted(byname_seeds, key=by_place)], + 'external': [{'at': f'{f}:{l}', 'name': n, 'how': how, **({'prose': True} if (n, f, l) in prose_hits else {})} for f, l, n, how in sorted({(r[0], int(r[1]), r[2], r[3]) for r in res.get('extbind', [])})], 'test_universe': universe, 'verified': bad == 0, 'checked_hops': hops, 'unresolved_inside': u}, indent=1)) return 0 for k, lab, _ in targets: print(f"change: {lab} [{k}]") @@ -1782,10 +2094,10 @@ def main(argv): break if contract: nm = sum(1 for _, why in contract if 'name match' in why) - print(f"must change with it ({len(contract)}: " + (f"{len(contract) - nm} bound by a contract the engine resolved, {nm} matched by name" if nm else "bound by a contract the engine resolved") + "):") + print(f"must change with it ({len(contract)}: " + ("matched by name" if nm == len(contract) else f"{len(contract) - nm} bound by a contract the engine resolved, {nm} matched by name" if nm else "bound by a contract the engine resolved") + "):") # printed in a fixed order — by display, then reason. The rows are a set; leaving them in the order the # engine emitted them makes the same answer come out shuffled from one solver to another. - for c, why in contract[:limit]: print(f" {g.disp(c)} {g.loc(c)} — {why}" + (f" [for {', '.join(sorted(contract_of[(c, why)]))}]" if multi else '')) + for c, why in contract[:limit]: print(f" {g.disp(c)} {g.loc(c)} — {why}" + also_text(contract_also.get(c)) + (f" [for {', '.join(sorted(contract_for[c]))}]" if multi else '')) if len(contract) > limit: print(f" … +{len(contract) - limit} (--limit N)") # the window a reader actually sees must not be decided by ASCII: ``, ``, `` sort before # every letter, and in a callback-heavy language they are most of the list, so the file that breaks is pushed into @@ -1813,13 +2125,34 @@ def main(argv): ep_by_reason = collections.defaultdict(list) for m, why in ep_rows: if not _is_t(m): ep_by_reason[why].append(m) + # WORDED FROM THE REASON (ax_edges.ENTRY). Only a surface the outside world calls (a route, a process start, a remote + # client, a message) is one whose change "changes what the outside world can call"; a hook, a lifecycle method or a + # constructor the container runs is called back by the framework, and a change to it breaks that contract instead. + # And where a row above SENDS the request or event this handles, the handler is not unreached: the framework runs + # it for what that row sends, so "no client call site reaches it" would contradict the row. + _ev_why = ax_edges.TIER_WHY.get('event_dispatch') + _senders = {c for c, role, why, cert, loc, n in D if why == _ev_why} + sent_to = lambda m: bool(_senders) and (len(seeds) == 1 or any(m in asserted.get(c, ()) for c in _senders)) for why in sorted(ep_by_reason): ms = sorted(ep_by_reason[why], key=g.disp) - if len(ms) == 1: - print(f"entry point: {g.disp(ms[0])} is a {why} entry point — a framework invokes it, so no client call site reaches it. Changing it changes what the outside world can call.") + tail = f" … +{len(ms) - limit} (--limit N)" if len(ms) > limit else '' + if any(sent_to(m) for m in ms): + one = len(ms) == 1 + print(f"entry point: {g.disp(ms[0]) + ' is' if one else f'{len(ms)} entry points, each'} {ax_edges.entry_phrase(why)} — a framework runs " + f"{'it' if one else 'them'} for the request or event the sender row(s) above send, so no call site names {'it' if one else 'them'}" + + ('' if one else ': ' + ', '.join(g.disp(m) for m in ms[:limit]) + tail)) + elif ax_edges.entry_outside(why): + if len(ms) == 1: + print(f"entry point: {g.disp(ms[0])} is a {why} entry point — a framework invokes it, so no client call site reaches it. Changing it changes what the outside world can call.") + else: + print(f"entry point: {len(ms)} {why} entry points — a framework invokes them, so no client call site reaches them. Changing one changes what the outside world can call: " + + ', '.join(g.disp(m) for m in ms[:limit]) + tail) + elif len(ms) == 1: + print(f"entry point: {g.disp(ms[0])} is {ax_edges.entry_phrase(why)} — the framework calls it back, so no client call site reaches it; " + "a change breaks that framework contract, not an outside caller.") else: - print(f"entry point: {len(ms)} {why} entry points — a framework invokes them, so no client call site reaches them. Changing one changes what the outside world can call: " - + ', '.join(g.disp(m) for m in ms[:limit]) + (f" … +{len(ms) - limit} (--limit N)" if len(ms) > limit else '')) + print(f"entry point: {len(ms)} entry points, each {ax_edges.entry_phrase(why)} — the framework calls them back, so no client call site reaches them; " + "a change to one breaks that framework contract, not an outside caller: " + ', '.join(g.disp(m) for m in ms[:limit]) + tail) n_ep_test = len({m for m, _ in ep_rows if _is_t(m)}) if n_ep_test: print(f"entry point: {n_ep_test} test entry point(s) — a runner invokes them, so no client call site reaches them either; they are not a surface the outside world calls. The tests section below is what names them.") @@ -1931,7 +2264,9 @@ def main(argv): f = (r[4] or '').rsplit(':', 1)[0] if len(shown) < limit and per[f] < cap: per[f] += 1; shown.append(r) else: hidden.append(r) - for c, role, why, cert, loc, n in shown: print(f" [{cert}] {g.disp(c)} {loc} — {why}" + (f" ({n} sites)" if n > 1 else '') + (f" [for {', '.join(sorted(from_target[(c, why)]))}]" if multi else '')) + for c, role, why, cert, loc, n in shown: + why, oth = merge_reasons(why, direct_also.get((c, _grp(role))) or []) + print(f" [{cert}] {g.disp(c)} {loc} — {why}" + (f" ({n} sites)" if n > 1 else '') + also_text(oth) + (f" [for {', '.join(sorted(direct_for[(c, _grp(role))]))}]" if multi else '')) if hidden: byf = collections.Counter((r[4] or '').rsplit(':', 1)[0] for r in hidden) print(f" … +{len(hidden)} more in {len(byf)} file(s): " + ', '.join(f"{f} ({n})" for f, n in byf.most_common(6)) + (' …' if len(byf) > 6 else '') + " (--limit N for all of them)") @@ -1940,6 +2275,14 @@ def main(argv): srch = (f'grep -rn "\\.{nm[0]}(" {P.grep_roots(g)}' if len(nm) == 1 else P.grep_for(g, nm[0]) if nm else '') print(f" +{len({c for c, *_ in collapsed})} [by name] (the same method name on an untyped receiver; may be another method)" + (f" — {srch} lists them" if srch else '') + "; --page all or --limit N prints each row") + # a test callable that only STUBS it on a mock names it and runs none of it: said apart, never as "reach it" + tstub = [r for r in tst if r[3] == 'stubs it' or r[2].startswith('stubs a method of this name')] + tst = [r for r in tst if r not in tstub] + if tstub and not want_tests: + sbyf = collections.Counter((r[4] or '').rsplit(':', 1)[0] for r in tstub if r[4]) + print(f" +{len({c for c, *_ in tstub})} test callable(s) in {len(sbyf)} file(s): " + ', '.join(f"{f} ({n})" for f, n in sbyf.most_common(4)) + + (' …' if len(sbyf) > 4 else '') + " only stub it on a mock (when/verify, Setup/Verify, Received/Returns): they name it and" + " run none of it, so they break on a rename or a new parameter and never on a body change") if tst and not want_tests: # the count names its files: a pointer that says only "+31" leaves the reader not knowing what was cut or # where, which is what the crowding rule exists to prevent (tests/cases/typescript/anonymous-crowding) @@ -1950,6 +2293,12 @@ def main(argv): if any(k == 'var' for k, _, _ in targets) and not reached: print("reaches those through resolved calls: nothing — a local lives inside its method; it escapes only through what the method returns or writes, and the callers of the method are its impact only if it does") ext = sorted({(r[0], int(r[1]), r[2], r[3]) for r in res.get('extbind', [])}, key=lambda x: (x[0], x[1])) + # A COMMON WORD WRITTEN AS A WORD IS NOT A BINDING (ax_nonsource.py, PROSE): `note` in a template's text, `build` + # in a CI file, a Maven `validate`. Those rows leave `ext` here, so nothing below -- the section, + # its header count, `next:`, the --delete verdict -- reads them as a place a rename breaks; they are counted with + # the search that lists them, printed each under --page all / --limit N, and kept in --json with "prose": true. + ext_prose = [x for x in ext if (x[2], x[0], x[1]) in prose_hits] + ext = [x for x in ext if (x[2], x[0], x[1]) not in prose_hits] print(f"reaches those through resolved calls: {len(reached)} more callable(s) in {nfiles} file(s)" + (f" within {depth} hop(s)" if depth < 40 else '') + (f" under *{IN}*" if IN else '')) weak = [m for m in reached if m not in sure_set] if weak: print(f" {len(weak)} of those {len(reached)} are reached ONLY through a dependent that is not an exact edge ([by name], [text], [one of a set]): they stand or fall with it") @@ -1987,13 +2336,25 @@ def main(argv): for m, d in ent[:limit]: decs = [r[0].split('.')[-1] for r in g.q("SELECT name FROM decorations WHERE owner_id = ?", m)] if g.has('decorations') else [] print(f" {d:2} hop(s) {g.disp(m)}{' [test]' if g.sym[m]['is_test'] else ''}{(' @' + ','.join(decs[:3])) if decs else ''} {g.loc(m)}") + # the entry points' own overflow line, under their list: it sat inside the next section and printed only when + # that section did + if len(ent) > limit: print(f" … +{len(ent) - limit} (--limit N)") if ext: byfile = collections.Counter(f for f, *_ in ext) print(f"bound from outside the source ({len(ext)} site(s) in {len(byfile)} file(s): a name matched in a file the index does" " not read as source, so the compiler is silent — nothing here fails a build; a rename or a retype breaks it at RUN time):") for f, l, n, how in ext[:limit]: print(f" [text] {f}:{l} — {how} ({n})") if len(ext) > limit: print(f" … +{len(ext) - limit} (--limit N)") - if len(ent) > limit: print(f" … +{len(ent) - limit} (--limit N)") + if ext_prose: + byname_p = collections.Counter(n for _f, _l, n, _h in ext_prose) + nfp = len({f for f, *_ in ext_prose}) + print(f"{'also ' if ext else ''}written as a plain word outside the source: {len(ext_prose)} line(s) in {nfp} file(s) name " + + ', '.join(f'"{n}"' for n in sorted(byname_p)) + " with no call, quote or qualifier around it (template text, CI and" + " build files, comments) — not counted as a binding; " + ' · '.join(f'grep -rnw "{n}" .' for n in sorted(byname_p)[:2]) + + (" lists them" if not list_all else '')) + if list_all: + for f, l, n, how in ext_prose[:limit]: print(f" [text, prose] {f}:{l} — {how} ({n})") + if len(ext_prose) > limit: print(f" … +{len(ext_prose) - limit} (--limit N)") if inh: print(f" inherited by {len(inh)} test class(es) that extend a reached one — they run those tests too: " + ', '.join(f"{g.disp(s_)} ({len(ms)}) {g.loc(s_)}" for s_, ms in sorted(inh.items(), key=lambda x: (-len(x[1]), g.disp(x[0])))[:6])) tfiles = {g.sym[m]['file'] for m in tests} | {g.sym[s_]['file'] for s_ in inh if s_ in g.sym} all_tfiles = {r[0] for r in g.q("SELECT DISTINCT file FROM symbols WHERE is_test = 1 AND file IS NOT NULL")} @@ -2001,10 +2362,41 @@ def main(argv): weak_tests = [m for m in tests if m not in sure_set] sys.stdout = _stdout print(f"tests: {len(tests)} of {universe} test method(s) reach the change" + (" (their own body, or a fixture that runs before them: constructor, static initializer, @Before*/setUp)" if tests else '')) + # THE TESTS LINE AGREES WITH THE ROWS ABOVE IT. A callable in test code that this answer LISTS as reaching the change + # (a [test] entry point, a test row) but that the count does not credit -- a runner entry this tool does not + # collect (a custom test attribute, a script's main), a remote hop the test walk does not take -- made "tests: 0" + # sit under a [test] row. They are named here, and `next:` counts them as tests to run. + # A test that only stubs it on a mock, or reaches it only through a type it holds as one, is not one of them: it is + # the [stubs it] line below, which says it is not counted because a body change cannot fail it. + test_rows = sorted(({m for m, _ in ent if _is_t(m)} | {c for c, role, why, cert, loc, n in D if _is_t(c) and cert not in ('alongside', 'stubs it') + and not why.startswith('stubs a method of this name')}) - set(tests) - set(stubbed), + key=lambda m: (g.disp(m), g.loc(m))) + if test_rows: + print(f" {'and ' if tests else 'but '}{len(test_rows)} callable(s) in test code listed above reach it by a route this count does not credit " + "(a runner entry it does not collect, a remote or framework hop) — treat them as tests to run: " + + ', '.join(f"{g.disp(m)} {g.loc(m)}" for m in test_rows[:4]) + (f" … +{len(test_rows) - 4}" if len(test_rows) > 4 else '')) if not tests and unmod: print(f" NOT CHECKED: {unmod_sig} — a framework calls it and the graph does not model how, so a test that enters " f"through that framework (a request, a started context, an event) reaches it with no route counted here: {unmod_grep}") + # …and the same for what REACHES it: an entry point of this closure that a framework enters (a route handler, a + # listener, a message handler) is driven by tests through the framework — a request, an event, a mediator send — + # and a route is counted only where a key or an edge joins the two. Said, with the search that finds those tests, + # rather than letting the count stand as if it were the whole set. + # only where the count is 0: beside counted tests it is one more line on every answer that crosses a web layer, and + # the claim it corrects ("no test reaches it") is not being made + if fw_ent and not tests: + shown = ', '.join(f"{g.disp(m)} {sig}" for m, sig in fw_ent[:3]) + (f" … +{len(fw_ent) - 3}" if len(fw_ent) > 3 else '') + print(f" NOT COUNTED: {len(fw_ent)} entry point(s) of this change are entered by a framework ({shown}) — a test that drives" + f" them through it (a request, an event, a mediator send) reaches the change with no route here unless a [by key] row" + f" says so, so the count is a lower bound; to find those tests: {fw_grep}") if weak_tests: print(f" {len(weak_tests)} of them only through a dependent that is not an exact edge") + if stubbed: + sbf = collections.Counter(g.sym[m]['file'] for m in stubbed) + mk = sorted({mock_via[m] for m in stubbed if m in mock_via}) + print(f" [stubs it] {len(stubbed)} more test(s) only stub it on a mock (when/verify, Setup/Verify, Received/Returns)" + + (f" or reach it only through a type they hold as a mock ({', '.join(mk[:3])}{' …' if len(mk) > 3 else ''})" if mk else '') + + " and never run it: not counted above, since a body change cannot fail them; a rename or a new parameter does — " + + ', '.join(f"{f} ({n})" for f, n in sbf.most_common(4)) + (f" … +{len(sbf) - 4}" if len(sbf) > 4 else '')) if tests: byc = collections.Counter(test_cert.values()) print(" how sure each route is: " + ', '.join(f"{n} {c}" for c, n in sorted(byc.items(), key=lambda x: CERT_RANK[x[0]])) + f" — {len(sure)} reach it through resolved single-target calls within 3 hops") @@ -2056,6 +2448,15 @@ def main(argv): else: print(f" {f} ({len(ms)}){via}: " + ', '.join(tname(m) for m in shown) + (f" … +{len(ms) - limit} more (--limit N)" if len(ms) > limit else '')) if not want_why: print(" --why adds each test's route (with --tests-in to keep it short)") + if want_tests and stubbed: + sfiles = collections.defaultdict(list) + for m in stubbed: + if not tests_in or tests_in in (g.sym[m]['file'] or ''): sfiles[g.sym[m]['file']].append(m) + if sfiles: + print(f" [stubs it] {sum(len(ms) for ms in sfiles.values())} test(s) in {len(sfiles)} file(s), not counted: each stubs it on a mock and never runs it" + " (they break on a rename or a new parameter, never on a body change):") + for f, ms in sorted(sfiles.items(), key=lambda kv: (-len(kv[1]), kv[0])): + print(f" {f} ({len(ms)}): " + ', '.join(tname(m) for m in ms[:limit]) + (f" … +{len(ms) - limit} more (--limit N)" if len(ms) > limit else '')) # --depends: the downstream direction (#761). `impact` walks callers only, so what the target itself calls is # never reported, and that is what an agent editing it has to change alongside. Same edges, read the other way; # the same certainty vocabulary, printed as its own section so the two questions are never confused. @@ -2210,7 +2611,9 @@ def main(argv): # AN ENTRY POINT IS CALLED BY A FRAMEWORK, AND SO IS AN UNMODELLED ONE (#1446). The entry-point line above said # a framework invokes it, and this verdict then printed the one for a class nothing registers, word for word. fw_why = []; epw = sorted(ep_by_reason) - if epw: fw_why.append(f"it is {'an' if epw[0][:1] in 'aeiou' else 'a'} {'/'.join(epw[:3])} entry point (above) — a framework calls it, so no call site in the graph is expected") + if epw and not any(ax_edges.entry_outside(w) for w in epw): + fw_why.append(f"it is {' / '.join(ax_edges.entry_phrase(w) for w in epw[:3])} (above) — the framework calls it back, so no call site in the graph is expected") + elif epw: fw_why.append(f"it is {'an' if epw[0][:1] in 'aeiou' else 'a'} {'/'.join(epw[:3])} entry point (above) — a framework calls it, so no call site in the graph is expected") if unmod: fw_why.append(f"NOT CHECKED: {unmod_sig} — a framework may call it and the graph does not model how; before deleting run: {unmod_grep}") if why: print(" NOT SAFE — " + '; '.join(why) + ". They are listed above.") elif fw_why: print(" NOT SAFE TO ASSUME — no dependent in this graph, but " + '; '.join(fw_why) + ". Check what else the graph cannot see:") @@ -2229,6 +2632,7 @@ def main(argv): if decs: bits.append("decorations on it: @" + ', @'.join(decs[:5]) + " — a framework may call it with no call site, and a decorator can hand the wrapper to the caller instead of this declaration") if u: bits.append(f"{u} unresolved call(s) inside the impacted set") if tests: bits.append(f"{len(tests)} test(s) reach it") + if stubbed: bits.append(f"{len(stubbed)} test(s) stub it on a mock or hold its type as one ([stubs it] above): deleting it breaks the stubs they write, though no body change fails them") for b in bits: print(f" · {b}") if not bits and not calls and not contract and not weak and not ov and not ext and not fw_why: print(" · nothing — no by-name match, no literal, no decoration, no unresolved call inside") if not vis and g.has('methods'): print(" · visibility is not recorded in this graph, so exported surface cannot be told from internal") @@ -2275,16 +2679,26 @@ def main(argv): print("bound: the closure could not start — no dependent above is an edge it can walk back from, so what " "reaches THEM is not in this answer") if u: print(f"bound: {u} unresolved call(s) inside the impacted set — what those reach is unknown, so the set is a lower bound") + # A STRUCTURAL TYPE IS SATISFIED WITHOUT BEING NAMED (#784). `return { kind: 'LiteralNode', fragments }` checks + # against the interface and never writes it, so an added required property or a narrowed one breaks sites no row + # above can name. Assignability is the compiler's job; the answer says so rather than looking complete. + shapes = sorted({g.sym[t]['name'] for k, _, pay in targets if k == 'type' for t in (pay if isinstance(pay, list) else []) + if isinstance(t, str) and t in g.sym and g.sym[t].get('kind') in ('interface', 'type') + and (g.sym[t].get('file') or '').endswith(('.ts', '.tsx', '.mts', '.cts'))}) + if shapes: + print(f"bound: an object literal, a spread or a JSX element that builds a {' / '.join(shapes[:3])} without " + "writing the name is checked against it structurally and is not in this answer — after adding a required " + "property or narrowing one, `tsc --noEmit` lists those sites") if dunder and not reached: print(f"outside the graph: {', '.join(dunder)} is a protocol method — the language runtime calls it (an operator, a conversion, a lifecycle hook), which is no call site, so nothing reaching it here does NOT mean nothing does") if outside and lone: print("outside the graph: " + ', '.join(f"{o} extends / implements {a}, which the graph does not contain" for o, a in outside[:3]) + " — a library or framework can call this method, so " + ("what reaches it above is not all that does" if reached else "nothing reaching it here does NOT mean nothing does")) - if byname_seeds: print(f"through a call the engine could not resolve: {len(byname_seeds)} caller(s) write this method's name on an untyped receiver — counted in the reach above, so that part of it MAY not be real: " + ', '.join(f"{g.disp(c)} {g.loc(c)}" for c in sorted(byname_seeds)[:4])) + if byname_seeds: print(f"through a call the engine could not resolve: {len(byname_seeds)} caller(s) write this method's name on an untyped receiver — counted in the reach above, so that part of it MAY not be real: " + ', '.join(f"{g.disp(c)} {g.loc(c)}" for c in sorted(byname_seeds, key=by_place)[:4])) byname = [x for x in D if x[3] in ('one of a set', 'by name', 'text')] # a label the answer USED must be explained here; a label it did not use must not be, or the note # describes evidence the reader cannot find above it. `registered` is new and is the one most # likely to be misread, because on JavaScript it is where a third of the routes go. - REGISTERED_NOTE = ("[registered] is a callable handed over as a value — the engine recorded the hand-off, " - "not a call to it, and cannot say the holder ever runs it") + REGISTERED_NOTE = ("[registered] is a callable handed over as a value, or a handler a framework runs for a request or event " + "sent to it — the engine recorded the hand-off or the send, not a call to it, and cannot say it ever runs") used_registered = 'registered' in set(test_cert.values()) if byname: legend = "[one of a set] is one member of a target set the engine could not narrow (its own oracle: right 126 of 351 times, against 528 of 585 for a resolved edge), [by name] / [text] may be a same-named other thing" @@ -2343,7 +2757,7 @@ def main(argv): ep_why = sorted(ep_by_reason) weak_locs = list(dict.fromkeys(loc for c, role, why, cert, loc, n in sorted(D, key=lambda x: (CERT.get(x[3], 99), x[4] or '')) if cert not in STRONG_DIRECT and cert != 'alongside' and loc and loc not in here)) - nt = len(tests) + nt = len(tests) + len(test_rows) tpart = (f"; {nt} test(s) reach it — run them after the edit (`test-impact`)" if nt else '') if places: places = list(dict.fromkeys(places + [x for x in ext_locs if x not in here])) if not places and ext_locs: @@ -2354,6 +2768,11 @@ def main(argv): print(f"next: {nobody}, but it is not local — {', '.join(foreign[:3])} calls it from outside the graph: " f"read {', '.join(here) or 'the declaration'}; a body change is safe there, a change to its name or signature " f"stops that caller reaching it{lead}") + elif not places and ep_why and not any(ax_edges.entry_outside(w) for w in ep_why): + # a framework callback (ax_edges.ENTRY): no route and no outside caller to break, only the framework's contract + print(f"next: nothing in this graph calls it, but it is not local — it is {' / '.join(ax_edges.entry_phrase(w) for w in ep_why[:3])}, so the " + f"framework calls it back: read {', '.join(here) or 'the declaration'}; a body change is safe there, a change to its name, parameters " + f"or result breaks that framework contract{tpart}{lead}") elif not places and ep_why: print(f"next: nothing in this graph calls it, but it is not local — it is {'an' if ep_why[0][:1] in 'aeiou' else 'a'} {'/'.join(ep_why[:3])} entry point, so its " f"callers are outside the graph (the framework, a request, a client, a runner, a scheduler): read {', '.join(here) or 'the declaration'}; a body change " diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 5e7b034ca..55a16d40a 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -148,13 +148,16 @@ A = { # still the decoration, and a wrong `args` mapping would be silently empty in the same way decorations=dict(file='all-csharp-attributes.csv', name='attributeName', owner='ownerHash', line='startLine', fileVia=('modules', 'csModuleLinkHash'), id='csAttributeUniqueHash'), - # fields carry no ownerQualifiedName (potentialQualifiedName is the field's TYPE), so the owner comes - # from the file the way javascript's does; a property is a member that reads as a field at its sites + # fields carry no ownerQualifiedName (potentialQualifiedName is the field's TYPE), but every member row links its + # declaring type by csTypeLinkHash: that type is the owner, so `Type.Field`, `Type.Prop` and `Type.CONST` name it + # and qualified_name is `.`, as Java's is (#1445). Without it the owner was empty and + # qualified_name `file#name`, so impact on Owner.Member fell back to a type-name match and called the change local. + # A property is a member that reads as a field at its sites decls=[dict(file='all-csharp-fields.csv', id='csFieldUniqueHash', kind=lambda r: 'const' if r.get('isConst') == 'true' else 'field', - name='name', owner=None, fileVia=('modules', 'csModuleLinkHash'), line='startLine', end='endLine'), + name='name', ownerVia='csTypeLinkHash', fileVia=('modules', 'csModuleLinkHash'), line='startLine', end='endLine'), dict(file='all-csharp-properties.csv', id='csPropertyUniqueHash', kind=lambda r: 'field', - name='name', owner=None, fileVia=('types', 'csTypeLinkHash'), line='startLine', end='endLine'), - dict(file='all-csharp-enum-members.csv', kind=lambda r: 'enum_member', name='name', owner=None, fileVia=('modules', 'csModuleLinkHash'), line='startLine', end='endLine')], + name='name', ownerVia='csTypeLinkHash', fileVia=('types', 'csTypeLinkHash'), line='startLine', end='endLine'), + dict(file='all-csharp-enum-members.csv', kind=lambda r: 'enum_member', name='name', ownerVia='csTypeLinkHash', fileVia=('modules', 'csModuleLinkHash'), line='startLine', end='endLine')], boundNames=None, skipped='skipped-csharp-files.csv'), }[LANG] @@ -373,9 +376,15 @@ for d in A['decls']: name = r.get(d['name'], ''); if not name: continue fp = file_of(r, d); od = owner_display(r.get(d['owner'], '')) if d.get('owner') else None + # the owner as a link to the declaring type's row (C#): its display and qualified name come from that row, so a + # nested type's member is `Outer.Inner.Member` and two types' same-named members stay two symbols + ot = types.get(r.get(d['ownerVia'], '')) if d.get('ownerVia') else None + if ot: od = tdisplay(ot['id']) or None ln = int(r.get(d['line']) or 0); en = int(r.get(d['end']) or ln) if (fp, ln, name) in bound_at: continue # `const foo = () => …` is already the function foo - sym.append((r.get(d['id']) if d.get('id') else None, name, (od + '.' if od else '') + name, d['kind'](r), (r.get('qualifiedName') or ((r.get(d['owner'], '') + '.') if d.get('owner') else (fp + '#')) + name), None, fp, ln, en, od, 1 if fp and TESTRE.search(fp) else 0, None, None)) + 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)) c.executemany("INSERT INTO symbols VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", sym) # ── references, literals, comments ─────────────────────────────────────────────────────────────── diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index fa67aad7a..585a7fa03 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -65,6 +65,9 @@ def chain_json(g, chain): # declarations that describe a callable and have no body (score.py's bodiless_kinds) BODILESS_KINDS = {'METHOD_SIGNATURE', 'TYPE_LITERAL_METHOD_SIGNATURE', 'CALL_SIGNATURE', 'TYPE_LITERAL_CALL_SIGNATURE', 'FUNCTION_TYPE_SIGNATURE', 'CONSTRUCT_SIGNATURE', 'TYPE_LITERAL_CONSTRUCT_SIGNATURE', 'CONSTRUCTOR_TYPE_SIGNATURE'} +# the one name a front end gives every lambda it declares (Python and C#; Java declares none): a name that says nothing +# about WHICH lambda, so it is never a target on its own (G.lambda_label, G.lambda_target) +LAMBDA_NAMES = {''} # ONE SPELLING FOR EVERY LANGUAGE. Each front end writes a qualified name its own way: Java and C# `pkg.Owner.m`, # Python `pkg.module.f`, TypeScript `src/util#square`, JavaScript `src/util.square`. canon() maps all of them onto the @@ -198,6 +201,61 @@ class G: def disp(self, i): r = self.sym.get(i) or self.all_sym.get(i); return r['display'] if r else i + # ── a lambda is named by where it is written ───────────────────────────────────────────────────────────── + # Every lambda of a language is declared under ONE name (``), so the name alone matches every lambda in the + # graph: an edit next to one was handed to impact as ``, which answered for hundreds of declarations, and + # the lambdas of one class were one symbol `Owner.`. A lambda is identified by the member that encloses it + # and its line, `Owner.method.`, and targeted by `file:line`, which names that one declaration. + def is_lambda(self, i): + r = self.all_sym.get(i) or self.sym.get(i) + return bool(r) and r.get('name') in LAMBDA_NAMES and r.get('method_id') is not None + def enclosing(self, i): + """the narrowest declaration other than a lambda or a module body whose span holds lambda `i`: a method, a + field whose initializer it is, or a type. None at the top level of a file.""" + r = self.all_sym.get(i) or self.sym.get(i) + if not r or not r.get('file') or not r.get('line'): return None + a, b = r['line'], r.get('end_line') or r['line'] + rows = self.q("""SELECT id, rowid, display, kind, name, line, end_line FROM symbols WHERE file = ? AND line <= ? AND COALESCE(end_line, line) >= ? + AND id IS NOT ? AND kind NOT IN ('module', 'library', 'written') AND name NOT IN (%s) + ORDER BY COALESCE(end_line, line) - line, CASE WHEN method_id IS NOT NULL THEN 0 WHEN type_id IS NULL THEN 1 ELSE 2 END""" % ','.join('?' * len(LAMBDA_NAMES)), + r['file'], a, b, i, *sorted(LAMBDA_NAMES)) + return dict(rows[0]) if rows else None + def lambda_label(self, i): + """`Owner.method.`: the enclosing member's name and the lambda's line""" + r = self.all_sym.get(i) or self.sym.get(i) + if not r: return str(i) + e = self.enclosing(i) + if e: pre = e['display'] + else: + q = (r.get('qualified_name') or '').replace('.', '') + pre = q.rsplit('.', 1)[0] if '.' in q and not q.rsplit('.', 1)[0].endswith('>') else (r.get('owner') or os.path.splitext(os.path.basename(r.get('file') or ''))[0]) + return f"{pre}.{r['name'][:-1]}@L{r.get('line')}>" + def decl_at_line(self, f, ln, spanning=None): + """('field', row) or ('type', row) when `f:ln` is the declaration line of a field, a constant, a property or a + type and no callable of its own is written there; None otherwise. A file:line names "the declaration at that + line", and the resolver took callables only, so a field line answered `no callable spans` in every language + and a C# class line too (#1426). What a front end synthesises ON such a line is not a callable of its own: a + lambda in a field's initializer is part of the field, and an implicit constructor on a type's header line is + the type's (a Python class body is a module node and is never a candidate here).""" + if spanning is None: + spanning = self.q("SELECT id, end_line - line AS span FROM symbols WHERE file = ? AND line <= ? AND end_line >= ? AND method_id IS NOT NULL AND kind <> 'module' ORDER BY span", f, ln, ln) + fields = [dict(x) for x in self.q("SELECT rowid, * FROM symbols WHERE file = ? AND line = ? AND method_id IS NULL AND type_id IS NULL AND kind IN ('field','const','enum_member','variable')", f, ln)] + types = [dict(x) for x in self.q("SELECT * FROM symbols WHERE file = ? AND line = ? AND type_id IS NOT NULL AND method_id IS NULL", f, ln)] + if not fields and not types: return None + fend = max([x.get('end_line') or x['line'] for x in fields], default=ln) + for x in spanning: + s = self.sym.get(x['id']) or self.all_sym.get(x['id']) or {} + if fields and self.is_lambda(x['id']) and ln <= (s.get('line') or 0) and (s.get('end_line') or 0) <= fend: continue + if types and s.get('kind') == 'constructor' and s.get('line') == ln and (s.get('end_line') or ln) == ln: continue + return None # a callable of its own is written on this line + return ('field', fields[0]) if fields else ('type', types[0]) + def name(self, i): + """the name a reader is shown for a declaration: its display, and a lambda's place (lambda_label)""" + return self.lambda_label(i) if self.is_lambda(i) else self.disp(i) + def lambda_target(self, i): + r = self.all_sym.get(i) or self.sym.get(i) + return f"{r['file']}:{r['line']}" if r and r.get('file') and r.get('line') else None + # ── endpoints: exact, or a short list of what is close ─────────────────────────────────────────────────── IN = None def resolve(self, sel, fragment=False): @@ -231,16 +289,61 @@ class G: if not hasattr(self, '_mkind'): self._mkind = {r[0]: r[1] for r in self.q("SELECT id, kind FROM methods")} if self.has('methods') else {} return self._mkind.get((self.sym.get(i) or {}).get('method_id') or i) + def repo_file(self, f): + """-> (the file as the index stores it, whether it lies outside the repository). A file:line is written the way + editors, stack traces and test runners print it: `./src/a.js`, or absolute under the repository root. The index + stores it relative to that root, so both are read as the relative path; an absolute path elsewhere is kept, with + its leading `/` dropped as before, so a root-relative `/src/a.js` still matches — and is named as outside when not.""" + while f.startswith('./'): f = f[2:] + if not os.path.isabs(f): return f, False + real = os.path.realpath(f) + for root in {self.repo, os.path.abspath(self.repo)}: + for p in (real, os.path.abspath(f)): + if p == root or p.startswith(root.rstrip('/') + '/'): + return os.path.relpath(p, root).replace(os.sep, '/'), False + return f.lstrip('/'), True def _resolve(self, sel, fragment=False): s = sel.strip().strip('`"\'') - m = re.match(r'^(.+?):(\d+)$', s) + # file:line, or file:line:col as a stack trace prints it — the column never changes which callable spans the line + m = re.match(r'^(.+?):(\d+)(?::\d+)?$', s) if m and ('/' in m.group(1) or '.' in m.group(1)): # file:line → the method containing it - f, ln = m.group(1).lstrip('/'), int(m.group(2)) + (f, outside), ln = self.repo_file(m.group(1)), int(m.group(2)) + # A PARTIAL PATH NAMES ONE FILE OR IS REFUSED (#1599). `order.service.js:2` with the file under both src/ and + # lib/ took the smallest span across BOTH files, so the answer was for whichever function at that line was + # shorter, labelled with what was typed. The path is settled first: the file written in full, else the one + # file it is a suffix of; two or more are listed, each with what spans the line there. + files = sorted({x['file'] for x in self.q("SELECT DISTINCT file FROM symbols WHERE file = ? OR file LIKE ?", f, f"%/{f}") + if x['file'] == f or (x['file'] or '').endswith('/' + f)}) + if f in files: files = [f] + if len(files) > 1: + rows = [] + for fx in files[:12]: + at = self.q("SELECT id FROM symbols WHERE file = ? AND line <= ? AND end_line >= ? AND method_id IS NOT NULL AND kind <> 'module' ORDER BY end_line - line LIMIT 1", fx, ln, ln) + rows.append(f" {fx}:{ln} " + (self.disp(at[0]['id']) if at else '(top-level code)')) + if len(files) > 12: rows.append(f" … +{len(files) - 12} more") + die(f"'{m.group(1)}' matches {len(files)} indexed files — give enough of the path to name one:\n" + '\n'.join(rows)) + if files: f = files[0] # the smallest callable(s) spanning the line — several when they share it (`class Item { run(){} tag(){} }`); # top-level code belongs to the file's module node, which is a caller like any other - r = self.q("SELECT id, end_line - line AS span FROM symbols WHERE (file = ? OR file LIKE ?) AND line <= ? AND end_line >= ? AND method_id IS NOT NULL AND kind <> 'module' ORDER BY span", f, f"%/{f}", ln, ln) + r = self.q("SELECT id, end_line - line AS span FROM symbols WHERE file = ? AND line <= ? AND end_line >= ? AND method_id IS NOT NULL AND kind <> 'module' ORDER BY span", f, ln, ln) + d = self.decl_at_line(f, ln, r) + if d and d[0] == 'type': # a type's header line: the type, as its name answers + t = d[1] + ids = [x['id'] for x in self.q("SELECT id FROM symbols WHERE owner = ? AND file = ? AND method_id IS NOT NULL", t['display'], f)] + if t['id'] in self.callers_ids: ids.append(t['id']) + if ids: return f"{t['display']} (type at {s}: {len(ids)} methods)", ids + if d and d[0] == 'field': # a field's line: what its name answers + fr = d[1]; nm = f"{fr['owner']}.{fr['name']}" if fr.get('owner') else fr['name'] + try: + with contextlib.redirect_stdout(io.StringIO()): label, ids = self._resolve(nm) + return f"{label} (field at {s})", ids + except SystemExit: pass if r: ids = [x['id'] for x in r if x['span'] == r[0]['span']] + # a field initializer and what it holds on its one line (`static cfg = make({ run() {…} });`) share the + # span; the line names the innermost, the callable written inside the initializer (#1663) + sy = lambda i: (self.sym.get(i, {}).get('display'), self.sym.get(i, {}).get('qualified_name')) + ids = [j for j in ids if not any(ax_edges.init_holds(*sy(j), *sy(i)) for i in ids if i != j)] or ids # AN ANNOTATION IS NOT THE FUNCTION. `export const f: (a: A) => B = (a) => …` puts a one-line call # signature and the arrow on the same line, and the signature, being shortest, won: `impact file:171` # answered for ``. Where every pick is a bodiless signature, the callable that starts @@ -248,9 +351,13 @@ class G: if self.has('methods') and all(self.method_kind(i) in BODILESS_KINDS for i in ids): start = {self.sym[i].get('line') for i in ids if i in self.sym} ids += [x['id'] for x in r if x['id'] not in ids and self.sym.get(x['id'], {}).get('line') in start and self.method_kind(x['id']) not in BODILESS_KINDS] - return (f"{self.disp(ids[0])} (at {s})" if len(ids) == 1 else f"{len(ids)} callables at {s}"), ids - r = self.q("SELECT id FROM symbols WHERE (file = ? OR file LIKE ?) AND kind = 'module' AND method_id IS NOT NULL LIMIT 1", f, f"%/{f}") + return (f"{self.name(ids[0])} (at {s})" if len(ids) == 1 else f"{len(ids)} callables at {s}"), ids + # top-level code: the module body that holds the line. A Python file has two (the file's and each class + # body's), and the first row came back whatever the line, so a module-level constant below a class was + # answered as that class's body + r = self.q("SELECT id FROM symbols WHERE file = ? AND kind = 'module' AND method_id IS NOT NULL ORDER BY (line <= ? AND COALESCE(end_line, line) >= ?) DESC, COALESCE(end_line, line) - line LIMIT 1", f, ln, ln) if r: return f"{self.disp(r[0]['id'])} (top-level code at {s})", [r[0]['id']] + if outside: die(f"{m.group(1)} is outside the indexed repository {self.repo}: give the file relative to that root, or ask the graph of the repository it belongs to") die(f"no callable spans {s}") if s.startswith('@') and self.has('decorations'): # @GetMapping / @*Mapping / @Test → every method carrying it pat = s[1:].replace('*', '%') @@ -282,6 +389,15 @@ class G: # printed the near miss `Router.#handleError`, which is what had just been asked for: a private method could not # be asked about at all, in the language where private methods are written that way. So the spelling as # written is tried against the index BEFORE any separator is rewritten. + # `Owner.method.`, the name changed and test-impact print for a lambda: the lambda at that line + # whose enclosing member is the prefix written + ml = re.match(r'^(?:(.*)\.)?<(\w+)@L(\d+)>$', s) + if ml: + want = canon(ml.group(1) or '') + ids = [x['id'] for x in self.q("SELECT id FROM symbols WHERE name = ? AND line = ? AND method_id IS NOT NULL", f"<{ml.group(2)}>", int(ml.group(3))) + if not want or canon_is(self.lambda_label(x['id']).rsplit('.<', 1)[0], want)] + if ids: return (s if len(ids) == 1 else f"{s} ({len(ids)} declarations)"), ids + die(f"no <{ml.group(2)}> starts on line {ml.group(3)}" + (f" inside {ml.group(1)}" if ml.group(1) else '') + ": ask by file:line") verbatim = re.sub(r'\(.*\)$', '', s).strip() if '#' in verbatim: hit = self.q("SELECT id FROM symbols WHERE (display = ? OR qualified_name = ?) AND method_id IS NOT NULL", verbatim, verbatim) @@ -553,9 +669,11 @@ class G: # ── facts: exported once per graph, reused while graph.sqlite is unchanged ─────────────────────────────── # EXPORT_VERSION is in the stamp beside the graph's mtime: bump it whenever the edges this writes change, or an # upgraded plugin keeps answering from the edges the old code exported (the graph is unchanged, so `index` says it - # is up to date and nothing rebuilds). 2: defines needs a written, strictly wider span (#1402, #1399); 1 was the - # bare mtime. - EXPORT_VERSION = '2' + # is up to date and nothing rebuilds). 4: both 3s below, which landed on two branches under one number; + # 3 (0.1.5): on one line a JavaScript anonymous callable nests only where its name does (#1598); + # 3 (0.1.6): a call written inside a mock's stub or verification is not an edge (ax_edges.stub_sites); + # 2: defines needs a written, strictly wider span (#1402, #1399); 1 was the bare mtime. + EXPORT_VERSION = '4' def export(self): stamp = os.path.join(self.facts, 'stamp'); want = f"{self.db_mtime}:{self.EXPORT_VERSION}" if os.path.exists(stamp) and open(stamp).read() == want: return @@ -563,13 +681,18 @@ class G: # `generated` is a client declaration too: a Lombok accessor, a record member, a C# auto-property. The engine # synthesises it and RESOLVES the call sites that name it, so filtering the edge out lost every call into a # generated member — which, on a project whose models are all @Data, is most of what touches the models. - edges = [(r['caller_id'], r['callee_method_id'], r['tier']) for r in self.q("SELECT caller_id, callee_method_id, tier FROM call_edges WHERE callee_method_id IS NOT NULL AND callee_provenance IN ('client', 'generated')")] + # …except a call written inside a mocking library's stub or verification (`when(repo.find(1))`, + # `mock.Setup(r => r.Find(1))`): the receiver is a mock, so the real body never runs there, and walked as a call + # it made every test that stubs a method a `sound` route to it. impact lists those sites as "stubs it". + stubs = ax_edges.stub_sites(lambda s, p: self.q(s, *p)) if self.has('call_sites') else set() + edges = [(r['caller_id'], r['callee_method_id'], r['tier']) for r in self.q("SELECT call_site_id, caller_id, callee_method_id, tier FROM call_edges WHERE callee_method_id IS NOT NULL AND callee_provenance IN ('client', 'generated')") + if r['call_site_id'] not in stubs] # calls into libraries: an edge to a terminal node (a library method has no out-edges here — nothing is inferred about its # body), so `path '*' java.nio.file.Files.readAllBytes` can ask who reaches that library call edges += [(r['caller_id'], r['callee_method_id'], 'library') for r in self.q("SELECT DISTINCT caller_id, callee_method_id FROM call_edges WHERE tier = 'boundary_lib' AND callee_method_id IS NOT NULL")] # a callable defined inside another one's body: nobody calls it by name, but it runs only after its definer did. # One builder for this and the fast path (graph_sql._edges), so the two cannot disagree on what contains what. - edges += ax_edges.defines_edges(((r['file'], r['line'], r['end_line'], r['id'], r.get('display'), r['method_id']) + edges += ax_edges.defines_edges(((r['file'], r['line'], r['end_line'], r['id'], r.get('display'), r['method_id'], r.get('qualified_name')) for r in self.sym.values() if r['method_id'] and r['kind'] != 'module'), ax_edges.sites_of(lambda s, p: self.q(s, *p)) if self.has('call_sites') else None) # a call resolved to a base may run any instantiated override: the engine's own dispatch table @@ -591,7 +714,7 @@ class G: have = {(a, b) for a, b, _ in edges} edges += [(r[0], r[1], 'dispatch') for r in self.q("SELECT DISTINCT c0, c1 FROM ext_fn_value_call") if (r[0], r[1]) not in have] self.write('edge', edges) - self.write('byname', [(r[0], r[1]) for r in self.q("SELECT DISTINCT s.caller_id, s.callee_name FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.callee_name IS NOT NULL AND s.callee_name <> ''")]) + self.write('byname', sorted({(r[1], r[2]) for r in self.q("SELECT s.id, s.caller_id, s.callee_name FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.callee_name IS NOT NULL AND s.callee_name <> ''") if r[0] not in stubs})) self.write('named', [(r['name'], r['id']) for r in self.sym.values() if r['method_id'] and r['kind'] != 'module' and r['name']]) t = f'{stamp}.{os.getpid()}.tmp'; open(t, 'w').write(want); replace_file(t, stamp) def write(self, name, rows, d=None): @@ -867,9 +990,11 @@ def framework_note(g, ids, writers=()): return [] out = [] try: - regs = [r for r in ax_registration.all_registrations(g.q, g.site_file) if r[0] in ids] + every = ax_registration.all_registrations(g.q, g.site_file) except Exception: return [] + regs = [r for r in every if r[0] in ids] + registered = {r[4] for r in every if r[4]} # a key registered verbatim elsewhere wins over a pattern keys = {} for decl, f, l, kind, key, why in regs[:4]: out.append(f" {g.disp(decl)} is {why} ({f}:{l})") @@ -887,10 +1012,10 @@ def framework_note(g, ids, writers=()): hit = [] for v, f, l in g.q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): if not isinstance(v, str): continue - for key, decl in keys.items(): - if v == key or ax_registration.route_matches(v, key): - c = at(f, l) - if c in writers: hit.append((g.disp(c), v, key, g.site_file(f) if f else f, l)) + for key in ax_registration.route_candidates(v, registered): + if key not in keys: continue + c = at(f, l) + if c in writers: hit.append((g.disp(c), v, key, g.site_file(f) if f else f, l)) for c, v, key, f, l in sorted(set(hit))[:3]: out.append(f" {c} writes \"{v}\" at {f}:{l}, which is the key it is registered under" + (f' ("{key}")' if v != key else '') + " — the framework connects them, no call does") @@ -924,24 +1049,33 @@ def closure(g, sel, upstream, limit=40, depth=40): # turned 3 exact callers into 342 methods. Both numbers are true -- the first is what must run it, the second what may -- # and a count that does not say which it is reads as the first. Walk the same direction again over exact calls only. if rows: - exact_adj = collections.defaultdict(set) - for a, b, t in g.edges(): - if t != 'dispatch' and ax_edges.direct_cert(t) == 'resolved': (exact_adj[b].add(a) if upstream else exact_adj[a].add(b)) - ex, fr = set(ids), list(ids) - for _ in range(depth): - nxt = [] - for x in fr: - for y in exact_adj.get(x, ()): - if y not in ex: ex.add(y); nxt.append(y) - fr = nxt - if not fr: break + # A REQUEST OR EVENT SENT TO ITS HANDLER (`event_dispatch`) is not a dispatch choice: the framework runs the + # handler for what is sent, so what reaches the sender reaches the handler. It is walked as its own layer on top + # of the exact calls and counted apart, rather than filed with the interface fan-out as "MAY run it". + def _walk(ok): + adj_ = collections.defaultdict(set) + for a, b, t in g.edges(): + if ok(t): (adj_[b].add(a) if upstream else adj_[a].add(b)) + ex_, fr_ = set(ids), list(ids) + for _ in range(depth): + nxt = [] + for x in fr_: + for y in adj_.get(x, ()): + if y not in ex_: ex_.add(y); nxt.append(y) + fr_ = nxt + if not fr_: break + return ex_ + _exact_t = lambda t: t != 'dispatch' and ax_edges.direct_cert(t) == 'resolved' + ex = _walk(_exact_t) in_rows = {m for m, _ in rows} exact = (ex - set(ids)) & in_rows + sent = ((_walk(lambda t: _exact_t(t) or t in ax_edges.TIER_WHY) - set(ids)) & in_rows) - exact prod = lambda ms: sum(1 for m in ms if not g.sym[m]['is_test']) n_prod, n_test = prod(in_rows), len(in_rows) - prod(in_rows) e_prod, e_test = prod(exact), len(exact) - prod(exact) - print(f" {n_prod} production, {n_test} test. Through exact calls only: {e_prod} production, {e_test} test; the other " - f"{len(in_rows) - len(exact)} only through a dispatch choice (an interface or override with several implementations, " + print(f" {n_prod} production, {n_test} test. Through exact calls only: {e_prod} production, {e_test} test" + + (f"; {len(sent)} more through a request or event sent to a handler, which the framework runs for what is sent" if sent else '') + + f"; the other {len(in_rows) - len(exact) - len(sent)} only through a dispatch choice (an interface or override with several implementations, " "a call with several candidates) — they MAY run it, not must") if not upstream: print_boundary(g, ids, "it calls into libraries directly", "it also makes") @@ -1078,7 +1212,8 @@ def entered_in_process(g, ids): part = keys[k:k + 900]; q = ','.join('?' * len(part)) if g.has('entry_points'): for r in g.q(f"SELECT method_id, reason FROM entry_points WHERE method_id IN ({q})", *part): - if r['reason'] not in NOT_FROM_A_LIBRARY_CALL: out.setdefault(mid[r['method_id']], f"{r['reason']} entry point") + if r['reason'] not in NOT_FROM_A_LIBRARY_CALL: out.setdefault(mid[r['method_id']], f"{r['reason']} entry point" if ax_edges.entry_outside(r['reason']) + else ax_edges.entry_phrase(r['reason'])) if g.has('overrides'): for r in g.q(f"""SELECT o.overriding_method_id m, b.qualified_name b FROM overrides o JOIN methods b ON b.id = o.method_id WHERE o.overriding_method_id IN ({q}) AND b.provenance <> 'client'""", *part): @@ -1305,6 +1440,24 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): print(f" {ly} is entered from outside the graph in the same process, as such a call can do: " + ', '.join(f"{g.disp(m)} ({why}) {g.loc(m)}" for m, why in entered[:3]) + (f" … +{len(entered) - 3}" if len(entered) > 3 else '')) + # A CALL WHOSE CALLEE IS A VALUE CAN LAND ANYWHERE (#1649). `cb()` on a parameter, `f()` on a loop variable, + # `table[k]()`: nothing about the name narrows the target, so the by-name search above finds no lead and + # "independent" was printed for a start that hands control to whatever it was given. The engine marks such a + # site (`unresolved_value_callee`); one in either side's closure makes the connection unknown, not absent. + opaque = [] + if not (remote_found or lib_side) and g.has('ext_unresolved_value_callee'): + for xs, lx in ((A_, la), (B_, lb)): + seen = {i for i in xs if i in g.sym and g.sym[i]['kind'] not in ('library', 'written')}; fr = list(seen) + while fr: + fr = [y for y in dict.fromkeys(y for x in fr for y in adj.get(x, ())) if y not in seen and y in g.sym + and g.sym[y]['kind'] not in ('library', 'written')] + seen.update(fr) + ids = sorted(seen); rows = [] + for i in range(0, len(ids), 900): + part = ids[i:i + 900] + rows += g.q("SELECT s.caller_id c, s.callee_name n, s.file_path f, s.start_line ln, v.c1 why FROM ext_unresolved_value_callee v" + " JOIN call_sites s ON s.id = v.c0 WHERE s.caller_id IN (%s)" % ','.join('?' * len(part)), *part) + if rows: opaque.append((lx, sorted(rows, key=lambda r: (g.site_file(r['f']), r['ln'] or 0)))) # …but a framework may still connect them, and the graph holds the evidence for it: the target is registered # under a key, and the source writes that key. That is not a chain of calls, so it is reported and not walked. # Computed BEFORE the verdict line, which used to say "independent" one line above the note that joined them. @@ -1315,7 +1468,7 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): # a claim about the model and not the code. An endpoint the engine already made an entry point is modelled, and # the library-call rule above decides it. unmod = [] - if not (remote_found or keyed or lib_side): + if not (remote_found or keyed or lib_side or opaque): called = {y for ys in adj.values() for y in ys} eps = set() ends = [i for i in list(B_) + list(A_) if i in g.sym and g.sym[i]['kind'] not in ('library', 'written') and i not in called] @@ -1330,10 +1483,20 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): " does: NOT independent" if keyed else " — but a library called above can reach the other at run time (an event, a callback, a hook) with no" " call edge to record it: NOT shown to be independent" if lib_side + else f" — but {' and '.join(lx for lx, _ in opaque)} make{'s' if len(opaque) == 1 else ''} a call whose callee is a" + f" value, not a name (below): any function passed, stored or exported may be what it calls, so the" + f" connection is UNKNOWN, not absent" if opaque else f" — but NOT CHECKED: {'; '.join(s_ for _i, s_ in unmod[:3])} — a framework calls it and the graph does not" f" model how, so the other may reach it through that framework. Before treating them as independent run: " f"{grep_for(g, g.sym[unmod[0][0]]['name'])}" if unmod else ": the two are independent in this graph")) + WHY = {'parameter': 'a parameter', 'loop_variable': 'a loop variable', 'local': 'a local', 'computed': 'a computed member', + 'dynamic_code': 'code built at run time'} + for lx, rows in opaque: + print(f" {lx} reaches {len(rows)} call(s) through a value:") + for r in rows[:4]: + print(f" `{r['n'] or '[…]'}()` in {g.disp(r['c'])} at {g.site_file(r['f'])}:{r['ln']} — the callee is {WHY.get(r['why'], r['why'])}") + if len(rows) > 4: print(f" … +{len(rows) - 4} more") for line in fw: print(line) return 1 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index 9154a8f96..388fdab54 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -1,5 +1,13 @@ #!/usr/bin/env python3 -"""axiomcode test-impact [] [--range .. | --staged] [--in ] [--limit N] [--json] [--why] +"""axiomcode test-impact [] […] [--range .. | --staged] [--in ] [--limit N] [--json] [--why] + + … only these files; a named file with no edit against its base (or any named file on a copy + without git) counts WHOLE: the tests of everything declared in it + --range a..b b's own commits: compared from `git merge-base a b`, so commits a received after b forked + from it are not counted (a note says so when a has moved) + files outside every indexed language (fixtures, case data, a schema) are named, with the test files that + name them in their text; a changed file that is also run as a program (__main__, main) is looked for by + name in the tests too, since a test that starts it as a subprocess has no call edge to it WHICH TESTS ACTUALLY HAVE TO RUN for the edit in front of you. @@ -46,7 +54,7 @@ those five files. Nothing a reader is shown got better; what got better is WHY t the graph can follow rather than a file named after the changed module. A recall number that does not move is not the same as a change that does nothing, and the rung on each row is where the difference is. """ -import importlib.machinery, importlib.util, json, os, re, subprocess, sys, collections +import importlib.machinery, importlib.util, json, os, re, subprocess, sys, collections, functools, posixpath, shlex TESTY = re.compile(r'(^|[/_.-])(test|tests|spec|specs|__tests__|it)([/_.-]|$)', re.I) @@ -60,9 +68,65 @@ def run(script, args): return r.returncode, r.stdout, r.stderr +def concrete_test_classes(db, names): + """(names, {abstract: [concrete subclasses]}): each ABSTRACT class among `names` replaced by the concrete classes + that extend it. A test declared in an abstract base runs only as a member of a subclass, so `mvn -Dtest=AbstractX` + runs nothing (surefire skips an abstract class), and `dotnet test --filter FullyQualifiedName~AbstractX` matches + nothing (an inherited test is named after the class that runs it). A concrete base is kept: its tests run in it. + The graph records no modifiers, so `abstract` is read from the declaration in the source, which the types table + locates. Returns `names` unchanged when there is no graph to ask.""" + import sqlite3 + try: + con = sqlite3.connect(f'file:{db}?mode=ro', uri=True) + types = con.execute("SELECT id, name, qualified_name, file_path FROM types").fetchall() + anc = con.execute("SELECT type_id, ancestor_type_id FROM type_ancestors").fetchall() + root = dict(con.execute("SELECT key, value FROM index_meta").fetchall()).get('repo', '') + con.close() + except Exception: + return list(names), {} + by_name, info = collections.defaultdict(list), {} + for tid, n, qn, f in types: + info[tid] = (n, f) + by_name[n].append(tid) + if qn: by_name[qn].append(tid) + subs = collections.defaultdict(set) + for t, a in anc: + if t != a: subs[a].add(t) + texts = {} + def is_abstract(tid): + n, f = info[tid] + if not f: return False + f = f if os.path.isabs(f) else os.path.join(root, f) + if f not in texts: + try: texts[f] = open(f, errors='replace').read() + except OSError: texts[f] = '' + # a declaration line, not a comment: modifiers (or annotations) and then `class Name` + return re.search(r'^[ \t]*(?:[\w@]+[ \t]+)*abstract[ \t]+(?:\w+[ \t]+)*class[ \t]+' + re.escape(n) + r'\b', + texts[f], re.M) is not None + out, replaced = [], {} + for c in names: + tids = by_name.get(c) or by_name.get(c.split('.')[-1]) or [] + if not tids or not any(is_abstract(t) for t in tids): + out.append(c); continue + seen, todo, concrete = set(), list(tids), set() + while todo: # every class below it, however deep (type_ancestors may be direct only) + for s in subs.get(todo.pop(), ()): + if s in seen: continue + seen.add(s); todo.append(s) + if not is_abstract(s): concrete.add(info[s][0]) + replaced[c] = sorted(concrete) + out += sorted(concrete) + return list(dict.fromkeys(out)), replaced + + # How a suite is invoked, per language. Only the shapes this graph is built for; anything else prints the # files and lets the reader decide, which is better than inventing a command that does not run. -def command_for(lang, files, classes): +def command_for(lang, files, classes, db=None, repo='.'): + if lang in ('java', 'csharp') and db: + # a class to select by name: the test's owner, else its file's basename (a Java or C# test class is its file) + names = classes or [os.path.splitext(os.path.basename(f))[0] for f in files] + classes, files = concrete_test_classes(db, names)[0], [] + if not classes: return None # only abstract classes, and nothing extends them: nothing runs if lang == 'java' and classes: return "mvn test -Dtest=" + ",".join(sorted({c.split('.')[-1] for c in classes})[:40]) if lang == 'java' and files: @@ -83,10 +147,1187 @@ def command_for(lang, files, classes): if lang == 'python': return "pytest " + " ".join(sorted(files)[:40]) if lang in ('typescript', 'javascript'): - return "npx vitest run " + " ".join(sorted(files)[:40]) + return "\n".join(js_plan(repo, files)[0]) or None + return None + + +# A TypeScript or JavaScript test file is run by whatever its PACKAGE runs it with, and nothing about the +# language says which: vitest collects `*.test.ts` and ignores `services-tests.ts`, a script suite is run as +# `npx tsx src/test/x.ts` from its package, and `npx vitest run ` on a file vitest does not collect +# exits 1 with "No test files found" (#1570). So each file is placed with the runner that would collect it — +# read off the package's config and dependencies, with that runner's own include globs — and failing that +# with the command its package scripts or its own header give for it. A file nothing collects is said so, +# never handed to a runner that will not run it. +# +# A runner's collection is read the way the runner reads it, not by the first `include` in the file: vitest's +# test include is the one directly under `test:` (not `coverage.include`), each inline project collects on its +# own, `exclude` takes files back out, and vite.config.* is its config when there is no vitest.config.*; jest's +# testMatch is relative to , testRegex replaces it, and `jest --config x` in a script is a config too. +# The nearest directory whose runner collects the file is where the command runs from, so a nested +# e2e/jest.config.ts runs its own files. A runner with a config outranks one that is only a dependency. +# +# A config is read as the value the file exports (_js_config), so the include is found wherever the file puts it: +# behind defineConfig or a config function, in a const or a shorthand property, spread from the runner's own +# defaults, merged by mergeConfig, re-exported or imported from a file beside it. A value it still cannot read +# (computed at load time, from a package it does not know) is unknown, and unknown is never taken as the +# runner's defaults: an unknown include keeps the command, an unknown exclude takes nothing out. +_CONF_EXT = ('ts', 'mts', 'cts', 'js', 'mjs', 'cjs') +_RUNNERS = ( + # runner, config files, package.json key, command + ('vitest', tuple(f'vitest.config.{e}' for e in _CONF_EXT) + tuple(f'vitest.workspace.{e}' for e in _CONF_EXT) + + ('vitest.workspace.json',), None, 'npx vitest run'), + ('jest', tuple(f'jest.config.{e}' for e in _CONF_EXT) + ('jest.config.json',), 'jest', 'npx jest'), + ('mocha', ('.mocharc.js', '.mocharc.cjs', '.mocharc.json', '.mocharc.jsonc', '.mocharc.yml', '.mocharc.yaml'), + 'mocha', 'npx mocha'), + ('playwright', tuple(f'playwright.config.{e}' for e in _CONF_EXT), None, 'npx playwright test'), +) +_VITEST_INCLUDE = ('**/*.{test,spec}.?(c|m)[jt]s?(x)',) +_VITEST_EXCLUDE = ('**/node_modules/**', '**/dist/**', '**/cypress/**', '**/.{idea,git,cache,output,temp}/**', + '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*') +_JEST_MATCH = ('**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)') +_PW_MATCH = ('**/*.@(spec|test).?(c|m)[jt]s?(x)',) +_NODE_TEST = ('**/*.test.?(c|m)[jt]s', '**/*-test.?(c|m)[jt]s', '**/*_test.?(c|m)[jt]s', '**/test-*.?(c|m)[jt]s', + '**/test.?(c|m)[jt]s', '**/test/**/*.?(c|m)[jt]s') +_CRA_MATCH = ('src/**/__tests__/**/*.{js,jsx,ts,tsx}', 'src/**/*.{spec,test}.{js,jsx,ts,tsx}') + +_JS_TOK = re.compile(r"""(?P\s+|//[^\n]*|/\*.*?\*/) + |(?P"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`) + |(?P[A-Za-z_$][\w$]*|\d[\w.]*) + |(?P

\.\.\.|.)""", re.S | re.X) +_JS_RE = re.compile(r'/((?:[^/\\\n\[]|\\.|\[(?:[^\]\\\n]|\\.)*\])+)/[a-z]*') + + +def _unq(s): + """A JS or JSON string literal's text.""" + return re.sub(r'\\(.)', lambda m: {'n': '\n', 't': '\t'}.get(m.group(1), m.group(1)), s[1:-1]) + + +class _Unknown: + """A config value this cannot read: a call it does not know, an env lookup, a file it cannot load.""" + def __repr__(self): return '?' + + +_UNK = _Unknown() +_PARTIAL = '\0partial' # an object with a spread this cannot read: any key it does not spell is unknown + + +class _Fn: + """A function value: what it returns (a runner calls a config function and reads its result).""" + def __init__(self, ret): self.ret = ret + + +_VITEST_MOD = {'configDefaults': {'include': list(_VITEST_INCLUDE), 'exclude': list(_VITEST_EXCLUDE)}, + 'defaultInclude': list(_VITEST_INCLUDE), 'defaultExclude': list(_VITEST_EXCLUDE)} +_JEST_DEFAULTS = {'testMatch': list(_JEST_MATCH), 'testRegex': [], 'testPathIgnorePatterns': ['/node_modules/']} +_KNOWN_MODULES = {'vitest/config': _VITEST_MOD, 'vitest/node': _VITEST_MOD, 'vitest': _VITEST_MOD, + 'jest-config': {'defaults': _JEST_DEFAULTS}} +_IDENTITY = {'defineConfig', 'defineProject', 'defineWorkspace', 'defineVitestConfig', 'defineVitestProject'} +_OPS = set('|&?:+-*%<>=!^~') + + +def _js_tokens(text): + """(kind, text, newline-before) for each token: `s` string, `re` regex, `id` word or number, `p` punctuation.""" + toks, i, n, nl = [], 0, len(text), False + while i < n: + prev = toks[-1][1] if toks else '' + if text[i] == '/' and (prev in ('', ':', '[', ',', '(', '=', '!', '&', '|', '?', '{', ';', 'return') + ) and not text.startswith(('//', '/*'), i): + m = _JS_RE.match(text, i) + if m: + toks.append(('re', m.group(1), nl)); i = m.end(); nl = False; continue + m = _JS_TOK.match(text, i) + if m.lastgroup != 'ws': + toks.append((m.lastgroup, m.group(0), nl)); nl = False + elif '\n' in m.group(0): + nl = True + i = m.end() + return toks + + +class _PkgMod(dict): + """A package's exports: the values this knows (vitest's configDefaults), else the export's name, so a call to + it (defineConfig, mergeConfig) is still read by name.""" + + +def _get(obj, key): + """obj[key] the way a runner reads its options: None when absent, _UNK when it cannot be known.""" + if not isinstance(obj, dict): return None if obj is None else _UNK + if key in obj: return obj[key] + if isinstance(obj, _PkgMod): return ('global', key) + return _UNK if obj.get(_PARTIAL) else None + + +def _merge(a, b): + """vite's mergeConfig: objects merge key by key, arrays concatenate, anything else is replaced.""" + if a is _UNK or b is _UNK: return _UNK + if isinstance(a, dict) and isinstance(b, dict): + out = dict(a) + for k, v in b.items(): + out[k] = _merge(out[k], v) if k in out and k != _PARTIAL else v + return out + if isinstance(a, list) or isinstance(b, list): + return (a if isinstance(a, list) else [] if a is None else [a]) + (b if isinstance(b, list) else [b]) + return a if b is None else b + + +def _resolve_module(base_dir, spec): + for cand in (spec, *(spec + '.' + e for e in _CONF_EXT + ('json',)), + *(posixpath.join(spec, 'index.' + e) for e in _CONF_EXT)): + p = os.path.join(base_dir, cand) + if os.path.isfile(p): return p + # `./x.js` written for a file that is `./x.ts` (TypeScript's own import spelling) + stem, ext = os.path.splitext(spec) + if ext in ('.js', '.mjs', '.cjs'): + for e in ('ts', 'mts', 'cts'): + p = os.path.join(base_dir, f'{stem}.{e}') + if os.path.isfile(p): return p + return None + + +_LOADING = set() + + +@functools.lru_cache(maxsize=None) +def _js_module(path): + """A config file's exports as {'default': value, name: value}; each value a Python dict/list/str, a + ('re', src) regex, a _Fn, or _UNK where the file does not say it literally (or through a const, a spread, a + shorthand, a re-export, an import of a file beside it, defineConfig or mergeConfig).""" + if path in _LOADING or len(_LOADING) > 6: return {'default': _UNK} + _LOADING.add(path) + try: + text = _read(path) + toks = _js_tokens(text) + if path.endswith(('.json', '.jsonc')): + try: + return {'default': json.loads(text)} + except ValueError: # a .json with comments (jsonc) is read as the JS expression it is + toks = [('id', 'export', False), ('id', 'default', False)] + toks + return _JsEval(path, toks).exports + except Exception: + return {'default': _UNK} + finally: + _LOADING.discard(path) + + +def _js_config(path): + """The value a config file exports, a config function's result taken for the function.""" + v = _js_module(path).get('default', _UNK) + return v.ret if isinstance(v, _Fn) else v + + +class _JsEval: + """Reads the module-level JS a config file is written in, enough to know what it exports. Not a JS engine: + any expression it does not know evaluates to _UNK, and parsing never fails, only gives up on a value.""" + CLOSE = {')', ']', '}', ',', ';'} + + def __init__(self, path, toks): + self.dir, self.t, self.exports = os.path.dirname(path), toks, {} + env = {} + self.block(0, len(toks), env, None) + if 'default' not in self.exports: + self.exports['default'] = _UNK + + def v(self, i): + return self.t[i][1] if i < len(self.t) else '' + + def kind(self, i): + return self.t[i][0] if i < len(self.t) else '' + + def nl(self, i): + return self.t[i][2] if i < len(self.t) else True + + def skip_balanced(self, i): + """Past the bracket at i and everything up to its match.""" + depth = 0 + while i < len(self.t): + x = self.v(i) + if self.kind(i) == 'p' and x in '([{': depth += 1 + elif self.kind(i) == 'p' and x in ')]}': + depth -= 1 + if depth <= 0: return i + 1 + i += 1 + return i + + def skip_type(self, i, stop): + """Past a TypeScript type, up to a token in `stop` at depth 0, or a line break that ends the statement.""" + depth, start = 0, i + while i < len(self.t): + x = self.v(i) + if depth == 0 and x in stop and self.kind(i) == 'p': return i + if depth == 0 and i > start and self.nl(i) and self.v(i - 1) not in ('|', '&', ':', '=', ',', '<', '>') \ + and x not in ('|', '&', '.', '[', '<', '{', '=', ':'): + return i + if x in '([{<' and self.kind(i) == 'p': depth += 1 + elif x in ')]}>' and self.kind(i) == 'p': + if x == '>' and self.v(i - 1) == '=': pass # `=>` in a function type + else: depth -= 1 + i += 1 + return i + + def module(self, spec): + if spec in _KNOWN_MODULES: return {'default': _PkgMod(_KNOWN_MODULES[spec]), **_KNOWN_MODULES[spec]} + if spec.startswith('.'): + p = _resolve_module(self.dir, spec) + if p: return _js_module(p) + return {} + + def imported(self, spec, name): + mod = self.module(spec) + if not spec.startswith('.'): # a package: its known values, else the export by name + mod = _PkgMod(mod) + return mod if name == '*' else _get(mod, name) + if name == '*': return mod or _UNK + return mod.get(name, _UNK) + + # ---- statements --------------------------------------------------------------------------------------------- + def block(self, i, end, env, rets): + """Statements from i to end. `rets` collects a function body's return values (None at module level).""" + while i < end: + x, k = self.v(i), self.kind(i) + if k == 'p' and x in (';', ',', '}', ')', ']'): + i += 1; continue + if k == 'id' and x == 'import' and self.v(i + 1) not in ('(', '.'): + i = self.stmt_import(i + 1, env); continue + if k == 'id' and x == 'export': + i = self.stmt_export(i + 1, env, rets); continue + if k == 'id' and x in ('const', 'let', 'var'): + i = self.stmt_decl(i + 1, env); continue + if k == 'id' and x in ('function', 'async') and (x == 'function' or self.v(i + 1) == 'function'): + j = i + 1 if x == 'function' else i + 2 + if self.v(j) == '*': j += 1 + name = self.v(j) if self.kind(j) == 'id' else None + fn, i = self.function(j + 1 if name else j, env) + if name: env[name] = fn + continue + if k == 'id' and x in ('type', 'interface', 'declare', 'enum', 'namespace') and self.kind(i + 1) == 'id': + j = self.skip_type(i + 2, {'=', '{', ';'}) + if self.v(j) == '=': + i = self.skip_type(j + 1, {';'}); continue + i = self.skip_balanced(j) if self.v(j) == '{' else j + 1; continue + if k == 'id' and x == 'return' and rets is not None: + val, i = self.expr(i + 1, env) + rets.append(val); continue + if k == 'id' and x in ('if', 'for', 'while', 'switch', 'with', 'catch'): + i = self.skip_balanced(i + 1) if self.v(i + 1) == '(' else i + 1; continue + if k == 'id' and x in ('else', 'try', 'finally', 'do'): + i += 1; continue + if k == 'p' and x == '{': + j = self.skip_balanced(i) + sub = [] if rets is not None else None + self.block(i + 1, j - 1, dict(env), sub) + if rets is not None: rets.extend(sub + ([_UNK] if sub else [])) # a return under a condition + i = j; continue + if k == 'id' and x == 'module' and self.v(i + 1) == '.' and self.v(i + 2) == 'exports': + if self.v(i + 3) == '=': + self.exports['default'], i = self.expr(i + 4, env); continue + if self.v(i + 3) == '.' and self.v(i + 5) == '=': + val, i = self.expr(i + 6, env) + d = self.exports.get('default') + if isinstance(d, dict): d[self.v(i + 4)] = val + continue + if k == 'id' and x == 'exports' and self.v(i + 1) == '.' and self.v(i + 3) == '=': + name = self.v(i + 2) + self.exports[name], i = self.expr(i + 4, env); continue + _, j = self.expr(i, env) + i = j if j > i else i + 1 + return i + + def stmt_import(self, i, env): + if self.kind(i) == 's': + return i + 1 # import 'side-effect' + if self.v(i) == 'type': i += 1 + names = [] # (local, imported) + while i < len(self.t) and self.v(i) != 'from': + x = self.v(i) + if x == '*' and self.v(i + 1) == 'as': + names.append((self.v(i + 2), '*')); i += 3; continue + if x == '{': + j = i + 1 + while j < len(self.t) and self.v(j) != '}': + if self.kind(j) in ('id', 's') and self.v(j) != 'type': + imp = _unq(self.v(j)) if self.kind(j) == 's' else self.v(j) + if self.v(j + 1) == 'as': + names.append((self.v(j + 2), imp)); j += 3 + else: + names.append((imp, imp)); j += 1 + continue + j += 1 + i = j + 1; continue + if self.kind(i) == 'id': + names.append((x, 'default')) + i += 1 + spec = _unq(self.v(i + 1)) if self.kind(i + 1) == 's' else '' + for local, imp in names: + env[local] = self.imported(spec, imp) if spec else _UNK + return i + 2 + + def stmt_export(self, i, env, rets): + x = self.v(i) + if x == 'default': + self.exports['default'], j = self.expr(i + 1, env) + return j + if x in ('const', 'let', 'var'): + before = dict(env) + j = self.stmt_decl(i + 1, env) + for name, val in env.items(): + if before.get(name, None) is not val: self.exports[name] = val + return j + if x in ('function', 'async'): + j = i + 1 if x == 'function' else i + 2 + name = self.v(j) if self.kind(j) == 'id' else None + fn, j = self.function(j + 1 if name else j, env) + if name: env[name] = self.exports[name] = fn + return j + if x == 'type' and self.v(i + 1) == '{' or x == '{' or x == '*': + if x == 'type': i += 1 + pairs, j = [], i + 1 + if x == '*': + j = i + 1 + if self.v(j) == 'as': pairs.append((self.v(j + 1), '*')); j += 2 + else: + while j < len(self.t) and self.v(j) != '}': + if self.kind(j) in ('id', 's'): + src = _unq(self.v(j)) if self.kind(j) == 's' else self.v(j) + if self.v(j + 1) == 'as': + pairs.append((self.v(j + 2), src)); j += 3 + else: + pairs.append((src, src)); j += 1 + continue + j += 1 + j += 1 + if self.v(j) == 'from' and self.kind(j + 1) == 's': + spec = _unq(self.v(j + 1)) + for out, src in pairs: + self.exports[out] = self.imported(spec, src) + return j + 2 + for out, src in pairs: + self.exports[out] = env.get(src, _UNK) + return j + return i + + def stmt_decl(self, i, env): + while i < len(self.t): + if self.kind(i) == 'id': + name, i = self.v(i), i + 1 + if self.v(i) == '!': i += 1 + if self.v(i) == ':': i = self.skip_type(i + 1, {'=', ',', ';'}) + if self.v(i) == '=': + env[name], i = self.expr(i + 1, env) + else: + env[name] = None + elif self.v(i) in ('{', '['): + pat_end = self.skip_balanced(i) + j = pat_end + if self.v(j) == ':': j = self.skip_type(j + 1, {'=', ',', ';'}) + val = _UNK + if self.v(j) == '=': + val, j = self.expr(j + 1, env) + self.destructure(i, pat_end, val, env) + i = j + else: + return i + if self.v(i) != ',': return i + i += 1 + return i + + def destructure(self, i, end, val, env): + """`{ a, b: c, ...rest }` / `[a, b]` bound from val (a name it cannot bind is _UNK).""" + if self.v(i) == '[': + names = [self.v(j) for j in range(i + 1, end - 1) if self.kind(j) == 'id'] + for n, name in enumerate(names): + env[name] = val[n] if isinstance(val, list) and n < len(val) else _UNK + return + j = i + 1 + while j < end - 1: + if self.v(j) == '...' and self.kind(j + 1) == 'id': + env[self.v(j + 1)] = _UNK; j += 2; continue + if self.kind(j) in ('id', 's'): + key = _unq(self.v(j)) if self.kind(j) == 's' else self.v(j) + local = key + if self.v(j + 1) == ':' and self.kind(j + 2) == 'id': + local = self.v(j + 2); j += 2 + env[local] = _get(val, key) + j += 1 + if self.v(j) == '=': # a default: skip it + while j < end - 1 and self.v(j) != ',': j = self.skip_balanced(j) if self.v(j) in '([{' else j + 1 + continue + j += 1 + + # ---- expressions -------------------------------------------------------------------------------------------- + def function(self, i, env): + """A function from its parameter list at i (a `name` already consumed): (_Fn, index after it).""" + if self.v(i) == '<': i = self.skip_type(i + 1, {'>'}) + 1 # generics + if self.v(i) == '(': + i = self.skip_balanced(i) + elif self.kind(i) == 'id': + i += 1 + if self.v(i) == ':': i = self.skip_type(i + 1, {'{', '='}) + if self.v(i) == '=' and self.v(i + 1) == '>': i += 2 + if self.v(i) == '{': + j = self.skip_balanced(i) + rets = [] + self.block(i + 1, j - 1, dict(env), rets) + return _Fn(rets[0] if len(rets) == 1 else _UNK), j + val, j = self.expr(i, env) + return _Fn(val), j + + def arrow_at(self, i): + """Whether an arrow function's parameters start at i (`(a, b) =>`, `x =>`, `(x): T =>`).""" + if self.kind(i) == 'id' and self.v(i + 1) == '=' and self.v(i + 2) == '>': return True + if self.v(i) != '(': return False + j = self.skip_balanced(i) + if self.v(j) == ':': j = self.skip_type(j + 1, {'='}) + return self.v(j) == '=' and self.v(j + 1) == '>' + + def expr(self, i, env): + val, i = self.unary(i, env) + while i < len(self.t) and self.kind(i) == 'p' and self.v(i) in _OPS: + if self.v(i) == '=' and self.v(i + 1) == '>': break + if self.v(i) == '?' and self.v(i + 1) == '.': break + while self.kind(i) == 'p' and self.v(i) in _OPS and not (self.v(i) == '-' and self.v(i + 1) == '-'): + i += 1 + if self.v(i) in self.CLOSE: break + rhs, i = self.unary(i, env) + val = _UNK + while self.kind(i) == 'id' and self.v(i) in ('as', 'satisfies') and not self.nl(i): + i = self.skip_type(i + 1, {',', ';', ')', ']', '}'}) + return val, i + + def unary(self, i, env): + x = self.v(i) + if x in ('await', 'typeof', 'void', 'delete', 'new') and self.kind(i) == 'id': + val, j = self.unary(i + 1, env) + return (val if x == 'await' else _UNK), j + if self.kind(i) == 'p' and x in ('!', '-', '+', '~', '...'): + _, j = self.unary(i + 1, env) + return _UNK, j + val, i = self.primary(i, env) + return self.postfix(val, i, env) + + def postfix(self, val, i, env): + while i < len(self.t): + x = self.v(i) + if x == '.' or (x == '?' and self.v(i + 1) == '.'): + i += 2 if x == '?' else 1 + if self.v(i) in ('(', '['): continue # `?.(` / `?.[` + name, i = self.v(i), i + 1 + # a member of a name this does not know is kept by name (`vite.mergeConfig`), and read as unknown + val = ('global', name) if isinstance(val, tuple) and val[0] == 'global' else \ + (_get(val, name) if isinstance(val, dict) else _UNK) + continue + if x == '!' and self.v(i + 1) != '=': + i += 1; continue + if x == '[' and not self.nl(i): + key, j = self.expr(i + 1, env) + i = self.skip_balanced(i) + val = (_get(val, key) if isinstance(val, dict) and isinstance(key, str) else + val[int(key)] if isinstance(val, list) and isinstance(key, str) and key.isdigit() + and int(key) < len(val) else _UNK) + continue + if x == '<' and isinstance(val, tuple) and val[0] == 'global': + j = self.skip_type(i + 1, {'>'}) + if self.v(j) == '>' and self.v(j + 1) == '(': + i = j + 1; continue + return val, i + if x == '(' and not self.nl(i): + args, j = [], i + 1 + while j < len(self.t) and self.v(j) != ')': + a, k = self.expr(j, env) + args.append(a) + j = k if k > j else j + 1 + if self.v(j) == ',': j += 1 + i = j + 1 + val = self.call(val, args) + continue + if self.kind(i) == 's' and self.v(i).startswith('`'): # a tagged template + i += 1; val = _UNK; continue + break + return (None if isinstance(val, tuple) and val[0] == 'global' and val[1] == 'undefined' + else _UNK if isinstance(val, tuple) and val[0] == 'global' else val), i + + def call(self, fn, args): + name = fn[1] if isinstance(fn, tuple) and fn[0] == 'global' else None + if name in _IDENTITY: + a = args[0] if args else _UNK + return a.ret if isinstance(a, _Fn) else a + if name == 'mergeConfig': + out = None + for a in args[:2]: + a = a.ret if isinstance(a, _Fn) else a + out = a if out is None else _merge(out, a) + return _UNK if out is None else out + if name == 'require' and args and isinstance(args[0], str): + return self.imported(args[0], 'default') if args[0].startswith('.') else self.imported(args[0], '*') + if isinstance(fn, _Fn): + return fn.ret + return _UNK + + def primary(self, i, env): + x, k = self.v(i), self.kind(i) + if k == 's': + if x.startswith('`') and '${' in x: return _UNK, i + 1 + return _unq(x), i + 1 + if k == 're': + return ('re', x), i + 1 + if k == 'p' and x == '{': + return self.object(i, env) + if k == 'p' and x == '[': + return self.array(i, env) + if k == 'id' and x == 'async' and (self.arrow_at(i + 1) or self.v(i + 1) == 'function'): + return self.primary(i + 1, env) + if self.arrow_at(i): + return self.function(i, env) + if k == 'p' and x == '(': + val, j = self.expr(i + 1, env) + return val, self.skip_balanced(i) + if k == 'id' and x == 'function': + j = i + 1 + if self.v(j) == '*': j += 1 + if self.kind(j) == 'id' and self.v(j + 1) in ('(', '<'): j += 1 + return self.function(j, env) + if k == 'id' and x == 'class': + j = i + 1 + while j < len(self.t) and self.v(j) != '{': j += 1 + return _UNK, self.skip_balanced(j) + if k == 'id': + if x in ('true', 'false'): return x == 'true', i + 1 + if x == 'null': return None, i + 1 + if x[0].isdigit(): return x, i + 1 + if x == 'import' and self.v(i + 1) in ('(', '.'): + return ('global', 'import()'), i + 1 + if x in env: return env[x], i + 1 + return ('global', x), i + 1 + return _UNK, i # nothing here this can read: do not advance + + def object(self, i, env): + out, j = {}, i + 1 + while j < len(self.t) and self.v(j) != '}': + x, k = self.v(j), self.kind(j) + if x == ',': + j += 1; continue + if x == '...': + val, j = self.expr(j + 1, env) + val = val.ret if isinstance(val, _Fn) else val + if isinstance(val, dict): out.update(val) + else: out[_PARTIAL] = True + continue + if x == '[': # a computed key: its entry cannot be named + j = self.skip_balanced(j) + out[_PARTIAL] = True + if self.v(j) == ':': _, j = self.expr(j + 1, env) + elif self.v(j) == '(': _, j = self.function(j, env) + continue + if k in ('id', 's'): + key = _unq(x) if k == 's' else x + if k == 'id' and x in ('get', 'set', 'async') and self.kind(j + 1) in ('id', 's') \ + and self.v(j + 2) == '(': + j += 1; key = self.v(j) + if self.v(j + 1) == '?': j += 1 + nxt = self.v(j + 1) + if nxt == ':': + out[key], j = self.expr(j + 2, env) + elif nxt in ('(', '<'): + fn, j = self.function(j + 1, env); out[key] = fn + elif nxt == '=': # a destructuring default, not an object literal + out[key], j = _UNK, j + 2 + _, j = self.expr(j, env) + else: + out[key] = env.get(key, _UNK); j += 1 # shorthand `{ test }` + continue + _, nj = self.expr(j, env) + j = nj if nj > j else j + 1 + return out, j + 1 + + def array(self, i, env): + out, j = [], i + 1 + while j < len(self.t) and self.v(j) != ']': + if self.v(j) == ',': + j += 1; continue + if self.v(j) == '...': + val, j = self.expr(j + 1, env) + out.extend(val if isinstance(val, list) else [_UNK]) + continue + val, nj = self.expr(j, env) + out.append(val) + j = nj if nj > j else j + 1 + return out, j + 1 + + +def _glob_re(g): + """A runner's include glob as a regex over a /-separated path: **, *, ?, [..], {a,b}, ?(..) and +(..).""" + out, i = '', 0 + g = g[2:] if g.startswith('./') else g + while i < len(g): + c = g[i] + if g.startswith('**/', i): + out += '(?:.*/)?'; i += 3; continue + if g.startswith('**', i): + out += '.*'; i += 2; continue + if c in '?+*@!' and g.startswith('(', i + 1): + j = g.index(')', i + 2) + alts = '|'.join(_glob_re(a) for a in g[i + 2:j].split('|')) + out += f"(?:{alts})" + {'?': '?', '+': '+', '*': '*', '@': '', '!': ''}[c]; i = j + 1; continue + if c == '*': + out += '[^/]*' + elif c == '?': + out += '[^/]' + elif c == '[': + j = g.index(']', i + 1); out += g[i:j + 1]; i = j + 1; continue + elif c == '{': + j = g.index('}', i + 1) + out += '(?:' + '|'.join(_glob_re(a) for a in g[i + 1:j].split(',')) + ')'; i = j + 1; continue + else: + out += re.escape(c) + i += 1 + return out + + +def _globs_match(globs, rel): + for g in globs: + try: + if re.fullmatch(_glob_re(g), rel): return True + except (ValueError, re.error): + return True # a glob this cannot read is not evidence the file is left out + return False + + +def _read(p, n=None): + try: + with open(p, encoding='utf-8', errors='replace') as fh: + return fh.read() if n is None else fh.read(n) + except OSError: + return '' + + +def _package_of_file(repo, rel): + """The directory (relative to repo, '' for the root) of the nearest package.json above a file.""" + d = os.path.dirname(rel) + while True: + if os.path.isfile(os.path.join(repo, d, 'package.json')): return d + if not d: return '' + d = os.path.dirname(d) + + +def _pkg_json(repo, d): + """The package.json in `d` as a dict, {} when unreadable, None when there is none.""" + t = _read(os.path.join(repo, d, 'package.json')) + if not t: return None + try: + pj = json.loads(t) + return pj if isinstance(pj, dict) else {} + except ValueError: + return {} + + +def _under(rel, base): + """rel taken relative to base (both from the same directory), or None when it is not under it.""" + base = posixpath.normpath(base or '.') + if base == '.': return rel + r = posixpath.relpath(rel, base) + return None if r.startswith('../') or r == '..' else r + + +def _pats_match(pats, rel, glob_rel=None): + """Whether any glob (matched on glob_rel) or regex (searched in '/'+rel) of `pats` takes this file.""" + for p in pats: + if isinstance(p, tuple): + try: + if re.search(p[1], '/' + rel): return True + except re.error: + return True + elif _globs_match((p,), rel if glob_rel is None else glob_rel): + return True + return False + + +def _pat_list(v): + """A pattern option as a list (a lone string or regex is one pattern): None when unset, _UNK when unreadable.""" + if v is None or v is _UNK or isinstance(v, list): return v + if isinstance(v, str) or isinstance(v, tuple) and v[:1] == ('re',): return [v] + return _UNK + + +def _pats_take(pats, rel, unknown, glob_rel=None): + """Whether a pattern list takes rel — or `unknown` when it does not say (a pattern this could not read).""" + if pats is _UNK: return unknown + unread = False + for p in pats: + if isinstance(p, str) or isinstance(p, tuple) and p[:1] == ('re',): + if _pats_match([p], rel, glob_rel): return True + else: + unread = True + return unknown if unread else False + + +def _member_dirs(entries, rel): + """The directories on rel's path that a list of workspace / project globs names (a `!glob` takes one out).""" + parts, out = rel.split('/')[:-1], [] + neg = [e[1:] for e in entries if isinstance(e, str) and e.startswith('!')] + for e in entries: + if not isinstance(e, str) or e.startswith('!'): continue + e = e.replace('/', '').rstrip('/') + e = e[2:] if e.startswith('./') else e + if re.search(r'\.(c|m)?[jt]s$|\.json$', e): e = posixpath.dirname(e) or '.' + for k in range(0, len(parts) + 1): + pre = '/'.join(parts[:k]) or '.' + if _globs_match((e,), pre) and not any(_globs_match((n.rstrip('/'),), pre) for n in neg) \ + and pre not in out: + out.append(pre) + return out + + +_VITEST_CONFS = tuple(f'vitest.config.{e}' for e in _CONF_EXT) +_VITE_CONFS = tuple(f'vite.config.{e}' for e in _CONF_EXT) +_VITEST_WS = tuple(f'vitest.workspace.{e}' for e in _CONF_EXT) + ('vitest.workspace.json',) + + +def _first_file(repo, d, names): + return next((c for c in names if os.path.isfile(os.path.join(repo, d, c))), None) + + +def _members(repo, d, root, pat, under, confs): + """(directory, config file or None), both from d, of each project a `projects` / workspace entry names on the + path `under` (rel from root): a directory glob with its own config (the first of `confs`), or a config glob.""" + pat = posixpath.normpath(pat[2:] if pat.startswith('./') else pat) + for m in _member_dirs([pat], under): + mdir = posixpath.normpath(posixpath.join(root, m)) + full = os.path.join(repo, d, mdir) + if re.search(r'\.(c|m)?[jt]s$|\.json$', pat): + names = sorted(os.listdir(full)) if os.path.isdir(full) else [] + conf = next((c for c in names if _globs_match((pat,), posixpath.normpath(posixpath.join(m, c)))), None) + if conf: yield mdir, posixpath.join(mdir, conf) + else: + yield mdir, _first_file(repo, d, [posixpath.join(mdir, c) for c in confs]) + + +# How vitest decides what it collects, from the directory it runs in: +# - its config is vitest.config.*, else vite.config.* (a `test` block there is vitest's), else none (defaults); +# `--config x` on the command line is another; +# - the config is what the file exports: through defineConfig, a config function, mergeConfig, a const, a +# spread, a shorthand property, a re-export or an import of a file beside it; +# - `test.projects` (or `test.workspace`, or a vitest.workspace.* file) makes each project collect on its own — +# an inline project from the root (with the root's options only when it says `extends: true`), a directory +# from its own vitest.config.* / vite.config.*, rooted there; +# - a project collects a file under `test.dir` (else `root`, else the directory vitest runs in) that its +# `test.include` (default **/*.{test,spec}.?(c|m)[jt]s?(x)) or `includeSource` takes and its `test.exclude` +# (default node_modules, dist, …) does not. `coverage.include` is coverage's, never the test include. +# What the config does not say literally is not read as the default: an include this cannot read collects the +# file, an exclude this cannot read excludes nothing. +def _vitest_takes(repo, d, cfg, ws, rel, root='.', depth=0): + cfg = cfg.ret if isinstance(cfg, _Fn) else cfg + if cfg is None: cfg = {} + if not isinstance(cfg, dict): return True + test = _get(cfg, 'test') + if test is None: test = {} + if not isinstance(test, dict): return True + r = _get(test, 'root') + if r is None: r = _get(cfg, 'root') + if r is not None and not isinstance(r, str): return True + root = posixpath.normpath(posixpath.join(root, r)) if r else root + projects = _get(test, 'projects') + if projects is None: projects = _get(test, 'workspace') + if isinstance(projects, str): + p = os.path.join(repo, d, root, projects) + projects = _js_config(p) if os.path.isfile(p) else _UNK + if projects is None and ws: + projects = _js_config(os.path.join(repo, d, ws)) + if projects is not None: + if depth > 2 or not isinstance(projects, list): return True + return any(_vitest_member_takes(repo, d, cfg, test, p, rel, root, depth) for p in projects) + dr = _get(test, 'dir') + if dr is not None and not isinstance(dr, str): return True + base = posixpath.normpath(posixpath.join(root, dr)) if dr else root + r = _under(rel, base) + if r is None: return False + exc = _pat_list(_get(test, 'exclude')) + if _pats_take(list(_VITEST_EXCLUDE) if exc is None else exc, r, False): return False + inc = _pat_list(_get(test, 'include')) + src = _pat_list(_get(test, 'includeSource')) + return bool(_pats_take(list(_VITEST_INCLUDE) if inc is None else inc, r, True) + or src is not None and _pats_take(src, r, False)) + + +def _vitest_member_takes(repo, d, cfg, test, p, rel, root, depth): + p = p.ret if isinstance(p, _Fn) else p + if isinstance(p, dict): + # an inline project: rooted where the root config is; it has the root's options only with `extends: true` + own = dict(p) + if _get(own, 'extends') is True: + base = {k: v for k, v in cfg.items() if k != 'test'} + base['test'] = {k: v for k, v in test.items() if k not in ('projects', 'workspace')} + own = _merge(base, {k: v for k, v in own.items() if k != 'extends'}) + return _vitest_takes(repo, d, own, None, rel, root, depth + 1) + if not isinstance(p, str): return True + under = _under(rel, root) + if p.startswith('!') or under is None: return False + # a directory is a project with its own vitest.config.* / vite.config.*, or vitest's defaults, rooted there + return any(_vitest_takes(repo, d, _js_config(os.path.join(repo, d, conf)) if conf else {}, None, rel, mdir, + depth + 1) + for mdir, conf in _members(repo, d, root, p, under, _VITEST_CONFS + _VITE_CONFS)) + + +def _vitest_collects(repo, d, conf, ws=None): + """How vitest run from `d` with config `conf` (a path from d, None for none) collects: a function of the path + from d, True when vitest collects it or its config does not say.""" + cfg = _js_config(os.path.join(repo, d, conf)) if conf else {} + return lambda rel: _vitest_takes(repo, d, cfg, ws, rel) + + +# How jest decides what it collects: its config (jest.config.*, the package.json `jest` key, or `--config x`) as +# the file exports it; `projects` makes each project collect on its own (a directory from its own config, rooted +# there); a file collects when it is under rootDir (default: the config's directory) and one of `roots`, no +# testPathIgnorePatterns regex matches it, and testRegex (when set) or testMatch takes it — testMatch in order, +# a `!glob` taking back what an earlier glob took. collectCoverageFrom and the coverage* keys are coverage's. +def _jest_globs(globs, rel): + kept, negatives, unread = None, 0, False + for g in globs: + if isinstance(g, tuple) and g[:1] == ('re',): + g = g[1] + if not isinstance(g, str): + unread = True; continue + g = g.replace('/', '') + if g.startswith('!'): + negatives += 1 + if _globs_match((g[1:],), rel): kept = False + elif _globs_match((g,), rel): + kept = True + if unread and kept is not False: return True + return kept is not False if negatives == len(globs) else bool(kept) + + +def _jest_takes(repo, d, cfg, base, rel, depth=0): + cfg = cfg.ret if isinstance(cfg, _Fn) else cfg + if cfg is None: cfg = {} + if not isinstance(cfg, dict): return True + rd = _get(cfg, 'rootDir') + if rd is not None and not isinstance(rd, str): return True + root = posixpath.normpath(posixpath.join(base, rd.replace('', '.'))) if rd else base + projects = _get(cfg, 'projects') + if projects is not None: + if depth > 2 or not isinstance(projects, list): return True + for p in projects: + p = p.ret if isinstance(p, _Fn) else p + if isinstance(p, dict): + if _jest_takes(repo, d, p, root, rel, depth + 1): return True + continue + if not isinstance(p, str): return True + under = _under(rel, root) + if under is None or p.startswith('!'): continue + for mdir, conf in _members(repo, d, root, p.replace('', '.'), under, _RUNNERS[1][1]): + mcfg = _js_config(os.path.join(repo, d, conf)) if conf else \ + (_pkg_json(repo, posixpath.join(d, mdir)) or {}).get('jest') or {} + if _jest_takes(repo, d, mcfg, mdir, rel, depth + 1): return True + return False + r = _under(rel, root) + if r is None: return False + roots = _get(cfg, 'roots') + if isinstance(roots, list) and roots and all(isinstance(x, str) for x in roots): + if not any(_under(r, x.replace('', '.')) is not None for x in roots): return False + ign = _get(cfg, 'testPathIgnorePatterns') + for p in (ign if isinstance(ign, list) else ['/node_modules/'] if ign is None else []): + try: + if isinstance(p, str) and re.search(p.replace('', ''), '/' + r): return False + except re.error: + pass + rx = _pat_list(_get(cfg, 'testRegex')) + if rx is _UNK: return True + if rx: + return bool(_pats_take([p if isinstance(p, tuple) or not isinstance(p, str) else ('re', p) for p in rx], + r, True)) + tm = _pat_list(_get(cfg, 'testMatch')) + if tm is _UNK: return True + return _jest_globs(list(_JEST_MATCH) if tm is None else tm, r) + + +def _jest_collects(repo, d, cfg, base): + """How jest run from `d` with the config value `cfg` (from the file at `base`, relative to d) collects.""" + return lambda rel: _jest_takes(repo, d, cfg, base, rel) + + +def _playwright_collects(repo, d, conf): + """playwright: testDir (default the config's directory), testMatch, testIgnore — top level or per project.""" + cfg = _js_config(os.path.join(repo, d, conf)) if conf else {} + cfg = cfg.ret if isinstance(cfg, _Fn) else cfg + + def one(o, top, rel): + get = lambda k: _get(o, k) if _get(o, k) is not None else _get(top, k) + td = get('testDir') + if td is not None and not isinstance(td, str): return True + r = _under(rel, posixpath.normpath(td) if td else '.') + if r is None: return False + ign = _pat_list(get('testIgnore')) + if ign and _pats_take(ign, r, False): return False + m = _pat_list(get('testMatch')) + return bool(_pats_take(list(_PW_MATCH) if m is None else m, r, True)) + + def collects(rel): + if not isinstance(cfg, dict): return True + projects = _get(cfg, 'projects') + if projects is _UNK: return True + if isinstance(projects, list) and projects: + return any(not isinstance(p, dict) or one(p, cfg, rel) for p in projects) + return one({}, cfg, rel) + return collects + + +def _script_words(v): + """Each shell command in a package script as its words; a quoted command (`glob -c "tsx --test"`) is split too.""" + out = [] + for seg in re.split(r'&&|\|\||;|\|', v): + words = [] + for q in re.findall(r'"([^"]*)"|\'([^\']*)\'|(\S+)', seg): + words += next((x for x in q if x), '').split() + out.append(words) + return out + + +def _node_test(words): + """(command, include globs) for a `node --test` / `tsx --test` command line, or None.""" + for i, w in enumerate(words): + if posixpath.basename(w) not in ('node', 'tsx'): continue + k, opts = i + 1, [w] + while k < len(words) and words[k].startswith('-'): + opts.append(words[k]) + if words[k] in ('--import', '-r', '--require', '--loader', '--experimental-loader') and k + 1 < len(words): + k += 1; opts.append(words[k]) + k += 1 + if '--test' not in opts: continue + globs = [] + for x in words[k:]: + if x.startswith('-'): continue + x = x[2:] if x.startswith('./') else x + if re.search(r'[*?{]|\.(c|m)?[jt]sx?$', x): globs.append(x) + elif '/' in x or re.fullmatch(r'[\w.-]+', x): globs.append(x.rstrip('/') + '/**/*.?(c|m)[jt]s') + cmd = 'npx tsx --test' if posixpath.basename(w) == 'tsx' else ' '.join(opts) + return cmd, tuple(globs) or _NODE_TEST + return None + + +_MOCHA_VALUED = {'--require', '-r', '--extension', '--config', '--reporter', '-R', '--timeout', '-t', '--ui', '-u', + '--file', '--ignore', '--exclude', '--spec', '--grep', '-g', '--fgrep', '-f', '--slow', '-s', + '--reporter-option', '--reporter-options', '-O', '--node-option', '-n', '--package', '--jobs', '-j', + '--retries', '--import', '--loader', '--watch-files', '--watch-ignore'} + + +def _mocha_rc(repo, d, conf): + """A .mocharc as a dict (spec, extension, recursive, ignore); YAML read for those keys only.""" + if not conf.endswith(('.yml', '.yaml')): + return _js_config(os.path.join(repo, d, conf)) + text, out = _read(os.path.join(repo, d, conf)), {} + for key in ('spec', 'extension', 'ignore', 'recursive'): + m = re.search(r'^' + key + r':[ \t]*(.*)$((?:\n[ \t]+-.*)*)', text, re.M) + if not m: continue + vals = [s.strip(' \'"-[]') for s in ([m.group(1)] if m.group(1).strip() else []) + m.group(2).split('\n')] + vals = [x.strip(' \'"') for v in vals for x in v.split(',') if x.strip(' \'"')] + out[key] = (vals[0] == 'true') if key == 'recursive' and vals else vals + return out + + +# How mocha decides what it runs: the specs on its command line (and --spec), else the config's `spec`, else the +# ./test directory; a directory spec takes the files in it (and below it with --recursive) with one of the +# --extension / `extension` extensions (default js, cjs, mjs); --ignore takes files back out. The command keeps +# the script's own flags (--require tsx/cjs …), since a TypeScript spec does not load without them. +def _mocha_run(repo, d, rc, args): + """(collects(path from d), the flags to run one file with) for mocha run with `args` over the config `rc`.""" + rc = rc if isinstance(rc, dict) else _UNK + specs, exts, ignore, flags, rec, i = [], [], [], [], False, 0 + while i < len(args): + a = args[i] + key, _, inline = a.partition('=') + val = inline if inline else (args[i + 1] if key in _MOCHA_VALUED and i + 1 < len(args) else None) + step = 1 if inline or key not in _MOCHA_VALUED else 2 + if key == '--config' and val and os.path.isfile(os.path.join(repo, d, val)): + rc = _mocha_rc(repo, d, val) + if key == '--spec' and val: specs.append(val) + elif key == '--extension' and val: exts += val.split(',') + elif key in ('--ignore', '--exclude') and val: ignore.append(val) + elif key == '--recursive': rec = True + if not a.startswith('-'): + specs.append(a) + elif key not in ('--spec', '--watch', '-w', '--watch-files', '--watch-ignore'): + flags += args[i:i + step] + i += step + + def opt(k): + v = _get(rc, k) if rc is not _UNK else _UNK + return v if v is _UNK or v is None or isinstance(v, (list, bool)) else [v] if isinstance(v, str) else _UNK + + rspec, rext, rign = opt('spec'), opt('extension'), opt('ignore') + if _UNK in (rspec, rext): return (lambda rel: True), flags + exts = exts or rext or ['js', 'cjs', 'mjs'] + rec = rec or opt('recursive') is True + globs = [] + for s in specs + (rspec or []) or ['test']: + if not isinstance(s, str): return (lambda rel: True), flags + s = s[2:] if s.startswith('./') else s + if re.search(r'[*?{]|\.(c|m)?[jt]sx?$', s): globs.append(s) + else: globs.append(s.rstrip('/') + ('/**/*.{' if rec else '/*.{') + ','.join(e.lstrip('.') for e in exts) + '}') + ignore += [g for g in (rign if isinstance(rign, list) else []) if isinstance(g, str)] + return (lambda rel: _pats_match(globs, rel) and not _pats_match(ignore, rel)), flags + + +@functools.lru_cache(maxsize=None) +def _runners_of(repo, d): + """[(runner, collects(path from d), command)] the directory `d` runs tests with, the configured ones first.""" + pj = _pkg_json(repo, d) or {} + deps = {**(pj.get('dependencies') or {}), **(pj.get('devDependencies') or {})} + scripts = [str(v) for v in (pj.get('scripts') or {}).values()] + said = ' '.join(scripts) + found = [] # (strength, order, name, collects, command) + + def conf_of(configs): + return next((c for c in configs if os.path.isfile(os.path.join(repo, d, c))), None) + + # vitest: vitest.config.* (else vite.config.* when the package uses vitest) and the workspace file, and each + # `vitest --config x` a script runs + uses_vitest = 'vitest' in deps or re.search(r'\bvitest\b', said) + vconf = conf_of(_VITEST_CONFS) or (uses_vitest and conf_of(_VITE_CONFS)) or None + ws = conf_of(_VITEST_WS) + if vconf or ws or uses_vitest: + cfg = _js_config(os.path.join(repo, d, vconf)) if vconf else None + strength = 3 if ws or vconf and (vconf.startswith('vitest.') or _get(cfg, 'test') is not None + or re.search(r'\btest\b', _read(os.path.join(repo, d, vconf)))) \ + else 2 if re.search(r'\bvitest\b', said) else 1 + found.append((strength, 0, 'vitest', _vitest_collects(repo, d, vconf, ws), 'npx vitest run')) + for s in scripts: + m = re.search(r'\bvitest\b[^&|;]*?(?:--config[= ]|-c\s+)(["\']?)([^\s"\'{]+)\1', s) + if m and os.path.isfile(os.path.join(repo, d, m.group(2))) and m.group(2) != vconf: + found.append((3, 0, 'vitest', _vitest_collects(repo, d, m.group(2)), + f'npx vitest run --config {shlex.quote(m.group(2))}')) + + # jest: its config file or package.json key, each `jest --config x` a script runs, react-scripts' own jest + jconf = conf_of(_RUNNERS[1][1]) + uses_jest = 'jest' in deps or re.search(r'\bjest\b', said) + if jconf or 'jest' in pj: + cfg = _js_config(os.path.join(repo, d, jconf)) if jconf else pj.get('jest') + found.append((3, 1, 'jest', _jest_collects(repo, d, cfg, '.'), 'npx jest')) + elif uses_jest: + found.append((2 if re.search(r'\bjest\b', said) else 1, 1, 'jest', _jest_collects(repo, d, {}, '.'), + 'npx jest')) + for s in scripts: + m = re.search(r'\bjest\b[^&|;]*?(?:--config[= ]|-c\s+)(["\']?)([^\s"\'{]+)\1', s) + if m and os.path.isfile(os.path.join(repo, d, m.group(2))): + p = m.group(2) + base = posixpath.dirname(p[2:] if p.startswith('./') else p) or '.' + found.append((3, 1, 'jest', _jest_collects(repo, d, _js_config(os.path.join(repo, d, p)), base), + f'npx jest --config {shlex.quote(p)}')) + if re.search(r'\b(react-scripts|craco|react-app-rewired)\s+test\b', s): + tool = re.search(r'\b(react-scripts|craco|react-app-rewired)\s+test\b', s).group(1) + found.append((2, 1, 'jest', lambda rel: _pats_match(_CRA_MATCH, rel), + f'npx {tool} test --watchAll=false')) + + # mocha: .mocharc / the package.json key, and each script that runs mocha with its own flags and specs + mconf = conf_of(_RUNNERS[2][1]) + if mconf or 'mocha' in pj or 'mocha' in deps or re.search(r'\bmocha\b', said): + rc = _mocha_rc(repo, d, mconf) if mconf else pj.get('mocha') or {} + strength = 3 if mconf or 'mocha' in pj else 2 if re.search(r'\bmocha\b', said) else 1 + runs = [ws[[posixpath.basename(w) for w in ws].index('mocha') + 1:] + for s in scripts for ws in _script_words(s) if 'mocha' in [posixpath.basename(w) for w in ws]] + for args in runs or [[]]: + collects, flags = _mocha_run(repo, d, rc, args) + found.append((strength, 2, 'mocha', collects, ' '.join(['npx mocha'] + [shlex.quote(f) for f in flags]))) + + # node's own runner: `node --test` / `tsx --test` in a script + for s in scripts: + for ws in _script_words(s): + nt = _node_test(ws) + if nt: + found.append((2, 3, 'node', (lambda rel, g=nt[1]: _pats_match(g, rel)), nt[0])) + + # playwright + pconf = conf_of(_RUNNERS[3][1]) + if pconf or '@playwright/test' in deps or 'playwright' in deps or re.search(r'\bplaywright\s+test\b', said): + found.append((3 if pconf else 2 if re.search(r'\bplaywright\s+test\b', said) else 1, 4, 'playwright', + _playwright_collects(repo, d, pconf), 'npx playwright test')) + found.sort(key=lambda x: (-x[0], x[1])) + return [(name, collects, cmd) for _, _, name, collects, cmd in found] + + +def _script_for(repo, pkg, prel): + """The command a package script, or the file's own usage line, gives for running this one file.""" + pj_text = _read(os.path.join(repo, pkg, 'package.json')) + try: + scripts = (json.loads(pj_text).get('scripts') or {}) if pj_text else {} + except ValueError: + scripts = {} + for k, v in scripts.items(): + # a script that RUNS the file, not one that lints, formats or type-checks it + if (re.search(r'(^|[\s/"\'])' + re.escape(prel) + r'($|[\s"\'])', str(v)) + and re.search(r'\b(tsx|ts-node(-esm)?|node|bun|deno|jest|vitest|mocha|playwright|ava|tap|uvu|cypress)\b', + str(v))): + return f"npm run {shlex.quote(k)}" + for k, v in scripts.items(): + # a test script whose own glob takes the file (`tape-runner test/test-*.js`) runs it, with the rest + if re.search(r'test|spec|e2e', k) and any(re.search(r'[*?]', w) and _globs_match((w[2:] if w.startswith('./') + else w,), prel) + for ws in _script_words(str(v)) for w in ws): + return f"npm run {shlex.quote(k)}" + runner = r'(?:(?:npx|pnpm(?: exec)?|yarn|bunx)\s+)?(?:tsx|ts-node(?:-esm)?|node(?:\s+--[\w-]+(?:=\S+)?)*|bun(?: run)?|deno run(?:\s+-\S+)*)' + lead = r'(?:^|[\s*/#`$])(' + # the file's own usage line first, then the package's README: where a script suite says how it is run + head = _read(os.path.join(repo, pkg, prel), 4000) + for text, exact_only in ((head, False), (_read(os.path.join(repo, pkg, 'README.md')), True)): + m = re.search(lead + runner + r')\s+(?:\./)?' + re.escape(prel) + r'(?=$|[\s`\'"])', text, re.M) + if m: + return f"{m.group(1)} {prel}" + if exact_only: continue + # a header written from another directory (`npx tsx x-tests.ts` from beside it) names the file by basename + base = os.path.basename(prel) + m = re.search(lead + runner + r')\s+(\S*' + re.escape(base) + r')(?=$|[\s`\'"])', text, re.M) + if m and os.path.isfile(os.path.join(repo, pkg, m.group(2))): + return f"{m.group(1)} {m.group(2)}" return None +def js_plan(repo, files): + """(commands, files no runner collects): each file with the runner its own package would run it with.""" + by_runner = collections.OrderedDict() + singles, unrun = [], [] + repo = os.path.realpath(repo) + for f in sorted(files)[:40]: + # the nearest directory whose runner collects the file runs it: a member's own config first, then the + # root's (a workspace member with no runner of its own is run by the root's, with paths from the root) + hit, any_pkg, d = None, False, os.path.dirname(f) + while hit is None: + any_pkg = any_pkg or _pkg_json(repo, d) is not None + drel = f[len(d) + 1:] if d else f + hit = next(((d, drel, c) for _, collects, c in _runners_of(repo, d) if collects(drel)), None) + if not d: break + d = os.path.dirname(d) + if hit is None and not any_pkg and _globs_match(_VITEST_INCLUDE, f): + # no package anywhere above it: a file named the way vitest and jest both collect keeps the command + # this always printed; any other file has no runner to be handed to + hit = ('', f, _RUNNERS[0][3]) + if hit: + by_runner.setdefault((hit[0], hit[2]), []).append(hit[1]); continue + pkg = _package_of_file(repo, f) + cmd = _script_for(repo, pkg, f[len(pkg) + 1:] if pkg else f) + if cmd: + singles.append((pkg, cmd)) + else: + unrun.append(f) + def cd(pkg, cmd): + return f"(cd {shlex.quote(pkg)} && {cmd})" if pkg else cmd + cmds = [cd(pkg, f"{c} " + " ".join(shlex.quote(x) for x in fs)) for (pkg, c), fs in by_runner.items()] + seen = set() + for pkg, c in singles: + if (pkg, c) not in seen: + seen.add((pkg, c)); cmds.append(cd(pkg, c)) + return cmds, unrun + + +def print_unrun(lang, repo, files): + """Say which selected files no runner collects, so a reader does not take the command as the whole run.""" + if lang not in ('typescript', 'javascript'): return + unrun = js_plan(repo, files)[1] + if unrun: + print(f"\n {len(unrun)} selected file(s) are collected by no test runner their package configures (no" + f" include glob matches, and no package script or usage line in the file runs it) — no command" + f" is given for them; read how each is run before trusting it ran:") + for f in unrun[:6]: + print(f" {f}") + + # The call graph can be silent for a reason that has nothing to do with coverage. A function placed in an # ARRAY or a table and invoked by iteration -- a transform pipeline, a middleware chain, a plugin list, a @@ -152,6 +1393,12 @@ def stems_of(rel): return {x for x in out if len(x) > 2} +def family(rel): + """the language a file's extension belongs to, for the name tier (TypeScript and JavaScript read each other)""" + e = os.path.splitext(rel)[1] + return {'.py': 'py', '.pyi': 'py', '.java': 'java', '.cs': 'cs'}.get(e, 'js' if e in ('.ts', '.tsx', '.js', '.mjs', '.cjs', '.jsx') else ('py' if not e else e)) + + def package_tier(repo, changed_files, limit=6000): """Two tiers of proximity, for when the call graph named nothing. Neither is a call-graph answer. @@ -169,6 +1416,7 @@ def package_tier(repo, changed_files, limit=6000): for f in changed_files: if f: want_stems |= stems_of(f) if not pkgs and not want_stems: return {}, {}, pkgs + fams = {family(f) for f in changed_files if f} by_pkg = collections.defaultdict(list) by_name = [] seen = 0 @@ -177,15 +1425,79 @@ def package_tier(repo, changed_files, limit=6000): for f in fs: if not f.endswith(('.ts', '.tsx', '.js', '.mjs', '.java', '.py', '.cs')): continue # a list that omits an extension makes both tiers below silently inert for that language (#1083's shape) rel = os.path.relpath(os.path.join(root, f), repo).replace(os.sep, '/') - if not TESTY.search(rel): continue + if not TESTY.search(rel) or rel in changed_files: continue # a changed test file is run as edited, not found here seen += 1 if seen > limit: return dict(by_pkg), {'files': sorted(by_name)}, pkgs - if stems_of(rel) & want_stems: by_name.append(rel) + # a test named after a changed file in ANOTHER language is not its test (server.js for server.py) + if stems_of(rel) & want_stems and family(rel) in fams: by_name.append(rel) p = package_of(rel) if p in pkgs: by_pkg[p].append(rel) return dict(by_pkg), ({'files': sorted(by_name)} if by_name else {}), pkgs +CODE = ('.ts', '.tsx', '.js', '.mjs', '.java', '.py', '.cs') +PROGRAM = re.compile(r'''^if\s+__name__\s*==\s*['"]__main__['"]|^#!.*\bpython|\bstatic\s+(?:async\s+)?(?:void|int|Task(?:)?)\s+[Mm]ain\s*\(''', re.M) + + +def test_texts(repo, limit=6000, cap=200_000): + """(test file, its text) for every test file under the repository, the way package_tier finds them""" + seen = 0 + for root, dirs, fs in os.walk(repo): + dirs[:] = [d for d in dirs if d not in ('node_modules', '.git', 'dist', 'build', 'target', '.axiomcode', 'bin', 'obj')] + for f in fs: + if not f.endswith(CODE): continue + rel = os.path.relpath(os.path.join(root, f), repo).replace(os.sep, '/') + if not TESTY.search(rel): continue + seen += 1 + if seen > limit: return + try: yield rel, open(os.path.join(repo, rel), errors='replace').read(cap) + except OSError: continue + + +def needles(rel, program=False): + """what a test that loads `rel` without calling it writes in its text, most specific first: the path, its tails (at + least two parts), then the file name, then (data only) the name of each directory holding it. A test runner that + globs `cases/*/case.json` names the file name and the directory, never the path.""" + parts = rel.split('/') + out = ['/'.join(parts[i:]) for i in range(len(parts) - 1)] + base = parts[-1]; stem = os.path.splitext(base)[0] + out.append(base) + if program: + out.append(stem) # `python -m pkg.cli`, [sys.executable, 'tool'] + if rel.endswith('.py'): out.append('.'.join(os.path.splitext(rel)[0].split('/')[-2:])) + elif TESTY.search(os.path.dirname(rel) + '/'): + # data under a test tree (fixtures, cases): a runner that globs it names its directory, not the file. Anywhere + # else a directory name is every file in it, and a doc under `skills/axiomcode/` matched every test naming the tool + out += [d for d in reversed(parts[:-1]) if len(d) > 2 and not TESTY.fullmatch(d)] + return [n for n in dict.fromkeys(out) if len(n) > 3] + + +def loaders(repo, wanted, edited=()): + """{changed file: (the needle that matched, [test files naming it])} for files no call edge can reach a test from: + changed files outside every indexed language, and code that also runs as a program. The most specific needle any + test names wins, and only the tests naming it are kept, so a bare directory name is used only when nothing names the + file itself. A text match, not a call: printed as its own tier.""" + if not wanted: return {} + want = {f: needles(f, prog) for f, prog in wanted.items()} + # a path or a file name stands alone (not inside a longer name); a bare word (a directory, a program's name) only + # where the text quotes it or puts it in a path, or `cases` would match every sentence about test cases + pats = {n: re.compile((r'(? needle index -> [tests] + for t, text in test_texts(repo): + if t in wanted or t in edited: continue + for f, ns in want.items(): + for k, n in enumerate(ns): + if n in text and re.search(pats[n], text): + hits[f].setdefault(k, []).append(t); break + out = {} + for f, by in hits.items(): + if by: + k = min(by); out[f] = (want[f][k], sorted(by[k])) + return out + + def read_answer(out): """impact's JSON document, even when a diagnostic was printed in front of it. @@ -213,6 +1525,9 @@ def read_answer(out): return None, None +VALUED = ('--range', '--in', '--limit') + + def main(argv): as_json = '--json' in argv why = '--why' in argv @@ -234,56 +1549,110 @@ def main(argv): if '--range' not in passthru and (not given or os.path.realpath(given) == os.path.realpath(ax_fresh.graph_dir(repo_))): bg = ax_fresh.baseline_graph(repo_) if bg: os.environ['AXIOMCODE_GRAPH'] = bg - rc, out, err = run('axiomcode-changed', passthru + ['--json']) - if rc != 0: - sys.stderr.write(err or out); return rc + # NAMED FILES: `test-impact …` is "the tests of these files". A named file with an edit is read as that edit; one + # with none (or any, on a copy without git) counts whole, which `changed` does when told --whole + pos, skip = [], False + for x in passthru: + if skip: skip = False; continue + if x in VALUED: skip = True; continue + if not x.startswith('-') and not os.path.isdir(x): pos.append(x) + changed_args = [] # --in narrows the TESTS, not the edit: changed never sees it + for k, x in enumerate(passthru): + if x == '--in' or (k and passthru[k - 1] == '--in'): continue + changed_args.append(x) + rc, out, err = run('axiomcode-changed', changed_args + (['--whole'] if pos else []) + ['--json', '--suggest']) try: changed = json.loads(out) except Exception: - sys.stderr.write(out); return 2 + changed = None + if changed and changed.get('refused'): + print(changed['refused'].replace('`axiomcode changed …` or ', '')) + return 2 + if rc != 0 or changed is None: + sys.stderr.write(err or out); return rc or 2 entries = changed.get('changed') or [] targets, by_target = [], {} for e in entries: t = e.get('target') - if not t: continue + if not t or (e.get('kind') == 'added' and not e.get('referenced')): continue if t not in by_target: by_target[t] = e; targets.append(t) - if not targets: - print("no changed declaration the graph can name, so no test can be selected from it." - + ("\n " + "; ".join(changed.get('notes') or []) if changed.get('notes') else "")) + repo = next((a for a in passthru if os.path.isdir(a)), '.') + heads = [f"note: {changed['range_note']}"] if changed.get('range_note') else [] + # A CHANGED TEST FILE IS ITSELF A TEST TO RUN: a new test file, or an edited one, has no test reaching it — it is one + edited = sorted({e['file'] for e in entries if e.get('file', '').endswith(CODE) and (e.get('is_test') or TESTY.search(e['file']))}) + # WHAT NO CALL EDGE REACHES: files outside every indexed language (fixtures, case data), and changed code that is + # also started as a program (a test that runs it as a subprocess has no edge to it). Looked for by name in the tests + outside = changed.get('outside_index') or [] + progs = {} + for f in sorted({e['file'] for e in entries if e.get('file')} - set(edited)): + try: txt = open(os.path.join(repo, f), errors='replace').read(400_000) + except OSError: continue + if PROGRAM.search(txt): progs[f] = True + loaded = loaders(os.path.realpath(repo), dict({f: False for f in outside}, **progs), edited) if (outside or progs) else {} + loaded_files = sorted({t for _, ts in loaded.values() for t in ts}) + + if not targets and not edited and not loaded and not outside: + for h in heads: print(h) + print("no changed declaration the graph can name, so no test can be selected from it.") + for n in (changed.get('notes') or [])[:8]: print(f" {n}") + if len(changed.get('notes') or []) > 8: print(f" … +{len(changed['notes']) - 8} more note(s)") + if changed.get('suggest_note'): print(f"next: {changed['suggest_note']}".replace('`changed --range', '`test-impact --range')) return 0 # `impact` is asked once per changed declaration rather than once for all of them: a single call # unions the answers and loses which change pulled in which test, and that is exactly what a reader - # needs to decide whether a selection is believable. - repo = next((a for a in passthru if os.path.isdir(a)), '.') + # needs to decide whether a selection is believable. Past a handful (a whole named file) the processes cost more than + # the attribution is worth: one call for all of them, each test credited to the declaration its chain ends at. scope = [] if '--in' in passthru: i = passthru.index('--in'); scope = ['--in', passthru[i + 1]] tests = {} # test id -> record + stub_only, stub_by = {}, collections.defaultdict(set) # tests that only stub a body-edited declaration + fw_entries, fw_greps = {}, [] # entry points a framework enters: their tests are not counted unreadable, notes = [], [] pulled = collections.defaultdict(set) unseen = [] - for t in targets: - rc, o, e = run('axiomcode-impact', [t, repo, '--tests', '--json'] + scope) + batches = [[t] for t in targets] if len(targets) <= 8 else [targets[k:k + 60] for k in range(0, len(targets), 60)] + disp = {by_target[t].get('symbol'): t for t in targets} + for batch in batches: + rc, o, e = run('axiomcode-impact', batch + [repo, '--tests', '--json'] + scope) if rc != 0: - unseen.append(t); continue + unseen.extend(batch); continue d, note = read_answer(o) if d is None: - unreadable.append((t, (o or e or '').strip().splitlines()[:1])) + unreadable.extend((t, (o or e or '').strip().splitlines()[:1]) for t in batch) continue if note: - notes.append((t, note)) + notes.append((batch[0] if len(batch) == 1 else f"{len(batch)} declarations", note)) for rec in d.get('tests') or []: tests.setdefault(rec['id'], rec) - pulled[rec['id']].add(t) + end = (rec.get('chain') or [None])[-1] + pulled[rec['id']].add(batch[0] if len(batch) == 1 else disp.get(end, end or batch[0])) for rec in d.get('inherited_tests') or []: rid = rec.get('id') if rid and rid not in tests: tests[rid] = dict(rec, hops=None, certainty='inherited') - pulled[rid].add(t) + pulled[rid].add(batch[0]) + # a test that only STUBS the declaration on a mock (`when(repo.find(1))`, `mock.Setup(r => r.Find(1))`) runs + # none of it: impact does not count it, and a body edit cannot fail it. A signature edit or a removal can (the + # stub no longer compiles, or stubs a method nothing calls), so there it is selected, labelled as what it is. + for rec in d.get('framework_entries') or []: + fw_entries.setdefault(rec.get('id') or rec.get('display'), rec) + if d.get('framework_grep') and d['framework_grep'] not in fw_greps: fw_greps.append(d['framework_grep']) + # a batch of several declarations breaks a stub when any of them is a signature change or a removal + breaks = any((by_target[t].get('kind') or '') in STUB_BREAKING for t in batch) + bt = batch[0] if len(batch) == 1 else f"{len(batch)} declarations" + for rec in d.get('stub_tests') or []: + rid = rec.get('id') + if not rid: continue + if breaks: + if rid not in tests: tests[rid] = dict(rec, hops=None) + pulled[rid].add(bt) + else: + stub_only.setdefault(rid, rec); stub_by[rid].add(bt) files = sorted({r.get('at', '').split(':')[0] for r in tests.values() if r.get('at')}) # A selected test carrying no location drops out of `files`, and `files` is what the command PRINTS and @@ -295,34 +1664,55 @@ def main(argv): # only when the graph found nothing, or found nothing in the changed files' own package: a text tier # offered alongside a good call-graph answer is noise, offered instead of silence it is the difference # between "run these first" and "there is nothing to run" - changed_files = [e.get('file') for e in entries if e.get('file')] - pkg_hits, name_hits, pkgs = package_tier(repo, changed_files) if not files else ({}, {}, set()) + changed_files = [e.get('file') for e in entries if e.get('file') and e.get('file').endswith(CODE)] + pkg_hits, name_hits, pkgs = package_tier(repo, changed_files) if not files and changed_files else ({}, {}, set()) classes = sorted({r.get('owner') for r in tests.values() if r.get('owner')}) # A row the answer named that carries no owner still has to reach the reader. Dropping it for being # malformed is how a selection gets quietly shorter, which is the one failure this command exists to # prevent — the same shape as a dropped edge or an answer that could not be parsed. ownerless = [r for r in tests.values() if not r.get('owner') and r.get('at')] - lang = (changed.get('lang') or '').lower() or _guess_lang(files) + run_files = sorted(set(files) | set(edited)) + lang = (changed.get('lang') or '').lower() or _guess_lang(run_files or changed_files) + # the graph `impact` answered from, asked again for which selected classes are abstract (command_for) + db = os.path.join(os.environ.get('AXIOMCODE_GRAPH') or ax_fresh.graph_dir(repo_), 'out', 'graph.sqlite') + # the classes the command names: every owner, and a test that has none by its file (a class that inherits a + # reached test is one), as the note below promises; each abstract one stands for the classes that extend it + run_classes = classes + sorted({os.path.splitext(os.path.basename(r['at'].split(':')[0]))[0] for r in ownerless}) \ + if classes and lang in ('java', 'csharp') else classes + replaced = concrete_test_classes(db, run_classes)[1] if lang in ('java', 'csharp') else {} + cmd_all = command_for(lang, run_files, run_classes + ([os.path.splitext(os.path.basename(f))[0] for f in edited] if classes else []), db, repo) if as_json: print(json.dumps({'changed': [{'symbol': by_target[t]['symbol'], 'kind': by_target[t]['kind'], 'target': t} for t in targets], 'test_files': files, 'test_classes': classes, 'tests': [dict(r, pulled_in_by=sorted(pulled[i])) for i, r in tests.items()], - 'command': command_for(lang, files, classes), + 'edited_test_files': edited, + 'command': cmd_all, + 'abstract_replaced': replaced, + 'not_collected': js_plan(repo, run_files)[1] if lang in ('typescript', 'javascript') else [], 'not_reached_from': unseen, + 'framework_entries': list(fw_entries.values()), 'framework_grep': fw_greps, + 'stub_only_not_selected': [dict(r, stubs=sorted(stub_by[i])) for i, r in stub_only.items() if i not in tests], 'unreadable_answers': [t for t, _ in unreadable], 'same_package_only': pkg_hits, 'same_name_only': name_hits.get('files') or [], + 'outside_index': outside, + 'named_in_test_text': {f: {'needle': n, 'tests': ts} for f, (n, ts) in loaded.items()}, + 'range_note': changed.get('range_note'), 'bound': 'a lower bound: a test reached only by reflection, a service loader, a ' - 'framework instantiating by name, or a case built at runtime is not here'}, + 'framework instantiating by name, a subprocess, or a case built at runtime is not here'}, indent=1)) return 0 + for h in heads: print(h) print(f"changed declarations ({len(targets)}): " + ", ".join( f"{by_target[t]['symbol']} [{by_target[t]['kind']}]" for t in targets[:8]) + (f" … +{len(targets)-8}" if len(targets) > 8 else "")) - if not tests: + if edited: + print(f"\nedited test file(s) ({len(edited)}), run as they are:") + for f in edited[:limit]: print(f" {f}") + if not tests and targets: print("\nno test in the graph reaches any of them.") print(" that is not the same as 'no test covers this': a function invoked through an array, a") print(" table or a plugin list has no resolved caller, so a test that drives it looks unrelated.") @@ -335,9 +1725,11 @@ def main(argv): print(f" {f}") # `lang` is guessed from the files the graph named, and here it named none — so guess it from # the tier's own files, or the one runnable sentence this block exists to produce is empty. - cmd = command_for(lang or _guess_lang(nf), nf[:limit], []) + nlang = lang or _guess_lang(nf) + cmd = command_for(nlang, nf[:limit], [], db, repo) if cmd: - print(f"\n {cmd}") + print("\n " + cmd.replace("\n", "\n ")) + print_unrun(nlang, repo, nf[:limit]) if pkg_hits: n = sum(len(v) for v in pkg_hits.values()) print(f"\n BY PACKAGE ONLY — {n} test file(s) live under the same package as a changed file") @@ -346,7 +1738,10 @@ def main(argv): for p, fs in sorted(pkg_hits.items()): for f in sorted(fs)[:limit]: print(f" {f}") - else: + if edited and cmd_all: + print("\n " + cmd_all.replace("\n", "\n ")) + print_unrun(lang, repo, run_files) + elif tests: print(f"\ntests to run: {len(tests)} test(s) in {len(files)} file(s)") for f in files[:limit]: n = sum(1 for r in tests.values() if r.get('at', '').startswith(f + ':')) @@ -358,9 +1753,55 @@ def main(argv): for i, r in sorted(tests.items(), key=lambda kv: (kv[1].get('hops') or 99, kv[1].get('display', '')))[:limit]: chain = " -> ".join(r.get('chain') or []) or "reaches it directly" print(f" {r.get('display')} [{r.get('certainty')}] {chain}") - cmd = command_for(lang, files, classes) - if cmd: - print(f"\n {cmd}") + if replaced: + # one line however many: an abstract class runs nothing by name, so the command names what extends it + each = [f"{a} -> " + (', '.join(subs[:3]) + (f" +{len(subs) - 3}" if len(subs) > 3 else '') if subs else 'none in the graph') + for a, subs in sorted(replaced.items())] + print(f"\n {len(each)} abstract test class(es) run nothing by name, so the command runs the classes that extend them: " + + '; '.join(each[:4]) + (f"; +{len(each) - 4} more (--json: abstract_replaced)" if len(each) > 4 else '')) + if cmd_all: + print("\n " + cmd_all.replace("\n", "\n ")) + print_unrun(lang, repo, run_files) + elif edited and cmd_all: + print("\n " + cmd_all.replace("\n", "\n ")) + print_unrun(lang, repo, run_files) + if outside or loaded: + # NOT A CALL: a file no graph reads, or a program a test starts by name. The tests that name it are the lead + if outside: + print(f"\n{len(outside)} changed file(s) outside every indexed language (no declaration to follow): " + + ", ".join(outside[:8]) + (f" … +{len(outside) - 8}" if len(outside) > 8 else "")) + if loaded: + print(f" [text] {len(loaded_files)} test file(s) name them in their text — they load or start them; run these too:") + for f, (n, ts) in sorted(loaded.items()): + print(f" {f} named as '{n}' by: " + ", ".join(ts[:6]) + (f" … +{len(ts) - 6}" if len(ts) > 6 else "")) + both = sorted(set(run_files) | set(loaded_files)) + cmd = command_for(_guess_lang(both) or lang, both[:limit], run_classes + [os.path.splitext(os.path.basename(f))[0] for f in loaded_files] if classes else [], db, repo) + if cmd: print("\n with the tests above: " + cmd.replace("\n", "\n ")) + missed = [f for f in outside if f not in loaded] + if missed: + print(f" no test names {', '.join(missed[:6])}{' …' if len(missed) > 6 else ''} in its text: which suite reads " + "it is not visible here, so 'no test' is NOT an answer for it — grep the test tree for its directory") + prog_missed = [f for f in progs if f not in loaded] + if prog_missed: + print(f"\n {', '.join(prog_missed[:4])} also run(s) as a program (a __main__ / main entry): a test that starts it " + "as a subprocess, or drives it from case data, is not a call edge and no test names it by file — the " + "selection is a lower bound for it") + elif progs: + print(f"\n {', '.join(list(progs)[:4])} also run(s) as a program (a __main__ / main entry): a test that starts it " + "as a subprocess, or drives it from case data, is not a call edge and no test names it by file — the " + "selection is a lower bound for it") + if fw_entries and not tests: + fe = list(fw_entries.values()) + print(f"\n NOT COUNTED: {len(fe)} entry point(s) of the change are entered by a framework (" + + ', '.join(f"{r.get('display')} {r.get('signal') or ''}".strip() for r in fe[:3]) + (f" … +{len(fe) - 3}" if len(fe) > 3 else '') + + ") — a test that drives them through it (a request, an event, a mediator send) is not selected unless a [by key]" + " route joins it, so the selection is a lower bound; to find those tests: " + ' ; '.join(fw_greps[:2])) + so = [r for i, r in stub_only.items() if i not in tests] + if so: + sf = collections.Counter((r.get('at') or '').split(':')[0] for r in so) + print(f"\n not selected: {len(so)} test(s) only stub a changed declaration on a mock (when/verify, Setup/Verify," + " Received/Returns) and run none of it, so a body edit cannot fail them; a signature change or a removal" + " selects them: " + ', '.join(f"{f} ({n})" for f, n in sf.most_common(4)) + (f" … +{len(sf) - 4}" if len(sf) > 4 else '')) if unlocated: print(f"\n {len(unlocated)} selected test(s) carry no location, so they are named here rather than" f" in the list above and cannot appear in the command:") @@ -385,17 +1826,22 @@ def main(argv): print(f" {t} {(first or [''])[0][:100]}") print(" the selection above is incomplete — do not act on it as though it were the whole answer.") print("\nbound: this is a LOWER bound. A test that reaches the change only through something the graph " - "does not encode — reflection, a service loader, a framework that instantiates by name, a case " + "does not encode — reflection, a service loader, a framework that instantiates by name, a subprocess, a case " "built at runtime — does not appear here. Run these first; do not skip the rest on this alone.") return 0 +# the change kinds (axiomcode changed) that break a test which only stubs the declaration: its stub is written +# against the name and the parameters +STUB_BREAKING = {'signature', 'removed'} + + def _guess_lang(files): ext = collections.Counter(os.path.splitext(f)[1] for f in files) if not ext: return '' top = ext.most_common(1)[0][0] return {'.java': 'java', '.py': 'python', '.ts': 'typescript', '.tsx': 'typescript', - '.js': 'javascript', '.mjs': 'javascript'}.get(top, '') + '.js': 'javascript', '.mjs': 'javascript', '.cs': 'csharp'}.get(top, '') if __name__ == '__main__': diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index d7f708168..04014cdbe 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -18,6 +18,11 @@ // qualifier(f, l, n, Q) on line f:l the name n is written qualified as Q.n // typeref(c, n, ctx, f, l) a type reference to n in context ctx (field type, method param …) inside c, at f:l // typeref_file(f, n, ctx) the same when the parser gives no line (Java): the file only +// type_alias(t) t is a TypeScript type alias: a reference on its own lines is held by it, not the module +// jsx_props(tag, n) an IntrinsicElements member: the attributes of are checked against type n +// jsx_tag(c, tag, f, l) an intrinsic element rendered inside c, at f:l +// discriminant(t, k, v) type t declares property k typed as the one string literal v +// keyed_literal(c, k, v, f, l) an object literal inside c writes the property k: 'v', at f:l // type_in_file(T, f) type T is declared in file f // literal(c, v, f, l) a string literal v inside c // field(F, T, n, f, l) field F named n declared in type T at f:l @@ -39,6 +44,7 @@ .decl edge(a:symbol, b:symbol, t:symbol) .input edge .decl named(n:symbol, m:symbol) .input named .decl calls(c:symbol, m:symbol, tier:symbol, f:symbol, l:number) .input calls +.decl handoff_at(c:symbol, m:symbol, f:symbol, l:number) .input handoff_at .decl unresolved(c:symbol, n:symbol, k:symbol, f:symbol, l:number) .input unresolved .decl named_site(c:symbol, n:symbol, k:symbol, f:symbol, l:number) .input named_site .decl member(t:symbol, s:symbol, n:symbol, k:symbol) .input member @@ -52,6 +58,11 @@ .decl qualifier(f:symbol, l:number, n:symbol, q:symbol) .input qualifier .decl typeref(c:symbol, n:symbol, ctx:symbol, f:symbol, l:number) .input typeref .decl typeref_file(f:symbol, n:symbol, ctx:symbol) .input typeref_file +.decl type_alias(t:symbol) .input type_alias +.decl jsx_props(tag:symbol, n:symbol) .input jsx_props +.decl jsx_tag(c:symbol, tag:symbol, f:symbol, l:number) .input jsx_tag +.decl discriminant(t:symbol, k:symbol, v:symbol) .input discriminant +.decl keyed_literal(c:symbol, k:symbol, v:symbol, f:symbol, l:number) .input keyed_literal .decl type_in_file(t:symbol, f:symbol) .input type_in_file .decl literal(c:symbol, v:symbol, f:symbol, l:number) .input literal .decl dec_literal(c:symbol, d:symbol, v:symbol, f:symbol, l:number) .input dec_literal @@ -89,6 +100,14 @@ .decl fixture_scope(fx:symbol, n:symbol, p:symbol) .input fixture_scope .decl file_scope(f:symbol, p:symbol) .input file_scope .decl autouse_fixture(fx:symbol) .input autouse_fixture +// injects(m, fx) the language engine resolved m's request to fixture fx (Python: the runner's own +// visibility, the decorator's `name=`, star imports and `pytest_plugins`) +// injected_fixture(fx) fx is injected by name and runs only for what requests it: not every test of its file +.decl injects(m:symbol, fx:symbol) .input injects +.decl injected_fixture(fx:symbol) .input injected_fixture +// usefixtures_scope(p, n) a usefixtures marker no declaration carries: a module's `pytestmark` (p is that +// file) or the ini option (p is the directory holding the ini file) +.decl usefixtures_scope(p:symbol, n:symbol) .input usefixtures_scope .decl kind(s:symbol, k:symbol) .input kind .decl decl_file(s:symbol, f:symbol) .input decl_file .decl lex_parent(inner:symbol, outer:symbol) .input lex_parent @@ -104,6 +123,8 @@ // bean(name, t, kind) a type registered as a bean (a stereotype, an @Bean factory) // bean_factory(m, t) the METHOD an @Bean factory is declared on, and the type it produces // injected(t, into, kind) a value of type t is handed to `into` by the container, not by a call the graph can see +// registers(t, by, kind) the bean of type t exists because an annotation on the type `by` names it +// (@EnableConfigurationProperties({T.class}), a mapper or properties package scan) .decl config_site(k:symbol, c:symbol, d:symbol, f:symbol, l:number) .input config_site // nonsource(n, f, l) the name n written in a file the index does NOT read as source // qual_name(s, n) a qualified spelling of symbol s, for a binding written in full @@ -114,6 +135,7 @@ .decl bean(n:symbol, t:symbol, kind:symbol) .input bean .decl bean_factory(m:symbol, t:symbol) .input bean_factory .decl injected(t:symbol, into:symbol, kind:symbol) .input injected +.decl registers(t:symbol, by:symbol, kind:symbol) .input registers // ── vocabulary ───────────────────────────────────────────────────────────────────────────────────────────────── .decl local_kind(k:symbol) @@ -291,6 +313,14 @@ direct(q, c, "uses", cat("receives it by dependency injection (", kind, ") — t // which names the METHOD. Keying on `bean(_, owner(m), _)` instead only says the method's OWNER is a bean, so // it fired for every method of every injected @Service and never for a factory whose @Configuration is not injected. direct(q, c, "uses", cat("is injected with the bean this defines (", kind, ")"), "resolved", "", 0) :- target(q, "method", m, _), bean_factory(m, bt), injected(bt, c, kind), c != m. +// a TYPE that another class registers as a bean: that class, which a rename, a move or a delete of the type breaks. +// Without it the registering annotation was one [by name] row among the text matches, and a type registered only +// that way read as a class nothing depends on. +direct(q, c, "uses", cat("registers it as a bean (", kind, "): the container creates it, no call site"), "resolved", "", 0) :- target(q, "type", t, _), registers(t, c, kind), !inside_target(q, c). +// a TYPE that DEFINES beans (a @Configuration class, an auto-configuration): whoever is injected with a bean one of its +// @Bean methods defines. The factory methods are inside the target, so the method rule above never runs for them +// and a change to the class said nothing resolved depended on it. +direct(q, c, "uses", cat("is injected with a bean this class defines (", kind, ")"), "resolved", "", 0) :- target(q, "type", _, _), inside_target(q, m), bean_factory(m, bt), injected(bt, c, kind), !inside_target(q, c). // a METHOD: its callers, and the unresolved sites written with its name // A RESOLVED call at a route registration says what the code does and not what it means: on JavaScript the engine @@ -312,33 +342,58 @@ route_site(f, l) :- registration(_, f, l, "route", _, _). // fast path reads the same one, so the rules and the fast path cannot disagree about a tier — which // is how the two copies of the binary test drifted apart from the rest of the vocabulary to begin with. .decl cert_tier(tier:symbol, cert:symbol, why:symbol) .input cert_tier -direct(q, c, "uses", why, cert, f, l) :- target(q, "method", m, _), calls(c, m, t, f, l), t != "multi_inferred", cert_tier(t, cert, why), !bean_call(q, c, m), !route_site(f, l). +direct(q, c, "uses", why, cert, f, l) :- target(q, "method", m, _), calls(c, m, t, f, l), t != "multi_inferred", t != "stub", cert_tier(t, cert, why), !bean_call(q, c, m), !route_site(f, l), !via_site(c, m, f, l). +// A CALL WRITTEN AGAINST A BASE THIS DECLARATION IS OVERRIDE-EQUIVALENT TO (#1542). The service-layer shape: a controller +// holds an interface-typed field and calls through it. Before this the caller of an implementation showed only under +// "reaches those" (the dispatch hop), and a caller the engine had NARROWED to the implementation (an injected field whose +// bean it resolved) was missing from the interface method altogether, so `impact` there said the change was local. +// The rows are graph_sql.via_base_rows, read by the SQL port too: for the base itself, a multi_inferred edge is the +// call the code names ("calls it", resolved); for each override the engine kept at that site, "calls it (via the +// interface)", resolved only when nothing else can run there; for a narrowed call, the base when the receiver read at +// the call is a field declared with the base's type. An override the engine left out of a site's set gets no row, so a +// caller whose receiver resolves to a sibling implementation does not become a caller of this one. `via_site` is the +// plain row each one replaces, so a caller is not listed twice for one call. +.decl via_base(c:symbol, m:symbol, why:symbol, cert:symbol, f:symbol, l:number, e:symbol) .input via_base +.decl via_site(c:symbol, m:symbol, f:symbol, l:number) .input via_site +direct(q, c, "uses", why, cert, f, l) :- target(q, "method", m, _), via_base(c, m, why, cert, f, l, _), !is_target_decl(q, c). +// A call written inside a mock's stub or verification (`when(repo.find(1))`, `mock.Setup(r => r.Find(1))`): the exporter +// gives it the tier "stub" (ax_edges.stub_sites). It names the method, so a rename or a new parameter breaks it, and it +// runs nothing: the receiver is a mock. Its own row, whatever else the caller does; it is not an edge of the closure. +direct(q, c, "uses", why, cert, f, l) :- target(q, "method", m, _), calls(c, m, "stub", f, l), cert_tier("stub", cert, why). // a registration names the row: the edge is the engine's, the sentence is the registration's, and the // certainty stays the tier's — a route handed over as a value is still handed over as a value. -direct(q, c, "uses", why, cert, f, l) :- target(q, "method", m, _), calls(c, m, t, f, l), t != "multi_inferred", cert_tier(t, cert, _), !bean_call(q, c, m), registration(_, f, l, "route", _, why). +direct(q, c, "uses", why, cert, f, l) :- target(q, "method", m, _), calls(c, m, t, f, l), t != "multi_inferred", t != "stub", cert_tier(t, cert, _), !bean_call(q, c, m), registration(_, f, l, "route", _, why). // A call INTO a method of a container-managed bean. Whether the container's proxy is on the path decides whether a // behavioural annotation added to the method (a transaction, a cache, an async dispatch, a retry, an authorization // check) reaches this caller at all: a call from inside the bean goes through `this` and never through the proxy. // This is a per-framework convention stated as such, and it fires only where the graph actually found a container. .decl bean_call(q:symbol, c:symbol, m:symbol) -bean_call(q, c, m) :- target(q, "method", m, _), owner(m, ot), bean(_, ot, _), calls(c, m, t, _, _), t != "multi_inferred". +// Not for an event either: the event system invokes a listener on the container's bean, so the proxy IS on that +// path, and "an instance it obtained itself" would be false (#1391). It keeps its tier's own row above. +bean_call(q, c, m) :- target(q, "method", m, _), owner(m, ot), bean(_, ot, _), calls(c, m, t, _, _), t != "multi_inferred", t != "event_dispatch", t != "stub". // the type that the container hands the bean to: a field injection names the type, a constructor or setter names the method .decl receives_bean(t:symbol, into:symbol) receives_bean(t, into) :- injected(t, into, _), typ(into, _, _). receives_bean(t, into) :- injected(t, x, _), owner(x, into). +// …and a point the engine WIRED to this bean, whatever type it declares: an `@Autowired Store store` given the JdbcStore +// bean receives it through the container, and keyed on the declared type alone its caller "obtained it itself" (#1384) +.decl injected_bean(t:symbol, into:symbol) .input injected_bean +receives_bean(t, into) :- injected_bean(t, into), typ(into, _, _). +receives_bean(t, into) :- injected_bean(t, x), owner(x, into). direct(q, c, "uses", "calls it from inside the same bean — through this, not through the container proxy, so an added proxied annotation does NOT apply to this caller", "resolved", f, l) :- - bean_call(q, c, m), owner(m, ot), owner(c, ot), calls(c, m, _, f, l). + bean_call(q, c, m), owner(m, ot), owner(c, ot), calls(c, m, t, f, l), t != "stub". direct(q, c, "uses", "calls it on the injected bean — the container proxy is on the path, so an added proxied annotation applies here", "resolved", f, l) :- - bean_call(q, c, m), owner(m, ot), owner(c, cot), cot != ot, receives_bean(ot, cot), calls(c, m, _, f, l). + bean_call(q, c, m), owner(m, ot), owner(c, cot), cot != ot, receives_bean(ot, cot), calls(c, m, t, f, l), t != "stub". direct(q, c, "uses", "calls it on an instance it obtained itself, not from the container — no proxy on the path, so an added proxied annotation does not apply here", "resolved", f, l) :- - bean_call(q, c, m), owner(m, ot), owner(c, cot), cot != ot, !receives_bean(ot, cot), calls(c, m, _, f, l). -direct(q, c, "uses", "calls it", "one of a set", f, l) :- target(q, "method", m, _), calls(c, m, "multi_inferred", f, l). + bean_call(q, c, m), owner(m, ot), owner(c, cot), cot != ot, !receives_bean(ot, cot), calls(c, m, t, f, l), t != "stub". +direct(q, c, "uses", "calls it", "one of a set", f, l) :- target(q, "method", m, _), calls(c, m, "multi_inferred", f, l), !via_site(c, m, f, l). // `!is_target_decl`: `this.rows.find(...)` inside `OrdersService.find` is the ARRAY's find, and with the receiver // untyped it matched the target by name — printing the declaration as a reader of itself. Worse, that row made the // change its own [by name] dependent, so `weak_seed` held for it and the whole answer said "12 of those 12 are // reached ONLY through a dependent that is not an exact edge": everything downstream looked untrustworthy because of // one self-match. A recursive call needs no row either — the body is what is changing. -direct(q, c, "uses", "calls a method of this name (receiver not typed)", "by name", f, l) :- target(q, "method", m, _), named(n, m), unresolved(c, n, k, f, l), !ctor_kind(k), !is_target_decl(q, c). +direct(q, c, "uses", "calls a method of this name (receiver not typed)", "by name", f, l) :- target(q, "method", m, _), named(n, m), unresolved(c, n, k, f, l), !ctor_kind(k), k != "stub", !is_target_decl(q, c). +direct(q, c, "uses", "stubs a method of this name on a mock (receiver not typed): the real method does not run there", "by name", f, l) :- target(q, "method", m, _), named(n, m), unresolved(c, n, "stub", f, l), !is_target_decl(q, c). // ── a method handed over as a VALUE, never called where it is named ────────────────────────────────────────── // `app.get('/orders/:id', getOrder)`, `useEffect(load)`, `items.map(format)`, `handlers = {"x": handle_x}`: the @@ -400,6 +455,47 @@ fref(q, c, rk, f, l) :- enum_member_target(q, fl), field(fl, t, n, ff, fll), ref .decl frole(rk:symbol, role:symbol, why:symbol) frole("qualified", "uses", "writes/reads it"). frole("bare", "reads", "reads it"). +// A CONST THAT HOLDS A WRAPPED HANDLER is registered where it is handed to a route, not read there. +// `const listAdmins = catchAsync(async (req, res) => …)` then `router.get('/admins', listAdmins)`: the declaration is +// a const, not a method, so the method-target route wording (`route_site` above) never applied and the answer said +// only "[by name] … reads it" at the registration. Two conditions, both required, both read by ax_registration: +// - `route_arg(c, f, l, n)`: the name is written IN A HANDLER POSITION of a route call `c` makes — a top-level +// argument or an element of an array argument, bare or at the end of a member chain. Not the receiver +// (`router.get`), not inside an options object (`{ schema: { 200: TaskSchema } }`), not an argument of a nested +// call (`validate(bodySchema)`), and not inside an inline handler, whose reads are the handler's. +// - `callable_const(ff, fll)`: the const HOLDS A FUNCTION — a function, a reference to a declared one, or a call that +// returns one (a callee the engine says returns a function, or a wrapper handed a function). A schema, an options +// object, `express.Router()` (a router mounted with `app.use('/p', router)` is a mount) or a module never does. +// Then: +// - registered: the function the const holds is known — the wrapper's returned function (`init_wrapper`, +// `returns_fn`) or the function it aliases (`init_alias`) — and the route line hands THAT function over +// (`handoff_at`). A hand-off of some other function on the same line (`.post(protect, admin, h)`) is not evidence. +// - by name: otherwise (a library wrapper says nothing about what it returns). The route's wording, the name's rung. +.decl route_arg(c:symbol, f:symbol, l:number, n:symbol) .input route_arg +.decl callable_const(f:symbol, l:number) .input callable_const +.decl init_wrapper(f:symbol, l:number, w:symbol) .input init_wrapper +.decl init_alias(f:symbol, l:number, n:symbol) .input init_alias +.decl returns_fn(w:symbol, m:symbol) .input returns_fn +.decl const_holds(ff:symbol, fll:number, m:symbol) +const_holds(ff, fll, m) :- init_wrapper(ff, fll, w), returns_fn(w, m). +const_holds(ff, fll, m) :- init_alias(ff, fll, n), named(n, m). +.decl const_handed(q:symbol, c:symbol, f:symbol, l:number, ff:symbol, fll:number) +const_handed(q, c, f, l, ff, fll) :- target(q, "field", fl, _), field(fl, _, n, ff, fll), callable_const(ff, fll), + fref(q, c, _, f, l), route_site(f, l), route_arg(c, f, l, n). +.decl const_route_edge(q:symbol, c:symbol, m:symbol, f:symbol, l:number) +const_route_edge(q, c, m, f, l) :- const_handed(q, c, f, l, ff, fll), const_holds(ff, fll, m), handoff_at(c, m, f, l). +.decl const_route(q:symbol, c:symbol, cert:symbol, f:symbol, l:number) +const_route(q, c, cert, f, l) :- const_route_edge(q, c, _, f, l), cert_tier("callback_registered", cert, _). +direct(q, c, "uses", why, cert, f, l) :- const_route(q, c, cert, f, l), registration(_, f, l, "route", _, why). +// the row claims the hand-off edge on that line, so `verified:` looks it up like any registered row +direct_edge(q, c, m) :- const_route_edge(q, c, m, _, _). +.decl const_route_byname(q:symbol, c:symbol, f:symbol, l:number) +const_route_byname(q, c, f, l) :- const_handed(q, c, f, l, _, _), !const_route(q, c, _, f, l). +direct(q, c, "uses", why, "by name", f, l) :- const_route_byname(q, c, f, l), registration(_, f, l, "route", _, why). +.decl const_routed(q:symbol, c:symbol, f:symbol, l:number) +const_routed(q, c, f, l) :- const_route(q, c, _, f, l). +const_routed(q, c, f, l) :- const_route_byname(q, c, f, l). + // A field access the ENGINE resolved. The site is bound to THIS declaration, so it is not a name // match against every field of that name, and its direction is known. Sites the engine could not // bind (tier ambiguous_unknown) are left to the name rules below, which is where they belong. @@ -416,15 +512,15 @@ direct(q, c, "uses", "reads it", "resolved", f, l) :- target(q, "field", fl fa_known(q, c) :- target(q, "field", fl, _), fa_bound(c, fl, _, _, _). fa_known(q, c) :- target(q, "field", fl, _), field(fl, _, n, _, _), fa_bound(c, fl2, _, _, _), fl2 != fl, field(fl2, _, n, _, _). // in scope: inside the owner, a subtype, or a nested type — or the type node itself (a field initializer) -direct(q, c, role, why, "in scope", f, l) :- fref(q, c, rk, f, l), frole(rk, role, why), target(q, "field", fl, _), field(fl, t, _, _, _), owner(c, s), scope(t, s), !fa_known(q, c). -direct(q, c, role, why, "in scope", f, l) :- fref(q, c, rk, f, l), frole(rk, role, why), target(q, "field", fl, _), field(fl, t, _, _, _), scope(t, c), !fa_known(q, c). +direct(q, c, role, why, "in scope", f, l) :- fref(q, c, rk, f, l), frole(rk, role, why), target(q, "field", fl, _), field(fl, t, _, _, _), owner(c, s), scope(t, s), !fa_known(q, c), !const_routed(q, c, f, l). +direct(q, c, role, why, "in scope", f, l) :- fref(q, c, rk, f, l), frole(rk, role, why), target(q, "field", fl, _), field(fl, t, _, _, _), scope(t, c), !fa_known(q, c), !const_routed(q, c, f, l). // qualified elsewhere: the qualifier written on the line decides — the owner's own name confirms, another type's name excludes direct(q, c, "uses", "writes/reads it", "in scope", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), - owner(c, s), !scope(t, s), qualifier(f, l, n, tn), !fa_known(q, c). + owner(c, s), !scope(t, s), qualifier(f, l, n, tn), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), - owner(c, s), !scope(t, s), !declares(s, n), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !fa_known(q, c). + owner(c, s), !scope(t, s), !declares(s, n), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), - owner(c, s), !scope(t, s), !declares(s, n), !qualifier(f, l, n, _), !fa_known(q, c). + owner(c, s), !scope(t, s), !declares(s, n), !qualifier(f, l, n, _), !fa_known(q, c), !const_routed(q, c, f, l). // THE SAME, IN A CALLABLE THAT HAS NO OWNER TYPE. Every rule above starts at `owner(c, s)`, which in Java and // TypeScript is always there — and in Python and JavaScript a module-level function has no owner at all, so a // qualified read of a field from one was matched by no rule and vanished from the answer. `price_order` reading @@ -432,15 +528,22 @@ direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualifie // own class touched it. The qualifier decides here exactly as it does above: the owner's own name confirms, // another TYPE's name excludes, and a variable (which is what a Python qualifier usually is) is `by name`. direct(q, c, "uses", "writes/reads it", "in scope", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), - !owner(c, _), !scope(t, c), qualifier(f, l, n, tn), !fa_known(q, c). + !owner(c, _), !scope(t, c), qualifier(f, l, n, tn), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), typ(t, tn, _), - !owner(c, _), !scope(t, c), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !fa_known(q, c). + !owner(c, _), !scope(t, c), qualifier(f, l, n, qn), qn != tn, !typ(_, qn, _), !self_qualifier(qn), !fa_known(q, c), !const_routed(q, c, f, l). direct(q, c, "uses", "writes/reads it", "by name", f, l) :- fref(q, c, "qualified", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), - !owner(c, _), !scope(t, c), !qualifier(f, l, n, _), !fa_known(q, c). + !owner(c, _), !scope(t, c), !qualifier(f, l, n, _), !fa_known(q, c), !const_routed(q, c, f, l). // bare elsewhere: only when the enclosing type has no member of that name itself direct(q, c, "reads", "reads it", "by name", f, l) :- fref(q, c, "bare", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), - owner(c, s), !scope(t, s), !declares(s, n). -direct(q, c, "reads", "reads it", "by name", f, l) :- fref(q, c, "bare", f, l), target(q, "field", fl, _), field(fl, t, _, _, _), !owner(c, _), !scope(t, c). + owner(c, s), !scope(t, s), !declares(s, n), !const_routed(q, c, f, l). +direct(q, c, "reads", "reads it", "by name", f, l) :- fref(q, c, "bare", f, l), target(q, "field", fl, _), field(fl, t, n, _, _), !owner(c, _), !scope(t, c), !const_routed(q, c, f, l), !shadowed_in(fl, n, f). +// a bare name in a file that declares ANOTHER field of that name is that file's own: `const listAdmins = ['root']` in +// one module and a wrapped handler of the same name in another are two declarations, and each answer carried the +// other's readers. A function of that name declared in the file is its own the same way: `module.exports = { createUser }` +// in a service that declares `const createUser = async (body) => …` is not a reader of the controller's wrapped handler +.decl shadowed_in(fl:symbol, n:symbol, f:symbol) +shadowed_in(fl, n, f) :- target(_, "field", fl, _), field(fl, _, n, _, _), field(fl2, _, n, f, _), fl2 != fl. +shadowed_in(fl, n, f) :- target(_, "field", fl, _), field(fl, _, n, ff, _), named(n, m), decl_file(m, f), f != ff. // a declared accessor is the field's door. The door is a CALL, so it carries the call's tier like every other // call-derived rule here: a site the engine could not narrow to one declaration is `one of a set`, not `resolved`. // Binding the tier as a wildcard and hardcoding "resolved" made the accessor door the one place in this file that @@ -481,6 +584,19 @@ direct(q, c, "produces", "instantiates it (unresolved site)", "by name", f, l) : direct(q, c, "produces", "reflects on its .class — deserialization or a framework produces it here", "by name", f, l) :- target(q, "type", t, _), typ(t, n, _), ref(c, n, _, "CLASS_LITERAL", f, l), !inside_target(q, c). direct(q, c, "uses", "references it", "by name", f, l) :- target(q, "type", t, _), typ(t, n, _), ref(c, n, _, ek, f, l), ek != "CLASS_LITERAL", !local_kind(ek), !member_kind(ek), !inside_target(q, c). direct(q, c, "uses", cat("names it (", ctx, ")"), "by name", f, l) :- target(q, "type", t, _), typ(t, n, _), typeref(c, n, ctx, f, l), !inside_target(q, c). +// the ALIAS HOP (#784): a type alias whose right-hand side names the target, directly or through another such alias, +// and everything that names the alias. `function finalize(s: DraftState)` breaks when MapState changes and never writes MapState. +.decl alias_over(q:symbol, a:symbol) +alias_over(q, a) :- target(q, "type", t, _), typ(t, n, _), typeref(a, n, _, _, _), type_alias(a), !inside_target(q, a). +alias_over(q, a) :- alias_over(q, b), typ(b, n, _), typeref(a, n, _, _, _), type_alias(a), !inside_target(q, a). +direct(q, c, "uses", cat("names ", an, ", a type alias over it (", ctx, ")"), "by name", f, l) :- alias_over(q, a), typ(a, an, _), typeref(c, an, ctx, f, l), c != a, !inside_target(q, c). +// an INTRINSIC ELEMENT whose attributes are checked against it (#784): `

` never writes FormElementProps +direct(q, c, "uses", cat("renders <", tag, ">, whose attributes are checked against it"), "by name", f, l) :- target(q, "type", t, _), typ(t, n, _), jsx_props(tag, n), jsx_tag(c, tag, f, l), !inside_target(q, c). +direct(q, c, "uses", cat("renders <", tag, ">, whose attributes are checked against ", sn, ", which extends it"), "by name", f, l) :- target(q, "type", t, _), extends(s, t), typ(s, sn, _), jsx_props(tag, sn), jsx_tag(c, tag, f, l), !inside_target(q, c). +// an OBJECT LITERAL that carries its discriminant (#784): `freeze({ kind: 'LiteralNode', fragments })` is checked against +// LiteralNode and never writes the name, so an added required property breaks it +direct(q, c, "produces", cat("builds an object literal with the tag ", k, ": '", v, "'"), "by name", f, l) :- target(q, "type", t, _), discriminant(t, k, v), keyed_literal(c, k, v, f, l), !inside_target(q, c). +direct(q, c, "produces", cat("builds an object literal with the tag ", k, ": '", v, "' of ", sn, ", which extends it"), "by name", f, l) :- target(q, "type", t, _), extends(s, t), typ(s, sn, _), discriminant(s, k, v), keyed_literal(c, k, v, f, l), !inside_target(q, c). direct(q, c, "uses", "names it (a signature or a declaration)", "text", f, l) :- target(q, "type", t, _), typ(t, n, _), textuse(c, n, f, l), !inside_target(q, c). direct(q, c, "uses", cat("uses ", n, ", imported from it"), "by name", f, l) :- target(q, "type", _, _), importuse(c, n, f, l), !inside_target(q, c). direct(q, c, "reads", cat("calls the generated getter ", an, "()"), "by name", f, l) :- target(q, "type", t, _), gen(t, "get"), field(fl, t, _, _, _), accessor(fl, an, "read"), unresolved(c, an, k, f, l), !ctor_kind(k), !inside_target(q, c). @@ -593,6 +709,8 @@ seed(q, m) :- seed_of(q, m). // merely that the caller calls something .decl direct_edge(q:symbol, c:symbol, callee:symbol) direct_edge(q, c, m) :- target(q, "method", m, _), calls(c, m, _, _, _). +// a via_base row claims the engine's edge at its site: the base's, or the override the call was narrowed to (#1542) +direct_edge(q, c, e) :- target(q, "method", m, _), via_base(c, m, _, _, _, _, e), !is_target_decl(q, c). direct_edge(q, c, a) :- target(q, "field", fl, _), field(fl, t, _, _, _), accessor(fl, an, _), member(t, a, an, _), calls(c, a, _, _, _). direct_edge(q, c, k) :- target(q, "field", fl, _), field(fl, t, _, _, _), member(t, k, _, "constructor"), calls(c, k, _, _, _). direct_edge(q, c, k) :- target(q, "field", fl, _), field(fl, t, _, _, _), holds(h, t), member(h, k, _, "constructor"), calls(c, k, _, _, _). @@ -622,7 +740,7 @@ seed_of(q, c) :- target(q, "string", _, _), direct(q, c, _, _, cert, _, _), cert // a nested type reached through `import static Outer.Inner`, a dynamic receiver) MAY be a call to the target. It is // not a resolved edge, so it is kept out of seed / direct and enters only here, marked, so the answer can say so. .decl seed_byname(q:symbol, c:symbol) -seed_byname(q, c) :- target(q, "method", m, _), named(n, m), unresolved(c, n, _, _, _), !seed(q, c). +seed_byname(q, c) :- target(q, "method", m, _), named(n, m), unresolved(c, n, k, _, _), k != "stub", !seed(q, c). // and a declaration handed over as a VALUE — `Depends(get_settings)`, `add_task(send_receipt, id)`, an element of // a pipeline list. `valueref` already produces the DIRECT row for these ("names it as a value — passed, stored or // registered"), and the closure stopped there: the answer named the callable that hands it over and then said the @@ -718,10 +836,33 @@ key_use_fan(k, n) :- key_written(_, k), n = count : { key_written(_, k) }. key_capped(k) :- reg_key_fan(k, n), key_cap(c), n > c. key_capped(k) :- key_use_fan(k, n), key_use_cap(c), n > c. .output key_capped -.decl fw_edge(a:symbol, b:symbol, how:symbol) -fw_edge(a, b, "by key") :- literal(a, k, _, _), reg_key(b, _, k), a != b, !key_capped(k). +// THE HTTP METHOD, where both sides name one (ax_registration.route_verbs / literal_verbs): a test that POSTs "/orders" +// drives the handler that answers POST, not the one beside it that answers GET. A side that names none joins as before. +// The writer cap is counted per registration over the writers whose method fits it, so a path five tests write, one +// verb each, is not refused on behalf of a handler only two of them can reach; a key with no verb anywhere counts +// exactly as before. +// reg_verb(b, k, v) route k registered by b answers the HTTP method v +// lit_verb(a, k, v) a writes the path k as the argument of a request call for the method v +.decl reg_verb(b:symbol, k:symbol, v:symbol) .input reg_verb +.decl lit_verb(a:symbol, k:symbol, v:symbol) .input lit_verb +.decl key_join(a:symbol, b:symbol, k:symbol, w:symbol) +key_join(a, b, k, k) :- literal(a, k, _, _), reg_key(b, _, k), a != b. // the same, where the two spellings of the path differ: /orders/o-1/price written, /orders/{order_id}/price registered -fw_edge(a, b, "by key") :- literal(a, w, _, _), route_alias(w, r), reg_key(b, _, r), a != b, !key_capped(r). +key_join(a, b, r, w) :- literal(a, w, _, _), route_alias(w, r), reg_key(b, _, r), a != b. +.decl verb_fits(a:symbol, b:symbol, k:symbol, w:symbol) +verb_fits(a, b, k, w) :- key_join(a, b, k, w), !reg_verb(b, k, _). +verb_fits(a, b, k, w) :- key_join(a, b, k, w), !lit_verb(a, w, _). +verb_fits(a, b, k, w) :- key_join(a, b, k, w), lit_verb(a, w, v), reg_verb(b, k, v). +.decl reg_capped(k:symbol) +reg_capped(k) :- reg_key_fan(k, n), key_cap(c), n > c. +// the writers of the key as registered (an alias spelling is not counted, as before). A helper relation, not +// `count : { verb_fits(_, b, k, k) }`: Soufflé 2.5 counts an aggregate atom with a repeated variable as 1. +.decl exact_fit(a:symbol, b:symbol, k:symbol) +exact_fit(a, b, k) :- verb_fits(a, b, k, k). +.decl use_fan_for(b:symbol, k:symbol, n:number) +use_fan_for(b, k, n) :- reg_key(b, _, k), n = count : { exact_fit(_, b, k) }. +.decl fw_edge(a:symbol, b:symbol, how:symbol) +fw_edge(a, b, "by key") :- verb_fits(a, b, k, _), !reg_capped(k), use_fan_for(b, k, n), key_use_cap(c), n <= c. // a decorator that REBINDS THE NAME: `@audited def summarise` leaves `summarise` denoting the wrapper, so every // caller written with that name runs the wrapper. This one is the engine's own resolution, not a name match. fw_edge(a, b, "decorator") :- calls(a, m, _, _, _), decorated_name(m, b), a != b. @@ -774,14 +915,16 @@ imports_file(f, h) :- imports_file(f, g), imports_file(g, h), f != h. // ── tests: a test's own body reaches the change, or a fixture its framework runs before it does ─────────────── .decl test_hit(q:symbol, m:symbol, d:number, via:symbol) test_hit(q, m, d, "") :- reach(q, m, d), test_method(m). -test_hit(q, m, d, fx) :- reach(q, fx, d), fixture(fx), owner(fx, t), scope(t, s), member(s, m, _, _), test_method(m). +test_hit(q, m, d, fx) :- reach(q, fx, d), fixture(fx), !injected_fixture(fx), owner(fx, t), scope(t, s), member(s, m, _, _), test_method(m). test_hit(q, m, d, fx) :- reach(q, fx, d), fixture(fx), typ(fx, _, _), member(fx, m, _, _), test_method(m). // a test file is imported as a whole: a class body or a fixture at its top level runs before every test in it — pytest // injects a fixture by parameter name and a the Python validation library model class is built at import, and neither is a call edge // An OWNED fixture is already carried, scoped to its type, by the rule above. Only a fixture that // belongs to no type -- a conftest.py fixture, a module-level setup -- has the file as its scope; a // constructor is a fixture too, and file-keyed it carried a sibling @Nested class's tests. -test_hit(q, m, d, c) :- reach(q, c, d), decl_file(c, f), decl_file(m, f), test_method(m), !test_method(c), (kind(c, "module") ; (fixture(c), !owner(c, _))). +// A fixture injected BY NAME is not one of these: it runs for the tests that request it (uses_fixture below), and a +// module-level `@pytest.fixture` credited to every test in its file counted tests that never ran it (#1527). +test_hit(q, m, d, c) :- reach(q, c, d), decl_file(c, f), decl_file(m, f), test_method(m), !test_method(c), (kind(c, "module") ; (fixture(c), !owner(c, _), !injected_fixture(c))). // a CLASS body runs before the tests that class declares, and before those of a type nested in it or deriving from // it -- which is what scope/2 already ranges over. Keyed on the file instead, one Java source file is one unit, so a // reached @Nested class carried every test of its SIBLING classes too. @@ -792,7 +935,7 @@ test_hit(q, m, d, c) :- reach(q, c, d), kind(c, "class"), scope(c, s), member(s, // JUnit file holds several @Nested classes, and a helper private to one of them is not written for a sibling's // tests. A helper that belongs to no type -- a module-level function, an arrow beside the describe block -- has no // scope narrower than the file, and keeps it. -test_hit(q, m, d, c) :- reach(q, c, d), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m), !test_method(c), decl_file(c, f), is_test_file(f). +test_hit(q, m, d, c) :- reach(q, c, d), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m), !test_method(c), !injected_fixture(c), decl_file(c, f), is_test_file(f). // A helper no type owns is scoped by the innermost declaration that lexically encloses it: a // `describe` callback holds its own helpers and its own tests, not a sibling block's; a module holds // its file, which is the file rule kept where it was right. TypeScript has no other scope to use -- @@ -821,10 +964,10 @@ helper_block_test(c, m) :- calls(x, c, _, _, _), encloses_test(x), lex_in(x, m), helper_block_test(c, m) :- calls(x, c, _, _, _), !encloses_test(x), !test_method(x), tblock(b, x), !tighter(b, x), lex_in(b, m), test_method(m). .decl helper_has_block(c:symbol) helper_has_block(c) :- helper_block_test(c, _). -test_hit(q, m, d, c) :- reach(q, c, d), !owner(c, _), !encloses_test(c), helper_has_block(c), helper_block_test(c, m), !test_method(c), decl_file(c, f), is_test_file(f). -test_hit(q, m, d, c) :- reach(q, c, d), !owner(c, _), !encloses_test(c), !helper_has_block(c), lex_parent(c, p), lex_in(p, m), test_method(m), !test_method(c), decl_file(c, f), is_test_file(f). +test_hit(q, m, d, c) :- reach(q, c, d), !owner(c, _), !encloses_test(c), helper_has_block(c), helper_block_test(c, m), !test_method(c), !injected_fixture(c), decl_file(c, f), is_test_file(f). +test_hit(q, m, d, c) :- reach(q, c, d), !owner(c, _), !encloses_test(c), !helper_has_block(c), lex_parent(c, p), lex_in(p, m), test_method(m), !test_method(c), !injected_fixture(c), decl_file(c, f), is_test_file(f). // nothing recorded a span for it, so the file is still the only scope known -test_hit(q, m, d, c) :- reach(q, c, d), !owner(c, _), !lex_parent(c, _), !encloses_test(c), decl_file(c, f), decl_file(m, f), test_method(m), !test_method(c), is_test_file(f). +test_hit(q, m, d, c) :- reach(q, c, d), !owner(c, _), !lex_parent(c, _), !encloses_test(c), decl_file(c, f), decl_file(m, f), test_method(m), !test_method(c), !injected_fixture(c), is_test_file(f). .decl encloses_test(c:symbol) encloses_test(c) :- lex_in(c, m), test_method(m). .decl lex_in(outer:symbol, inner:symbol) @@ -847,7 +990,18 @@ uses_fixture(m, fx) :- takes_param(m, n), fixture_scope(fx, n, p), decl_file(m, (test_method(m) ; fixture(m)), owner(fx, t), owner(m, u), scope(t, u). uses_fixture(m, fx) :- takes_param(m, n), fixture_scope(fx, n, p), decl_file(m, f), file_scope(f, p), m != fx, (test_method(m) ; fixture(m)), !owner(fx, _). +// Where the language engine resolved the request, its edge is the answer (the name join above is then not fed for +// that language). A fixture a class declares stays scoped to that class's tests, as above. +uses_fixture(m, fx) :- injects(m, fx), !owner(fx, _). +uses_fixture(m, fx) :- injects(m, fx), owner(fx, t), owner(m, u), scope(t, u). uses_fixture(m, fx) :- dec_literal(m, "usefixtures", n, _, _), fixture_scope(fx, n, p), decl_file(m, f), file_scope(f, p), m != fx. +// The marker on a CLASS is recorded on the type, and applies to every test the class holds, and to those of a class +// nested in it or deriving from it (scope/2). Joined to the declaration it decorates only, it reached no test (#1528). +uses_fixture(m, fx) :- dec_literal(t, "usefixtures", n, _, _), typ(t, _, _), scope(t, s), member(s, m, _, _), test_method(m), + fixture_scope(fx, n, p), decl_file(m, f), file_scope(f, p), m != fx. +// A module's `pytestmark` and the ini `usefixtures` option apply the marker to every test under their path (#1528). +uses_fixture(m, fx) :- usefixtures_scope(p, n), decl_file(m, f), file_scope(f, p), test_method(m), + fixture_scope(fx, n, q), file_scope(f, q), m != fx. uses_fixture(m, fx) :- autouse_fixture(fx), fixture_scope(fx, _, p), decl_file(m, f), file_scope(f, p), test_method(m), m != fx. // a fixture may request another fixture, and then both run before the test uses_fixture(m, g) :- uses_fixture(m, fx), uses_fixture(fx, g), m != g. @@ -863,6 +1017,26 @@ test_near(q, m, d) :- test_hit(q, m, d, _), d = min x : test_hit(q, m, x, _). .output test_near .output test_hit +// ── a test that only STUBS the change: named, and never counted as running it ───────────────────────────────── +// `when(repo.find(1))`, `verify(repo).save(x)`, `mock.Setup(r => r.Find(1))` name a method the closure reaches and run +// none of it. The tier "stub" keeps them out of `edge`, so they are not routes; this says which tests hold one, so a +// signature change still finds them. The callable that writes the stub (a test, a lambda in it, a setUp, a helper the +// setUp calls) is walked up through the same edges as the closure, and a test is found from it by the same three +// shapes a route ends in: the test itself, a fixture that runs before the tests of its type, a helper of that type. +.decl stub_at(q:symbol, x:symbol) +stub_at(q, x) :- reach(q, c, _), calls(x, c, "stub", _, _). +// A stub site the engine could not resolve (a Moq lambda whose parameter it did not type) is only a NAME: it leaves +// seed_byname, so it is no route, and it is not listed here either, since the same name is often another type's method. +.decl stub_near(q:symbol, a:symbol) +stub_near(q, x) :- stub_at(q, x). +stub_near(q, o) :- stub_at(q, x), lex_in(o, x). +stub_near(q, a) :- stub_near(q, b), edge(a, b, _). +.decl test_stub(q:symbol, m:symbol) +test_stub(q, m) :- stub_near(q, m), test_method(m). +test_stub(q, m) :- stub_near(q, fx), fixture(fx), !test_method(fx), owner(fx, t), scope(t, s), member(s, m, _, _), test_method(m). +test_stub(q, m) :- stub_near(q, c), !test_method(c), !fixture(c), owner(c, t), scope(t, s), member(s, m, _, _), test_method(m), decl_file(c, f), is_test_file(f). +.output test_stub + // ── bound from outside the source ────────────────────────────────────────────────────────────────────────────── // A name written in a file the index does not read as source: an XML mapper, a META-INF/services file, a // properties or yaml file, a build script, a spec. The binding is made by name at run time, so the compiler is diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py b/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py index 414e26d26..6e9ec862d 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl_program.py @@ -1,7 +1,18 @@ """dl_program.py — a Datalog program compiled to a native binary once, cached by the program's hash. -Every query program goes through here: `dl/impact.dl` (axiomcode-impact), and `dl/path*.dl` (axiomcode-path). The cache is keyed by the RULES alone and lives next to them in -`dl/.cache/`, so it is shared by every repository on the machine and survives until the program itself changes. +Every query program goes through here: `dl/impact.dl` (axiomcode-impact), and `dl/path*.dl` (axiomcode-path). The cache +is keyed by the RULES alone (and the machine's platform) and lives in the user's cache, +`${XDG_CACHE_HOME:-~/.cache}/axiomcode/queries/` (AXIOMCODE_QUERY_CACHE overrides it), beside the engine's own Soufflé +cache. It used to live next to the rules in `dl/.cache/`, inside the plugin: every plugin copy (an update installs a new +versioned directory; a second checkout, a worktree) then recompiled byte-identical rules, 45-140 s for impact.dl alone, +and that was paid by whichever came first, the index or a query (#1606). A binary an older plugin left in `dl/.cache/` +is still used. + +A QUERY NEVER WAITS FOR A COMPILE. When the rules are not compiled yet, `program()` starts the compile in the +background (one per program per machine, under a lock) and answers this query with the Soufflé interpreter, which +derives the same relations from the same rules. The interpreter is at most ~2x slower per solve on the graphs measured; +the compile it replaces cost 45 s and more on the first question. `axiomcode index` starts the compiles when the build +starts, in parallel with the engine, so normally they are done before the graph is published. What this buys is the COMPILE STEP, not query speed: on a 5 MB fact set the binary ran the same program in 1.3 s against the interpreter's 2.5 s, but on apache/rocketmq (2,265 files, 184k edges) a query took 22.5 s compiled and @@ -15,11 +26,11 @@ which was the whole of that benchmark's wall-clock regression. A killed compile leaves no `.nocompile` marker either (it did not fail, it was killed), so it cannot even record its own defeat. """ -import hashlib, os, platform, shutil, subprocess, sys +import hashlib, os, platform, shutil, subprocess, sys, time HERE = os.path.dirname(os.path.abspath(__file__)) DL_DIR = os.path.join(HERE, 'dl') -CACHE = os.path.join(DL_DIR, '.cache') +LEGACY_CACHE = os.path.join(DL_DIR, '.cache') # where a plugin before #1606 compiled to: read, never written when the user cache works HOOKS_DIR = os.path.abspath(os.path.join(HERE, '..', '..', '..', 'hooks')) SCOPE = '@axiomcode' # graph/pipeline/engine.conf's ENGINE_PACKAGE_SCOPE EXE = '.exe' if os.name == 'nt' else '' @@ -169,46 +180,188 @@ def packaged(stem, key): return None -def program(dl, verbose=True): - """the argv prefix to run this program: [] when it was built from these rules (no - soufflé, no compiler — what an npm install gets), else [], or ['souffle',
] when there is no compiler, when a - compile has already failed here (the failure is recorded, so a broken toolchain costs one attempt rather than one - per query — the visible half of #816), or under AXIOMCODE_INTERPRET.""" - dl = resolve(dl) - # keyed by the RULES alone. The soufflé runtime is compiled INTO the binary, so the version that generated it does - # not matter at run time; and keying on it meant a cached (or shipped) binary was unusable without soufflé present. - key = rules_id(dl) - stem = os.path.splitext(os.path.basename(dl))[0] - if not os.environ.get('AXIOMCODE_INTERPRET'): - shipped = packaged(stem, key) - if shipped: return [shipped] - binp = os.path.join(CACHE, f'{stem}-{key}'); nope = binp + '.nocompile' - if os.path.exists(binp): return [binp] - if os.path.exists(nope) or not shutil.which('c++') or not shutil.which('souffle') or os.environ.get('AXIOMCODE_INTERPRET'): - return ['souffle', dl] +def cache_dir(): + """where compiled query programs are kept: AXIOMCODE_QUERY_CACHE, else the user's cache (the engine's Soufflé cache + sits beside it, see graph/pipeline/run-souffle.sh), else, with no writable HOME, the plugin's own dl/.cache""" + ov = os.environ.get('AXIOMCODE_QUERY_CACHE') + cands = [ov] if ov else [] + base = os.environ.get('XDG_CACHE_HOME') or (os.path.join(os.path.expanduser('~'), '.cache') if os.path.expanduser('~') != '~' else '') + if base and not ov: cands.append(os.path.join(base, 'axiomcode', 'queries')) + cands.append(LEGACY_CACHE) + for d in cands: + try: + os.makedirs(d, exist_ok=True) + if os.access(d, os.W_OK): return d + except OSError: pass + return LEGACY_CACHE + + +def cached_name(stem, key): + """the file a compile of these rules is kept as. The platform is in the name because the binary is built with + -march=native: a cache on a home directory shared by two kinds of machine must not hand one the other's binary.""" + return f"{stem}-{key}-{npm_platform() or sys.platform}{EXE}" + + +def _alive(pid): + # on Windows signal 0 is CTRL_C_EVENT, not a probe: there the lock's age alone decides (LOCK_STALE_S) + if os.name == 'nt': return True + try: os.kill(pid, 0); return True + except ProcessLookupError: return False + except (PermissionError, OSError): return True + + +LOCK_STALE_S = 1800 # a compile that has held its lock this long is dead, whatever its pid says (pids are reused) + +def _lock_holder(lock): + """the pid compiling behind `lock`, or None when nobody is (a missing lock, or one whose holder died)""" + try: + pid = int(open(os.path.join(lock, 'pid')).read().strip() or 0) + age = time.time() - os.path.getmtime(lock) + except (OSError, ValueError): + try: age = time.time() - os.path.getmtime(lock) + except OSError: return None + return -1 if age < 30 else None # just created, pid not written yet + return pid if pid and _alive(pid) and age < LOCK_STALE_S else None + + +def _take_lock(lock): + """one compile per program per machine: mkdir is atomic. A lock left by a dead compile is taken over.""" + for _ in range(2): + try: + os.mkdir(lock) + with open(os.path.join(lock, 'pid'), 'w') as fh: fh.write(str(os.getpid())) + return True + except FileExistsError: + if _lock_holder(lock) is not None: return False + shutil.rmtree(lock, ignore_errors=True) + except OSError: return False + return False + + +_ELF_MACHINE = {62: 'x64', 183: 'arm64'} +_MACHO_CPU = {0x01000007: 'x64', 0x0100000c: 'arm64'} + + +def runs_here(p): + """whether the binary at p is an executable for this machine's OS and CPU, read from its header. A legacy dl/.cache + binary is named by its rules alone, so a plugin directory copied or synced from another machine (a Mac checkout + rsynced to a Linux VM) carries the other machine's binary under the right name, and running it was an + `OSError: Exec format error` in the middle of impact and path. Unknown formats and unreadable headers are trusted: + this rejects only a binary it can name as another machine's.""" + try: + with open(p, 'rb') as fh: h = fh.read(20) + except OSError: return False + plat = npm_platform() + if not plat or len(h) < 8: return True + o, a = plat.split('-') + if h[:4] == b'\x7fELF': + if o != 'linux': return False + m = _ELF_MACHINE.get(int.from_bytes(h[18:20], 'little' if h[5] == 1 else 'big')) if len(h) >= 20 else None + return m is None or m == a + if h[:4] in (b'\xcf\xfa\xed\xfe', b'\xce\xfa\xed\xfe'): # Mach-O, thin, little-endian + if o != 'darwin': return False + m = _MACHO_CPU.get(int.from_bytes(h[4:8], 'little')) + return m is None or m == a + if h[:4] in (b'\xca\xfe\xba\xbe', b'\xbe\xba\xfe\xca'): return o == 'darwin' # a universal Mach-O + if h[:2] == b'MZ': return o == 'win32' + return True + + +def compiled(dl): + """the compiled binary for these rules when one exists (the engine package's, the user cache's, or a legacy + dl/.cache one) and runs on this machine, else None. Does no work.""" + dl = resolve(dl); key = rules_id(dl); stem = os.path.splitext(os.path.basename(dl))[0] + shipped = packaged(stem, key) + if shipped: return shipped + for p in (os.path.join(cache_dir(), cached_name(stem, key)), os.path.join(LEGACY_CACHE, f'{stem}-{key}')): + if os.path.isfile(p) and os.access(p, os.X_OK) and runs_here(p): return p + return None + + +def _compile(dl, binp, nope, verbose): + """soufflé -g, then c++; the binary lands at binp by one atomic rename. Returns True on success. A failure is + recorded in `nope`, so a broken toolchain costs one attempt rather than one per query (the visible half of #816).""" inc = souffle_include() if not inc: try: open(nope, 'w').write("no souffle/CompiledSouffle.h found; set AXIOM_SOUFFLE_INCLUDE to the directory CONTAINING souffle/\n") except OSError: pass - print(" soufflé's headers were not found (set AXIOM_SOUFFLE_INCLUDE to the directory CONTAINING souffle/) — using the interpreter", file=sys.stderr) - return ['souffle', dl] - # two runs compiling the same program at once used to share one .cpp and one .tmp: the first to finish removed - # the file under the second, which then died with "no such file". Per process names, one atomic rename. - os.makedirs(CACHE, exist_ok=True); cpp = f"{binp}.{os.getpid()}.cpp"; out = f"{binp}.{os.getpid()}.tmp" - if verbose: print(f"compiling {os.path.basename(dl)} to a native binary once (~20 s) …", file=sys.stderr) + if verbose: print(" soufflé's headers were not found (set AXIOM_SOUFFLE_INCLUDE to the directory CONTAINING souffle/) — using the interpreter", file=sys.stderr) + return False + # per-process names and one atomic rename: two compiles of one program never share a .cpp or a .tmp + cpp = f"{binp}.{os.getpid()}.cpp"; out = f"{binp}.{os.getpid()}.tmp" + if verbose: print(f"compiling {os.path.basename(dl)} to a native binary once (45-140 s) …", file=sys.stderr) g1 = subprocess.run(['souffle', '-g', cpp, dl], capture_output=True, text=True) g2 = subprocess.run(['c++', '-std=c++17', '-O3', '-march=native', '-w', '-I', inc, cpp, '-o', out], capture_output=True, text=True) if g1.returncode == 0 else g1 try: os.remove(cpp) except OSError: pass - if g2.returncode == 0: os.replace(out, binp); return [binp] + if g2.returncode == 0: os.replace(out, binp); return True try: os.remove(out) except OSError: pass why = (g2.stderr or '').strip().split(chr(10))[-1][:200] - # record it: without this the next query repeats the whole failed attempt, which is what made a broken probe cost - # ~20 s on EVERY invocation instead of once (#816) try: open(nope, 'w').write(f"-I {inc}\n{why}\nremove this file to try again\n") except OSError: pass - print(f" could not compile ({why}) — using the interpreter; recorded in {os.path.basename(nope)}, remove it to retry", file=sys.stderr) + if verbose: print(f" could not compile {os.path.basename(dl)} ({why}) — using the interpreter; recorded in {nope}, remove it to retry", file=sys.stderr) + return False + + +def _can_compile(): + return bool(shutil.which('c++') and shutil.which('souffle') and not os.environ.get('AXIOMCODE_INTERPRET')) + + +def start_compile(dls): + """compile these programs in a detached process (its own session, so a caller's timeout or Ctrl-C does not kill + it half-way, which would cache nothing and repeat on every call). Programs already compiled, failed or being + compiled are skipped by the child. Returns at once.""" + dls = [resolve(d) for d in dls] + if not dls or not _can_compile(): return False + try: + subprocess.Popen([sys.executable, os.path.abspath(__file__), '--compile'] + dls, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, close_fds=True) + return True + except OSError: + return False + + +def ensure(dl, wait=True, verbose=True): + """compile one program unless it is compiled, failed before, or being compiled by another process. With wait, + a compile in another process is waited for. Returns the binary, or None.""" + dl = resolve(dl); key = rules_id(dl); stem = os.path.splitext(os.path.basename(dl))[0] + have = compiled(dl) + if have: return have + if not _can_compile(): return None + d = cache_dir(); binp = os.path.join(d, cached_name(stem, key)); nope = binp + '.nocompile'; lock = binp + '.lock' + while True: + if os.path.isfile(binp): return binp + if os.path.exists(nope): return None + if _take_lock(lock): + try: + if os.path.isfile(binp): return binp + return binp if _compile(dl, binp, nope, verbose) else None + finally: shutil.rmtree(lock, ignore_errors=True) + if not wait: return None + time.sleep(1) + + +_SAID = set(); _STARTED = set() # per process: say it once, start it once + +def program(dl, verbose=True): + """the argv prefix to run this program: [] when it was built from these rules (no + soufflé, no compiler: what an npm install gets), else [], else ['souffle',
]: the interpreter, + with a compile started in the background so the next query gets the binary. A query never waits for a compile + (see the module docstring); AXIOMCODE_INTERPRET forces the interpreter.""" + dl = resolve(dl) + if not os.environ.get('AXIOMCODE_INTERPRET'): + have = compiled(dl) + if have: return [have] + key = rules_id(dl); stem = os.path.splitext(os.path.basename(dl))[0] + binp = os.path.join(cache_dir(), cached_name(stem, key)) + if _can_compile() and not os.path.exists(binp + '.nocompile'): + if stem not in _STARTED and _lock_holder(binp + '.lock') is None: start_compile([dl]) + _STARTED.add(stem) + if verbose and stem not in _SAID: + _SAID.add(stem) + print(f" {stem} rules: compiling to a native binary in the background (once per machine); this answer " + f"uses the interpreter, same rules", file=sys.stderr) return ['souffle', dl] @@ -217,17 +370,32 @@ def all_programs(): return out -def warm(verbose=True): - """compile every query program that is not cached yet. Called at the end of `axiomcode index`, where ~20 s each is - noise against the build, so that no later query — least of all one under a hook's timeout — ever pays it.""" - done = [] - for dl in all_programs(): - pre = program(dl, verbose=False) - how = 'interpreter' if not pre or pre[0] == 'souffle' else 'cached' if pre[0].startswith(CACHE) else 'packaged' - done.append((os.path.basename(dl), how)) +def status(dl): + """how a query would run this program now: packaged, cached, compiling, or interpreter""" + have = compiled(dl) + if have: return 'packaged' if os.path.basename(os.path.dirname(have)) == 'queries' and os.sep + 'node_modules' + os.sep in have else 'cached' + dl = resolve(dl); stem = os.path.splitext(os.path.basename(dl))[0] + binp = os.path.join(cache_dir(), cached_name(stem, rules_id(dl))) + if _can_compile() and _lock_holder(binp + '.lock') is not None: return 'compiling' + return 'interpreter' + + +def warm(verbose=True, wait=True): + """compile every query program that is not cached yet. `axiomcode index` calls it twice: with --background when the + build starts, so the compiles run beside the engine, and at the end to report (waiting only with wait=True). + No query ever pays the compile: until it is done they are answered by the interpreter.""" + missing = [dl for dl in all_programs() if not compiled(dl)] + if missing: + if wait: + for dl in missing: ensure(dl, wait=True, verbose=False) + else: + start_compile([dl for dl in missing if status(dl) != 'compiling']) + done = [(os.path.basename(dl), status(dl)) for dl in all_programs()] if verbose: - ok = sum(1 for _, s in done if s != 'interpreter') + ok = sum(1 for _, s in done if s in ('cached', 'packaged')) print(f"datalog rules ready: {ok}/{len(done)} compiled (" + ', '.join(f'{n}={s}' for n, s in done) + ")") + if any(s == 'compiling' for _, s in done): + print(" the rest are compiling in the background; until they finish, queries use the interpreter (same answers)") # THE INTERPRETER WITHOUT SOUFFLÉ IS NOT A MODE, IT IS A FAILURE, and it has to be said here, where the index is, # not by the first `impact` after it: "0/4 compiled (…=interpreter)" read as a slower setting, and impact then # failed with advice for another operating system @@ -240,5 +408,11 @@ def warm(verbose=True): if __name__ == '__main__': if sys.argv[1:2] == ['--print-id']: for a in sys.argv[2:]: print(rules_id(a if os.path.exists(a) else resolve(a))) + elif sys.argv[1:2] == ['--compile']: # the detached child start_compile() runs + for a in sys.argv[2:] or all_programs(): ensure(a, wait=False, verbose=False) + elif sys.argv[1:2] == ['--background']: # start every missing compile and return at once + start_compile([dl for dl in all_programs() if not compiled(dl)]) + elif sys.argv[1:2] == ['--no-wait']: # report; start what is missing; never wait + warm(wait=False) else: warm() diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 89ead0e11..7caff1d4a 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -20,7 +20,7 @@ answering from call_edges alone under-reported (4 callers as 2). impact() returns None there and the caller falls back, which is right for that kind. """ -import os, re, sqlite3, json, collections, sys +import bisect, os, re, sqlite3, json, collections, sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))); import ax_edges import ax_registration @@ -157,10 +157,15 @@ def _at(f_, l_): # (`callback_registered`) and a capped fan-out (`fan_capped`) as `resolved` — the strongest claim # the tool makes — while the rules, which read the same column, now do not (#1131). A caller with # several sites is named once, under the best of them. + # A call inside a mock's stub or verification is `stubs it`, and is not walked below: the same set the rules + # and the path export read (ax_edges.stub_sites). + stubs = ax_edges.stub_sites(lambda s_, p_: q(s_, p_).fetchall()) + con.execute("CREATE TEMP TABLE _stub(id TEXT PRIMARY KEY)") + con.executemany("INSERT OR IGNORE INTO _stub VALUES(?)", [(x,) for x in stubs]) _reads = collections.defaultdict(set) - for d, tier in q(f"""SELECT DISTINCT s.display, ce.tier FROM call_edges ce JOIN symbols s ON s.id=ce.caller_id + for d, tier, sid in q(f"""SELECT DISTINCT s.display, ce.tier, ce.call_site_id FROM call_edges ce JOIN symbols s ON s.id=ce.caller_id WHERE ce.callee_method_id IN ({ph})""", ids): - _reads[d].add(ax_edges.direct_cert(tier)) + _reads[d].add(ax_edges.direct_cert(ax_edges.STUB_TIER if sid in stubs else tier)) # …and the end a FRAMEWORK hands it over from (#1509): a task's .delay() producer, a signal's sender, a route # table, a Depends() default. The rules list it as a `framework` dependent, so this does too, or the hook # line and `impact` name different dependents for the same declaration. A direct row only, as in the rules: @@ -176,7 +181,7 @@ def _at(f_, l_): short = target.rsplit('.', 1)[-1] byname = sorted({r[0] for r in q( """SELECT DISTINCT s.display FROM call_sites cs JOIN unresolved_sites us ON us.call_site_id=cs.id - JOIN symbols s ON s.id=cs.caller_id WHERE cs.callee_name=?""", (short,))} - set(reads)) + JOIN symbols s ON s.id=cs.caller_id WHERE cs.callee_name=? AND cs.id NOT IN (SELECT id FROM _stub)""", (short,))} - set(reads)) # the two counts. Each edge table joins in its OWN recursive branch so SQLite drives them by index; building # one combined edge CTE first scans all 608k edges per call (1.89 s against 0.02 s for the same answer). # THE DISPATCH HOP IS NARROWED, the same way the RULES narrow it. `edge.facts` is written by @@ -209,7 +214,8 @@ def _at(f_, l_): SELECT DISTINCT base_method_id b, candidate_method_id c FROM dispatch_candidates""") con.execute("CREATE INDEX _disp_c ON _disp(c)") rec = ("SELECT value, 0 FROM json_each(?)\n" - " UNION SELECT ce.caller_id, r.d+1 FROM call_edges ce JOIN r ON ce.callee_method_id=r.id WHERE r.d.apply` has rows in six), which is why this matters at all. one = {} - for i, f, ln, en, mid, kind, disp in q("""SELECT id, file, line, end_line, method_id, kind, display FROM symbols - WHERE method_id IS NOT NULL OR type_id IS NOT NULL"""): - one[i] = (f, ln, en, mid, kind, disp) - # the same builder the path tool exports with (ax_edges.defines_edges): generated members and equal spans (#1402, #1399) - e += ax_edges.defines_edges(((f, ln, en, i, disp, mid) for i, (f, ln, en, mid, kind, disp) in one.items() + for i, f, ln, en, mid, kind, disp, qn in q("""SELECT id, file, line, end_line, method_id, kind, display, qualified_name + FROM symbols WHERE method_id IS NOT NULL OR type_id IS NOT NULL"""): + one[i] = (f, ln, en, mid, kind, disp, qn) + # the same builder the path tool exports with (ax_edges.defines_edges): generated members and equal spans (#1402, #1399, #1598) + e += ax_edges.defines_edges(((f, ln, en, i, disp, mid, qn) for i, (f, ln, en, mid, kind, disp, qn) in one.items() if mid and kind != 'module'), ax_edges.sites_of(lambda s, p: q(s, *p))) have = {(a, b) for a, b, _ in e} # the same narrowing the path tool applies: a candidate whose owner type is never instantiated anywhere is not a @@ -562,7 +570,9 @@ def _test_sets(q, lines=None, rel=None): tm, fx = set(), set() for sid, name, kind, mid, tid in q("SELECT id, name, kind, method_id, type_id FROM symbols WHERE is_test=1"): d = dec.get(sid, ()) - if mid and kind in ('method', 'function') and (any(TEST_DECOR.search(x) for x in d) or (name or '').startswith(('test', 'it'))): + # a pytest fixture named test_* is built for the tests that request it and never collected (#1531) + if mid and kind in ('method', 'function') and (any(TEST_DECOR.search(x) for x in d) or (name or '').startswith(('test', 'it'))) \ + and not any((x or '').split('.')[-1] == 'fixture' for x in d): tm.add(sid) if (tid and not mid) or kind in ('constructor', 'module') or name in FIXTURE_NAMES or any(FIXTURE_DECOR.match((x or '').split('.')[-1]) for x in d): fx.add(sid) @@ -686,11 +696,136 @@ def _has(q, table): except Exception: return False +def field_decl_type(line, name): + """the type written on a field's declaration line, as a simple name: `private final Store store;`, `IStore _store;`, + `store: Store`. None where the line does not say. The impact exporter's `field_type` fact reads the same pattern.""" + m = re.search(rf'([A-Za-z_$][\w$.]*)\s*(?:<[^;=]*>)?\s*(?:\[\s*\])*\s+{re.escape(name)}\s*[;=,)]', line) + if m: return m.group(1).split('.')[-1] + m = re.search(rf'{re.escape(name)}\s*:\s*([A-Za-z_$][\w$.]*)', line) + return m.group(1).split('.')[-1] if m else None + + +def bean_injections(q): + """(bean type, receiver) for every injection point the engine wired to ONE bean (`ext_di_edge` known_bean / xml_ref): + a field injection's receiver is the type declaring it, a constructor or setter injection's is the method. Keyed on + the bean the container hands over, not the declared type of the point: an `@Autowired Store store` wired to + `JdbcStore` receives the JdbcStore bean, and read by the declared type alone the proxy rule said its caller + "obtained the instance itself" (#1384).""" + if not (_has(q, 'ext_di_edge') and _has(q, 'ext_inject_point')): return [] + return sorted({(bt, tgt) for bt, tgt in q("""SELECT d.c4, COALESCE(s.id, o.id) FROM ext_di_edge d + JOIN ext_inject_point i ON i.c1 = d.c0 + LEFT JOIN symbols s ON s.method_id = i.c4 + LEFT JOIN symbols o ON o.type_id = i.c3 AND o.method_id IS NULL + WHERE d.c5 IN ('known_bean', 'xml_ref') AND d.c4 NOT IN ('', '-') + AND COALESCE(s.id, o.id) IS NOT NULL""") if bt and tgt}) + + +def via_base_rows(q, lines=None, stubs=frozenset(), only=None): + """The callers that reach a declaration through a base declaration it is override-equivalent to (#1542). + + Returns (rows, sites): rows are (caller, declaration, why, cert, raw file, line, the callee of the engine's edge at + that site, which is what `verified:` looks up), and `sites` the (caller, declaration, raw file, line) whose plain + `calls it` row a row here replaces. `only`: the declarations asked about. + + A call site is TYPED ON A BASE b when the engine's targets there are b and overrides of b only, or b alone: + · for b itself, a multi_inferred edge is the call the code names, so it is `calls it`, resolved; + · for each override m the engine kept at that site (every override of b when b is the only target), it is + "calls it (via the interface)": resolved when m is the only thing that can run there (b has no body and one + override), one of a set otherwise. An override the engine left OUT of the site's set gets nothing, so a caller + whose receiver resolves to a sibling implementation never becomes a caller of this one. + A call the engine NARROWED to one override o (an injected field whose bean it resolved) names b in the source but + carries no edge to b: that caller is a caller of b when the receiver it reads there is a field declared with b's + type. Without it, `impact` on the interface method said nothing depended on it (#1542).""" + if not (_has(q, 'call_edges') and _has(q, 'call_sites')): return [], set() + pairs = set() + if _has(q, 'overrides'): + pairs |= {(b, o) for b, o in q("SELECT method_id, overriding_method_id FROM overrides")} + if _has(q, 'dispatch_candidates'): + pairs |= {(b, o) for b, o in q("SELECT base_method_id, candidate_method_id FROM dispatch_candidates WHERE basis <> 'value'")} + down = collections.defaultdict(set) + for b, o in pairs: + if b and o and b != o: down[b].add(o) + if not down: return [], set() + subs = {} + for b in down: # every override of b, through intermediate bases too + seen, stack = set(), list(down[b]) + while stack: + x = stack.pop() + if x in seen or x == b: continue + seen.add(x); stack += down.get(x, ()) + subs[b] = seen + up = collections.defaultdict(set) + for b, os_ in subs.items(): + for o in os_: up[o].add(b) + want = set(subs) | set(up) if only is None else set(only) | {b for m in only for b in up.get(m, ())} | \ + {o for m in only for o in subs.get(m, ())} + if not want: return [], set() + kind_of = {} + def base_kind(b): + if b not in kind_of: + r = q("SELECT owner FROM symbols WHERE id=?", b); o = r[0][0] if r and r[0][0] else None + k = q("SELECT kind FROM symbols WHERE display=? AND type_id IS NOT NULL AND method_id IS NULL", o) if o else [] + kind_of[b] = (k[0][0] if k else '', (o or '').split('.')[-1]) + return kind_of[b] + by_site = collections.defaultdict(dict); where = {} + wl = sorted(want) + for i in range(0, len(wl), 500): + chunk = wl[i:i + 500]; ph = ','.join('?' * len(chunk)) + for sid, c, m, t, f, l, sc, el, ec in q(f"""SELECT e.call_site_id, e.caller_id, e.callee_method_id, e.tier, s.file_path, + s.start_line, s.start_column, s.end_line, s.end_column + FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id + WHERE e.call_site_id IN (SELECT call_site_id FROM call_edges WHERE callee_method_id IN ({ph})) + AND e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL""", *chunk): + if sid in stubs: continue + by_site[sid][m] = t; where[sid] = (c, f or '', l or 0, sc or 0, el or l or 0, ec or 0) + ftype = {} + def recv_types(c, sp): + """the declared types of the fields caller c reads inside the call's span""" + if not (lines and _has(q, 'field_access')): return set() + out = set() + for fid, fl, fc in q("SELECT field_id, start_line, start_column FROM field_access WHERE caller_id=? AND access='read'", c): + if not (fid and fl) or not ((sp[0], sp[1]) <= (fl, fc or 0) <= (sp[2], sp[3] or 10 ** 6)): continue + if fid not in ftype: + r = q("SELECT name, file, line FROM symbols WHERE id=?", fid) + ln = '' + if r and r[0][1] and r[0][2]: + L = lines(r[0][1]) or []; ln = L[r[0][2] - 1] if r[0][2] <= len(L) else '' + ftype[fid] = field_decl_type(ln, r[0][0]) if r and r[0][0] else None + if ftype[fid]: out.add(ftype[fid]) + return out + sure = ('resolved', 'one of a set') + rows, sites = [], set() + for sid, T in by_site.items(): + c, f, l, sc, el, ec = where[sid] + if any(ax_edges.direct_cert(t) not in sure for t in T.values()): continue + typed_on = [b for b in T if b in subs and set(T) - {b} <= subs[b]] + for b in typed_on: + if T[b] == 'multi_inferred': + rows.append((c, b, 'calls it', 'resolved', f, l, b)); sites.add((c, b, f, l)) + bk, _bn = base_kind(b) + n = len(subs[b]) + (0 if bk.lower() in ax_edges.BODILESS_BASE else 1) + others = set(T) - {b} + for m in subs[b]: + if others and m not in others: continue + rows.append((c, m, ax_edges.via_base_why(bk), 'resolved' if n == 1 else 'one of a set', f, l, m if m in T else b)) + sites.add((c, m, f, l)) + if typed_on or len(T) != 1: continue + (o, t), = T.items() + if t == 'multi_inferred' or not up.get(o): continue + rt = None + for b in sorted(up[o]): + bk, bn = base_kind(b) + if not bn: continue + if rt is None: rt = recv_types(c, (l, sc, el, ec)) + if bn in rt: rows.append((c, b, ax_edges.via_base_why(bk), ax_edges.direct_cert(t), f, l, o)) + return rows, sites + + def _bean_call(q, ids, sites): """The container-bean layer on `calls it`, and the only rules in `direct` that a bundle without a container never exercises — which is why jackson (0 rows in ext_bean_def) was clean on it and keycloak (213) was not. - bean_call(q,c,m) :- target(q,"method",m,_), owner(m,ot), bean(_,ot,_), calls(c,m,t,_,_), t != "multi_inferred" + bean_call(q,c,m) :- target(q,"method",m,_), owner(m,ot), bean(_,ot,_), calls(c,m,t,_,_), t != "multi_inferred", t != "stub" Whether the container's proxy is on the path decides whether a behavioural annotation added to the method — a transaction, a cache, a retry, an authorization check — reaches this caller at all. A call from inside the bean @@ -722,7 +857,14 @@ def type_of(sid): WHERE i.c2 <> '' AND COALESCE(s.id, o.id) IS NOT NULL"""): if t != ot: continue recv.add(tgt if tgt in istype else type_of(tgt)) - callers = {c for c, tier, _f, _l in sites if tier != 'multi_inferred'} # a bean call site, any tier but the set + # …and a point the engine wired to this bean whatever type it declares: an interface-typed @Autowired field or + # constructor parameter receives the bean too (#1384) + # receives_bean(t,into) :- injected_bean(t,into), typ(into,_,_) ; injected_bean(t,x), owner(x,into) + for t, tgt in bean_injections(q): + if t == ot: recv.add(tgt if tgt in istype else type_of(tgt)) + # a bean call site, any tier but the set, a stub and an event: the event system invokes a listener on the container's + # bean, so the proxy IS on that path and "an instance it obtained itself" would be false (#1391) + callers = {c for c, tier, _f, _l in sites if tier not in ('multi_inferred', 'event_dispatch', ax_edges.STUB_TIER)} why = {} for c in callers: cot = type_of(c) @@ -738,7 +880,10 @@ def type_of(sid): return callers, why -def direct_for_method(q, ids, code=None, rel=None, at=None, lines=None): +STUB_BYNAME_WHY = 'stubs a method of this name on a mock (receiver not typed): the real method does not run there' + + +def direct_for_method(q, ids, code=None, rel=None, at=None, lines=None, via=None): """direct(q,c,role,why,cert,f,l) for a method target — the rows the answer groups by *why* and *how sure*. calls it resolved a resolved edge @@ -753,15 +898,26 @@ def direct_for_method(q, ids, code=None, rel=None, at=None, lines=None): rows = [] # ordered by site line: when a caller has several call sites the answer names one of them, and the rules name # the lowest. Leaving the order to the table printed a different site (254 against 257) for the same caller. - sites = q(f"""SELECT e.caller_id, e.tier, s.file_path, s.start_line + # a site inside a mock's stub or verification carries the tier "stub", exactly as the rules' `calls` fact does + stubs = ax_edges.stub_sites(lambda s_, p_: q(s_, *p_)) + raw = [(c, m, ax_edges.STUB_TIER if sid in stubs else t, f, l) for sid, c, m, t, f, l in q( + f"""SELECT e.call_site_id, e.caller_id, e.callee_method_id, e.tier, s.file_path, s.start_line FROM call_edges e LEFT JOIN call_sites s ON s.id=e.call_site_id WHERE e.callee_method_id IN ({ph}) AND e.callee_provenance='client' - ORDER BY s.start_line""", *ids) + ORDER BY s.start_line""", *ids)] + sites = [(c, t, f, l) for c, _m, t, f, l in raw] bean_callers, why_of = _bean_call(q, ids, sites) + # a call written against a base this declaration is override-equivalent to (#1542): its row replaces the plain one + # direct(q,c,"uses",why,cert,f,l) :- target(q,"method",m,_), via_base(c,m,why,cert,f,l), c != m + via, via_sites = via if via is not None else via_base_rows(q, lines, stubs, set(ids)) routes = {(f, l): w for _d, f, l, k, _key, w in ax_registration.registrations(q, rel) if k == 'route'} - for c, tier, f, l in sites: + for c, m, tier, f, l in raw: + # `!via_site(c, m, f, l)` on the plain call rules: the via_base row below says it instead + if tier != ax_edges.STUB_TIER and (c, m, f or '', l or 0) in via_sites: continue cert = ax_edges.direct_cert(tier) - if tier == 'multi_inferred': + if tier == ax_edges.STUB_TIER: + rows.append((c, 'uses', ax_edges.DIRECT_WHY[cert], cert, f or '', l or 0)) + elif tier == 'multi_inferred': # rule 208 carries no `!bean_call` guard, so a multi_inferred site stays `one of a set` even into a bean rows.append((c, 'uses', 'calls it', 'one of a set', f or '', l or 0)) elif c not in bean_callers: # `… , !bean_call(q, c, m)` @@ -769,20 +925,22 @@ def direct_for_method(q, ids, code=None, rel=None, at=None, lines=None): # edge is the engine's, the sentence is the registration's. A registration the ROUTE rules named keeps # their sentence whatever the tier — it is a more specific true thing than either default. why = routes.get((rel(f) if rel and f else f, l)) - if why is None: why = ax_edges.DIRECT_WHY.get(cert, 'calls it') + if why is None: why = ax_edges.direct_why(tier) rows.append((c, 'uses', why, cert, f or '', l or 0)) # …and for a caller into a container-managed bean, EVERY site of it — the three bean rules end in a bare # `calls(c, m, _, f, l)` with no tier test, so a multi_inferred site of a bean caller is a row here too. for c, tier, f, l in sites: - if c in bean_callers: rows.append((c, 'uses', why_of[c], 'resolved', f or '', l or 0)) + if c in bean_callers and tier != ax_edges.STUB_TIER: rows.append((c, 'uses', why_of[c], 'resolved', f or '', l or 0)) + idset = set(ids) + rows += [(c, 'uses', why, cert, f, l) for c, m, why, cert, f, l, _e in via if m in idset and c not in idset] names = {r[0] for r in q(f"SELECT name FROM symbols WHERE id IN ({ph})", *ids) if r[0]} seen = {r[0] for r in rows} for n in names: - for c, f, l, kind in q("""SELECT s.caller_id, s.file_path, s.start_line, s.kind FROM call_sites s + for sid, c, f, l, kind in q("""SELECT s.id, s.caller_id, s.file_path, s.start_line, s.kind FROM call_sites s JOIN unresolved_sites u ON u.call_site_id=s.id WHERE s.callee_name=?""", n): if kind in ('new', 'anon_new', 'CONSTRUCTOR_CALL'): continue # !ctor_kind(k) if c in ids: continue # !is_target_decl(q, c) - rows.append((c, 'uses', 'calls a method of this name (receiver not typed)', 'by name', f or '', l or 0)) + rows.append((c, 'uses', STUB_BYNAME_WHY if sid in stubs else 'calls a method of this name (receiver not typed)', 'by name', f or '', l or 0)) # the declaration handed over as a VALUE — a route registration, a callback — which has no call site at all # (the valueref / registered rules). The convention table is shared with the rules, in ax_registration.py, so # the two backends cannot disagree about what a registration is. @@ -979,6 +1137,43 @@ def _injected(q): return _INJECTED[key] + +_REGISTERS = {} +def _registers(q): + """`registers(t,by,kind)`: a bean of type t whose definition site is an annotation declared on ANOTHER type `by`. + + The engine records where each bean comes from (`ext_bean_def.c3`): for @EnableConfigurationProperties({T.class}) + and for a package scan (@MapperScan, @ConfigurationPropertiesScan) that is the annotation on the configuration class, + and `type_use` names the type that annotation sits on. A stereotype's site is on the bean's own type, so it is not a + registration by someone else and is left out. One query, read by the exporter (the rules) and by the fast path. + """ + key = id(q) + if key not in _REGISTERS: + idx = {} + if _has(q, 'ext_bean_def') and _has(q, 'type_use'): + for t, by, kind in q("""SELECT DISTINCT b.c1, s.id, b.c2 FROM ext_bean_def b + JOIN type_use u ON u.owner_id = b.c3 AND u.owner_kind = 'ANNOTATION' + AND u.context = 'ANNOTATION_TYPE' + JOIN symbols s ON s.id = u.owner_type_id AND s.method_id IS NULL + WHERE b.c1 IS NOT NULL AND u.owner_type_id <> b.c1"""): + idx.setdefault(t, set()).add((by, kind or 'bean')) + _REGISTERS[key] = idx + return _REGISTERS[key] + + +_FACTORIES = {} +def _factories(q): + """`bean_factory(m,t)`: the method an @Bean factory is declared on, and the type it produces.""" + key = id(q) + if key not in _FACTORIES: + idx = {} + if _has(q, 'ext_bean_def'): + for mid, t in q("SELECT c3, c1 FROM ext_bean_def WHERE c2 = 'factory_method' AND c3 IS NOT NULL AND c3 <> ''"): + idx.setdefault(mid, set()).add(t) + _FACTORIES[key] = idx + return _FACTORIES[key] + + def _bean_definition_consumers(q, ids): """A method that DEFINES a bean (@Bean): whoever the container injects that type into. @@ -1457,6 +1652,49 @@ class itself). The generated-member rules look here, so an accessor is found whi GEN_DECOR = set(GENERATED) # the names alone, where only membership matters +def stub_tests(q, depth, sets, stubs, rev=None): + """test_stub(q,m) (impact.dl): a test that stubs a reached callable on a mock, and so names it without running it. + The callable that writes the stub, and every callable lexically around it (a lambda's writer), is walked up the + same edges as the closure; a test is found from what that reaches as a route ends: the test itself, a fixture of + its type or a base type, or a helper of that type declared in a test file.""" + if not depth: return [] + tm, fx = sets + ids = list(depth); xs = set() + if stubs: + for k in range(0, len(ids), 400): + part = ids[k:k + 400] + for sid, c in q(f"SELECT call_site_id, caller_id FROM call_edges WHERE callee_method_id IN ({','.join('?' * len(part))})", *part): + if sid in stubs: xs.add(c) + if not xs: return [] + near = set(xs) + for x, f, a, b in q(f"SELECT id, file, line, end_line FROM symbols WHERE id IN ({','.join('?' * len(xs))})", *xs): + if not f: continue + near |= {r[0] for r in q("SELECT id FROM symbols WHERE file = ? AND line <= ? AND end_line >= ? AND id <> ?", f, a, b or a, x)} + frontier = list(near) + while frontier and rev is not None: + nxt = [] + for b in frontier: + for a, _t in rev.get(b, ()): + if a not in near: near.add(a); nxt.append(a) + frontier = nxt + info = {} + ph = ','.join('?' * len(near)) + for i, o, f in q(f"SELECT id, owner, file FROM symbols WHERE id IN ({ph})", *near): info[i] = (o, f) + tfiles = {r[0] for r in q("SELECT DISTINCT file FROM symbols WHERE is_test = 1 AND file IS NOT NULL")} + out = {m for m in near if m in tm} + owners = {info[x][0] for x in near if x in info and info[x][0] and x not in tm and (x in fx or info[x][1] in tfiles)} + if owners: + sub = set(owners) + if _has(q, 'type_ancestors'): + tid = {r[0]: r[1] for r in q("SELECT display, type_id FROM symbols WHERE type_id IS NOT NULL AND method_id IS NULL")} + disp = {v: k for k, v in tid.items()} + want = {tid[o] for o in owners if o in tid} + sub |= {disp[r[0]] for r in q("SELECT type_id, ancestor_type_id FROM type_ancestors") if r[1] in want and r[0] in disp} + for m, o in q("SELECT id, owner FROM symbols WHERE method_id IS NOT NULL AND owner IS NOT NULL"): + if m in tm and o in sub: out.add(m) + return sorted(out) + + def inherited_tests(q, hits): """`inherited_test(q,s,m,d) :- test_hit(q,m,d,_), owner(m,t), extends(s,t), typ(s,_,_), s != t` — the test classes that extend a class whose test was reached run that test too, in their own file.""" @@ -1563,6 +1801,105 @@ def direct_for_newconst(q, tids, code, inside): return sorted(set(rows)), switchers +def type_aliases(q): + """`type_alias(a)` with each alias's span and name: file -> [(line, end, id)], and id -> name (#784). + + A TypeScript `type DraftState = ObjectState | MapState` is a declaration of its own, but no callable spans it, so + the innermost-callable walk charged every name on its right-hand side to the module initializer. That lost the + one hop that matters: the code that breaks when a union member changes names only the alias.""" + if not (_has(q, 'types') and _has(q, 'symbols')): return {}, {} + spans, names = {}, {} + for i, n, f, a, b in q("""SELECT s.id, s.name, s.file, s.line, s.end_line FROM symbols s JOIN types t ON t.id = s.id + WHERE t.category = 'TYPE_ALIAS_TYPE' AND s.file IS NOT NULL AND s.line > 0"""): + spans.setdefault(f, []).append((a, b or a, i)); names[i] = n + return spans, names + + +def typeref_holder(at, spans, modules): + """`typeref(c, …)`'s c for a type reference at (f, l): the innermost callable, except that a reference on a type + alias's own lines, where the only callable spanning it is the module, belongs to the alias. An alias declared + inside a function body keeps the function: that is where its users are.""" + def holder(f, l): + c = at(f, l) + if c is None or c in modules: + best = None + for a, b, i in spans.get(f, ()): + if a <= l <= b and (best is None or b - a < best[0]): best = (b - a, i) + if best: return best[1] + return c + return holder + + +def jsx_props(q): + """`jsx_props(tag, n)`: the types an intrinsic element's attributes are checked against, read off the members of every + `IntrinsicElements` interface the project declares — `h1: FormElementProps` — each name in the member's type, so + `React.DetailedHTMLProps, …>` gives FormHTMLAttributes too (#784).""" + if not (_has(q, 'fields') and _has(q, 'types')): return set() + out = set() + for tag, tn in q("""SELECT f.name, f.type_name FROM fields f JOIN types t ON t.id = f.owner_type_id + WHERE t.name = 'IntrinsicElements' AND f.type_name IS NOT NULL AND f.type_name <> ''"""): + out |= {(tag, n) for n in re.findall(r'(?:[\w$]+\.)*([A-Za-z_$][\w$]*)', tn)} + return out + + +_JSX_OPEN = re.compile(r'<([a-z][\w-]*)(?=[\s/>])') +_JSX_AFTER_WORD = {'return', 'yield', 'await', 'throw', 'case', 'default', 'else'} # `return ` is an element + + +def jsx_tags(files, code, at, tags): + """`jsx_tag(c, tag, f, l)`: an intrinsic element `

` opened in a .tsx / .jsx file, charged to the callable it is + rendered in. Only the tags an IntrinsicElements table names, and only where `<` cannot close a type argument or a + comparison: the character before it is not an identifier, a `)`, a `]` or a `.` (`useState`, `a= 0 and text[j] in ' \t\r\n': j -= 1 + if j >= 0 and (text[j].isalnum() or text[j] in '_$)].'): + w = re.search(r'[\w$]+$', text[max(0, j - 15):j + 1]) + if not (w and w.group(0) in _JSX_AFTER_WORD): continue + l = bisect.bisect_left(nl, m.start()) + 1 + c = at(f, l) + if c: rows.append((c, m.group(1), f, l)) + return rows + + +def discriminants(q): + """`discriminant(t, k, v)`: t declares a property k whose type is the one string literal v — the tag of a + discriminated union, `readonly kind: 'LiteralNode'` (#784).""" + if not (_has(q, 'fields') and _has(q, 'types')): return set() + out = set() + for t, k, tn in q("""SELECT owner_type_id, name, type_name FROM fields + WHERE owner_type_id IS NOT NULL AND (type_name LIKE '''%''' OR type_name LIKE '"%"')"""): + m = re.fullmatch(r"""\s*(['"])([^'"\\]+)\1\s*""", tn or '') + if m: out.add((t, k, m.group(2))) + return out + + +def keyed_literals(q, code, at, values): + """`keyed_literal(c, k, v, f, l)`: an object literal inside c writes the property `k: 'v'` — the discriminant of a + type it builds without naming it. Read from the literals table, which holds string EXPRESSIONS only (a literal + type `kind: 'X'` in an interface is not there), then confirmed on the line: `node.kind === 'X'` compares and + builds nothing, so it is not a row.""" + rows = [] + if not values or not _has(q, 'literals'): return rows + for v, f, l in q(f"SELECT DISTINCT value, file, line FROM literals WHERE value IN ({','.join('?' * len(values))})", + *sorted(values)): + L = code(f); text = L[l - 1] if 0 < l <= len(L) else '' + c = at(f, l) + if not c: continue + # the reader blanks a string's body, quotes included, so `kind: 'X'` reads as `kind:` and a run of blanks as + # wide as the string; a reader that does not blank it leaves the quoted form + w = len(v) + 2 + keys = re.findall(rf"""([A-Za-z_$][\w$]*)\s*:\s*(?:(['"]){re.escape(v)}\2|\s{{{w}}}(?=\s|[,}}]|$))""", text) + rows.extend((c, k, v, f, l) for k, _q in keys) + return sorted(set(rows)) + + def direct_for_type(q, tids, at, inside, textuse, importuse, rel, code=None): """`direct(q,c,role,why,cert,f,l)` for a TYPE target — who instantiates it, calls into it, names it. @@ -1579,6 +1916,11 @@ def direct_for_type(q, tids, at, inside, textuse, importuse, rel, code=None): for t in tids: r = q("SELECT name, kind FROM symbols WHERE id=?", t) if r: names[t] = (r[0][0], r[0][1]) + aspans, anames = type_aliases(q) + holder = typeref_holder(at, aspans, {i for (i,) in q("SELECT id FROM symbols WHERE kind = 'module'")} + if aspans else set()) + def trefs(n): return q("SELECT name, file, line, context FROM type_refs WHERE line > 0 AND name = ?", n) + over, todo = set(), [] # alias_over(q, a), and the aliases still to expand # ── a type the container INJECTS (rule 186) ──────────────────────────────────────────────────────────── # direct(q,c,"uses",cat("receives it by dependency injection (",kind,") — …"),"resolved","",0) @@ -1591,6 +1933,27 @@ def direct_for_type(q, tids, at, inside, textuse, importuse, rel, code=None): rows.append((c, 'uses', f'receives it by dependency injection ({kind}) — the container hands it ' f'over, no call site', 'resolved', '', 0)) + # ── a type another class REGISTERS as a bean ─────────────────────────────────────────────────────────── + # direct(q,c,"uses",cat("registers it as a bean (",kind,"): …"),"resolved","",0) + # :- target(q,"type",t,_), registers(t,c,kind), !inside_target(q,c) + # ── a type that DEFINES beans: who is injected with one of them ──────────────────────────────────────── + # direct(q,c,"uses",cat("is injected with a bean this class defines (",kind,")"),"resolved","",0) + # :- target(q,"type",_,_), inside_target(q,m), bean_factory(m,bt), injected(bt,c,kind), !inside_target(q,c) + bean_rows = set() + regs = _registers(q) + for t in tids: + for c, kind in regs.get(t, ()): + if c not in inside: + bean_rows.add((c, 'uses', f'registers it as a bean ({kind}): the container creates it, no call site', + 'resolved', '', 0)) + fac = _factories(q) + for m in inside: + for bt in fac.get(m, ()): + for c, kind in inj.get(bt, ()): + if c not in inside: + bean_rows.add((c, 'uses', f'is injected with a bean this class defines ({kind})', 'resolved', '', 0)) + rows += sorted(bean_rows) + # ── the GENERATED accessors of the type's own fields: 276-277 ───────────────────────────────────────── # gen(t,"get"|"set"), field(fl,t,…), accessor(fl,an,…), unresolved(c,an,k,f,l), !ctor_kind(k), # !inside_target(q,c). `unresolved` here, not `named_site` — the type form is narrower than the field one. @@ -1680,14 +2043,73 @@ def direct_for_type(q, tids, at, inside, textuse, importuse, rel, code=None): rows.append((c, 'uses', 'references it', 'by name', f, l)) # 273 — the name written in a type position: the context says which (a field type, a parameter, a cast) if _has(q, 'type_refs'): - for nm, f, l, ctx in q("""SELECT name, file, line, context FROM type_refs - WHERE line > 0 AND name = ?""", n): - c = at(f, l) + for nm, f, l, ctx in trefs(n): + c = holder(f, l) if c and c not in inside: rows.append((c, 'uses', f'names it ({ctx})', 'by name', f, l)) + if c in anames and c not in inside and c not in over: over.add(c); todo.append(c) # 274 — the name in the text of a file the parser gave no line for for c, nm, f, l in textuse: if nm == n and c not in inside: rows.append((c, 'uses', 'names it (a signature or a declaration)', 'text', f, l)) + # the ALIAS HOP (#784): a type alias whose right-hand side names the target, directly or through another such + # alias, and everything that names the alias. `function finalize(s: DraftState)` breaks when MapState changes + # and never writes MapState. + # alias_over(q,a) :- target(q,"type",t,_), typ(t,n,_), typeref(a,n,_,_,_), type_alias(a), !inside_target(q,a) + # alias_over(q,a) :- alias_over(q,b), typ(b,n,_), typeref(a,n,_,_,_), type_alias(a), !inside_target(q,a) + # direct(q,c,"uses",cat("names ",an,", a type alias over it (",ctx,")"),"by name",f,l) + # :- alias_over(q,a), typ(a,an,_), typeref(c,an,ctx,f,l), c != a, !inside_target(q,c) + while todo: + b = todo.pop() + for _nm, f, l, _ctx in trefs(anames[b]): + c = holder(f, l) + if c in anames and c not in inside and c not in over: over.add(c); todo.append(c) + for a in sorted(over): + an = anames[a] + for _nm, f, l, ctx in trefs(an): + c = holder(f, l) + if c and c != a and c not in inside: + rows.append((c, 'uses', f'names {an}, a type alias over it ({ctx})', 'by name', f, l)) + # an INTRINSIC ELEMENT whose attributes are checked against it (#784): `

` never writes FormElementProps + # direct(q,c,"uses",cat("renders <",tag,">, whose attributes are checked against it"),"by name",f,l) + # :- target(q,"type",t,_), typ(t,n,_), jsx_props(tag,n), jsx_tag(c,tag,f,l), !inside_target(q,c) + # … and the same through a props type that extends it: cat("…checked against ",sn,", which extends it") + # :- target(q,"type",t,_), extends(s,t), typ(s,sn,_), jsx_props(tag,sn), jsx_tag(c,tag,f,l), !inside_target(q,c) + props = jsx_props(q) if code is not None else set() + if props: + own = {names[t][0] for t in tids if t in names} + subs = {} if not _has(q, 'type_ancestors') else { + n: s for s, n in q(f"""SELECT a.type_id, s.name FROM type_ancestors a JOIN symbols s ON s.id = a.type_id + WHERE a.ancestor_type_id IN ({','.join('?' * len(tids))})""", *tids)} + why = {} + for tag, n in props: + if n in own: why.setdefault(tag, set()).add('it') + if n in subs: why.setdefault(tag, set()).add(f'{n}, which extends it') + files = {f for (f,) in q("SELECT DISTINCT file FROM symbols WHERE file LIKE '%.tsx' OR file LIKE '%.jsx'")} + for c, tag, f, l in jsx_tags(files, code, at, set(why)): + if c in inside: continue + for w in sorted(why[tag]): + rows.append((c, 'uses', f'renders <{tag}>, whose attributes are checked against {w}', 'by name', f, l)) + # an OBJECT LITERAL that carries its discriminant (#784): `freeze({ kind: 'LiteralNode', fragments })` builds a + # LiteralNode and is checked against it, so an added required property breaks it; it never writes the name. + # direct(q,c,"produces",cat("builds an object literal with its tag ",k,": '",v,"'"),"by name",f,l) + # :- target(q,"type",t,_), discriminant(t,k,v), keyed_literal(c,k,v,f,l), !inside_target(q,c) + # … and through a subtype: cat("… with the tag ",k,": '",v,"' of ",sn,", which extends it") + # :- target(q,"type",t,_), extends(s,t), typ(s,sn,_), discriminant(s,k,v), keyed_literal(c,k,v,f,l), !inside_target(q,c) + if code is not None and tids: + disc = discriminants(q) + tset = set(tids) + sub = {} if not _has(q, 'type_ancestors') else { + s: n for s, n in q(f"""SELECT a.type_id, s.name FROM type_ancestors a JOIN symbols s ON s.id = a.type_id + WHERE a.ancestor_type_id IN ({','.join('?' * len(tids))})""", *tids)} + want = {} + for t, k, v in disc: + if t in tset: want.setdefault((k, v), set()).add('') + elif t in sub: want.setdefault((k, v), set()).add(f' of {sub[t]}, which extends it') + if want: + for c, k, v, f, l in keyed_literals(q, code, at, {v for _k, v in want}): + if c in inside or (k, v) not in want: continue + for w in sorted(want[(k, v)]): + rows.append((c, 'produces', f"builds an object literal with the tag {k}: '{v}'{w}", 'by name', f, l)) # 275 — a name imported FROM the type (a static import), used here. Keyed on the imported member, not on the # type's own name, so it is not inside the per-type loop. for c, nm, f, l in importuse: @@ -1818,6 +2240,30 @@ def direct_for_field(q, fids, at, code, lines, inside, rel): for f_ in fids: r_ = field_rec(q, f_) if r_ and r_[1]: decl_sites.setdefault(r_[1], set()).add((r_[2], r_[3])) + # const_handed / const_route / const_route_byname in dl/impact.dl: a const written in a HANDLER POSITION of a route + # call (`route_arg`) whose declaration holds a function (`callable_const`). Registered when the function it holds + # (`init_wrapper` + `returns_fn`, or `init_alias`) is what that line hands over; by name otherwise. Like the rules, + # keyed on the QUERY: any target declaration qualifies the site. + route_why, rargs, handed_to, holds, callable_names = {}, set(), collections.defaultdict(set), set(), set() + if decl_sites: + rargs = ax_registration.route_args(q, code, rel) + decls = sorted({(f_, l_, n_) for n_, ss in decl_sites.items() if n_ in {x[3] for x in rargs} for f_, l_ in ss}) + fcall, fwrap, falias = ax_registration.const_values(q, code, decls, rel) + callable_names = {n_ for f_, l_, n_ in decls if (f_, l_) in fcall} + if callable_names: + for _d, rf_, rl_, k_, _key, w_ in ax_registration.registrations(q, rel): + if k_ == 'route': route_why[(rf_, rl_)] = w_ + ret_of = collections.defaultdict(set) + for w_, m_ in ax_registration.returned_functions(q): ret_of[w_].add(m_) + for f_, l_, w_ in fwrap: + if (f_, l_) in fcall: holds.update(ret_of.get(w_, ())) # const_holds :- init_wrapper, returns_fn + for f_, l_, an_ in falias: # const_holds :- init_alias, named + if (f_, l_) in fcall: holds.update(m_ for (m_,) in q("SELECT method_id FROM symbols WHERE name = ? AND method_id IS NOT NULL", an_)) + for c_, m_, fp_, l_, e_ in q("""SELECT e.caller_id, e.callee_method_id, s.file_path, s.start_line, s.end_line FROM call_edges e JOIN call_sites s ON s.id = e.call_site_id + WHERE e.tier = 'callback_registered' AND e.callee_provenance = 'client' AND e.callee_method_id IS NOT NULL"""): + if m_ in holds: + for ln_ in ax_edges.site_lines(l_, e_): handed_to[(c_, rel(fp_) if fp_ else '', ln_)].add(m_) # handoff_at + reg_cert = ax_edges.direct_cert('callback_registered') for fid in fids: rec = field_rec(q, fid) if not rec: continue @@ -1865,8 +2311,20 @@ def declares_of(s_): # scope can be both `in scope` (231, the qualifier is the owner's own type name) and `by name` (233, some # other qualifier). Written as an if/elif chain this picked one and lost 19 rows on one field alone. typenames = {r[0] for r in q("SELECT name FROM symbols WHERE type_id IS NOT NULL AND name IS NOT NULL")} + # shadowed_in(fl,n,f): the files declaring ANOTHER field of this name, or a function of this name other than + # in the field's own file, whose bare reads are their own + own_decl = {r[0] for r in q("""SELECT file FROM symbols WHERE name = ? AND rowid <> ? AND method_id IS NULL AND type_id IS NULL + AND kind IN ('field','const','enum_member','variable')""", n, rid)} + own_decl |= {r[0] for r in q("SELECT file FROM symbols WHERE name = ? AND method_id IS NOT NULL AND file IS NOT NULL", n) if r[0] != ff} for c, rk, rf, rl, _e in fref: s_ = owner_of.get(c) + if (rf, rl) in route_why and n in callable_names and (c, rf, rl, n) in rargs: # const_handed: bare or qualified + if handed_to.get((c, rf, rl)): + rows.append((c, 'uses', route_why[(rf, rl)], reg_cert, rf, rl)) + de += [(c, m_) for m_ in handed_to[(c, rf, rl)]] # direct_edge(q,c,m) :- const_route_edge + continue + rows.append((c, 'uses', route_why[(rf, rl)], 'by name', rf, rl)) # const_route_byname + continue role, why = ('uses', 'writes/reads it') if rk == 'qualified' else ('reads', 'reads it') in_scope = (s_ is not None and s_ in scope) or (c in scope) if in_scope: # 228 / 229 @@ -1881,10 +2339,19 @@ def declares_of(s_): rows.append((c, 'uses', 'writes/reads it', 'by name', rf, rl)) if not quals: # 235 — no qualifier on the line rows.append((c, 'uses', 'writes/reads it', 'by name', rf, rl)) + # the same in a callable with no owner type (a module, a module-level function): `!owner(c, _)` rules + if rk == 'qualified' and s_ is None and c not in scope: + quals = _qualifiers(code, rf, rl, n) + if tname and tname in quals: + rows.append((c, 'uses', 'writes/reads it', 'in scope', rf, rl)) + if tname and any(qn != tname and qn not in typenames and qn not in SELF_QUALIFIERS for qn in quals): + rows.append((c, 'uses', 'writes/reads it', 'by name', rf, rl)) + if not quals: + rows.append((c, 'uses', 'writes/reads it', 'by name', rf, rl)) if rk == 'bare': if s_ is not None and s_ not in scope and n not in declares_of(s_): # 238 rows.append((c, 'reads', 'reads it', 'by name', rf, rl)) - if s_ is None and c not in scope: # 240 + if s_ is None and c not in scope and rf not in own_decl: # 240 rows.append((c, 'reads', 'reads it', 'by name', rf, rl)) # ── the accessors the convention gives the field, and their callers (242 / 243) ──────────────────── for an, role_ in _accessors(n): @@ -2431,6 +2898,10 @@ def _has_framework_hops(q, at=None, site_file=None): return True if _has(q, 'symbols') and q("SELECT 1 FROM symbols WHERE file LIKE '%conftest.py' AND method_id IS NOT NULL LIMIT 1"): return True + # a fixture injected by name anywhere, conftest or not: the rules credit it only to the tests that request + # it (`injects` / `injected_fixture`), where this port's file-wide rule credits every test in its file (#1527) + if _has(q, 'decorations') and q("SELECT 1 FROM decorations WHERE name = 'fixture' AND (file LIKE '%.py' OR file LIKE '%.pyi') LIMIT 1"): + return True except Exception: return True return False @@ -2478,9 +2949,9 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non # them. Without this the answer prints the machine's absolute path where the rules print src/main/java/… rel = site_file or (lambda x: x) out = {k: [] for k in ('contract', 'direct', 'direct_edge', 'seed', 'seed_byname', 'reach', 'reach_sure', - 'parent_up', 'test_near', 'test_hit', 'inherited_test', 'extbind', 'gen_fired', + 'parent_up', 'test_near', 'test_hit', 'test_stub', 'inherited_test', 'extbind', 'gen_fired', 'caller_handles', 'caller_unhandled', 'target_throws')} - E = _edges(q); rev = _rev(E); sets = _test_sets(q, lines, rel) + E = _edges(q); rev = _rev(E); sets = _test_sets(q, lines, rel); stubs = ax_edges.stub_sites(lambda s_, p_: q(s_, *p_)) for qq in QS: # A query can carry SEVERAL target kinds at once: a name match that hits both a method and a field # resolves to both, and the rules simply union what each kind derives. Dispatch per kind and union here @@ -2504,7 +2975,8 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non mids = sorted(by_kind['method']) mph = ','.join('?' * len(mids)) con += contract_for_method(q, mids) - d = direct_for_method(q, mids, code, rel, at, lines) + via = via_base_rows(q, lines, stubs, set(mids)) + d = direct_for_method(q, mids, code, rel, at, lines, via) # direct(q,c,"uses",…,"framework","",0) :- target(q,"method",m,_), framework(c,m,…) (#1509) if _has(q, 'ext_framework_edge'): d += [(c, 'uses', framework_why(mech, det, conf), 'framework', '', 0) for c, mech, det, conf in @@ -2515,7 +2987,10 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non seeds |= set(mids) | {c for c, _ in con} de += q(f"""SELECT DISTINCT caller_id, callee_method_id FROM call_edges WHERE callee_method_id IN ({mph}) AND callee_provenance='client'""", *mids) - byname = sorted({c for c, _r, _w, cert, _f, _l in d if cert == 'by name' and not ax_registration.is_value_why(_w)} - seeds) + # direct_edge(q,c,e) :- target(q,"method",m,_), via_base(c,m,_,_,_,_,e), !is_target_decl(q,c) + de += sorted({(c, e) for c, m, _w, _c, _f, _l, e in via[0] if m in set(mids) and c not in set(mids)}) + byname = sorted({c for c, _r, _w, cert, _f, _l in d if cert == 'by name' and not ax_registration.is_value_why(_w) + and _w != STUB_BYNAME_WHY} - seeds) if 'type' in by_kind: tids = sorted(by_kind['type']) @@ -2731,6 +3206,7 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non hits = sorted(tests_reaching(q, depth, sets, every=True), key=lambda r: (r[0], r[1], r[2])) out['test_hit'] += [[m, str(d), via, qq] for m, d, via in hits] out['inherited_test'] += [[s_, m, str(d), qq] for s_, m, d in inherited_tests(q, hits)] + out['test_stub'] += [[m, qq] for m in stub_tests(q, depth, sets, stubs, rev=rev)] # gen_fired(q,d,why) — WHICH decoration or shape made the generated rules apply, so the answer can say # why it believes in members that have no declaration. # 143 type · 144 clinit · 145 field (through its owner) · 146 method (through its owner) diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index b2923a602..77f053dd6 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -13,6 +13,11 @@ Read / Grep results as `graph: …` lines. **Trust the answer, and know what it is.** A `[resolved]` / `[sound]` row has already been looked up again in the graph (the `verified:` line): do not re-derive it by grepping. Each answer ends with `next:` — the one step to take. For a CHANGE (who calls it, what breaks, which tests), read only the lines you will cite or change. To EXPLAIN how something works, the graph gives the reading order, not the explanation: read each step's body, and continue through every `⚠` (a call the graph lost). `[by name]` / `[text]` rows are leads, not facts. +**A list of sites comes the way grep prints it.** The MCP `impact`, `path`, `test_impact` and `context` (without +`source` / `explain` / `from_`) answer one site per line: `path:line: [resolved · hop 2 · test …]`, +surest first, capped with a count of the rest; `limit=N` lists more, `full=True` gives the sectioned answer with `next:`. +From the shell the same shape is `--grep` (`--grep-limit N`); without it the answer is the prose. + ## Start here | the question in front of you | the call | @@ -27,7 +32,7 @@ Read / Grep results as `graph: …` lines. | "how does A reach B" · "everything that reaches X" | `axiomcode path A B` · `axiomcode path '*' X` | | "what did my edit touch" · "which tests do I run" | `axiomcode changed --impact` · `axiomcode test-impact` | | "is it safe to delete X" | `axiomcode impact X --delete` | -| the graph as a page for a human · this repo should prefer the graph, once | `axiomcode graph` · `axiomcode install` | +| the graph as a page for a human · this repo should prefer the graph, once | `axiomcode graph` (drawn from the existing graph in seconds; a stale one is rebuilt first with the flags it was indexed with; prints the page's absolute path) · `axiomcode install` | Rules that decide whether an answer means anything: @@ -38,7 +43,8 @@ Rules that decide whether an answer means anything: briefly on its own only when the answer touches an edited file and the rebuild is nearly done. - **Before a delete or a rename, ask with `--fresh`** (MCP `fresh=true`): it waits for the rebuild, printing its progress, and answers from a graph that includes every edit. - A manual `index` with different flags (e.g. dropping `--library`) rebuilds a worse graph over the good one. + A manual `index` with different flags rebuilds a worse graph over the good one. A bare `index`, the background + refresh and `graph` keep the `--lang` (and `--src`, `--library`) the graph was indexed with; pass `--lang` to change it. - A repo in several languages is indexed in all of them, one graph each, and every query asks each graph; calls are not followed from one language to another. `--lang` restricts it, `--src src` narrows it; `--library ` so calls into dependencies resolve (without it they are `ambiguous_unknown` — do not quote that resolution rate). @@ -57,6 +63,7 @@ An answer's label is the **worst** rung on its route. Read it before acting on t | `[defines]` · `[protocol]` · `[decorator by name]` | closure from its definer · interpreter-called method · wrapper rebinding the name | | `[fixture]` · `[at import]` | injected before the test body · module raised on import, test never collected | | `[by key]` | joined through a registration **string** (route, signal, CLI command) — not an edge | +| `[stubs it]` | a call written inside a mock's stub or verification (`when(m.f())`, `verify(m).f()`, `Setup(x => x.F())`, `Received().F()`): names it, runs none of it — never a test route, listed apart | | `[in scope]` · `[by name]` · `[text]` | same name in the owner's scope · same name elsewhere (may be another thing) · text only | Below `[sound]` / `[one of a set]` the order is a tie-break, not a measured ranking. `[sound]` means the edges @@ -81,9 +88,11 @@ keys, injected beans and handlers registered as values — none has a call site. ## changed · test-impact — from an edit -`axiomcode changed [--impact] [--staged | --range a..b]` says how each declaration changed (`signature`, `body`, -`field`, `type`, `removed`, `added`). `axiomcode test-impact [--why]` lists the tests the edit reaches and the -command to run them. It is a **lower bound**: skipping what it does not name is your risk decision, since reflection +`axiomcode changed [--impact] [--staged | --range a..b] […]` says how each declaration changed (`signature`, `body`, +`field`, `type`, `removed`, `added`). `axiomcode test-impact [--why] […]` lists the tests the edit reaches and the +command to run them. For your branch's commits ask `--range ..HEAD`: it reads from the merge-base, so a base +that moved on is not counted as yours. On a copy without git, name the files you edited. Changed fixtures and other +files no graph reads are named, with the tests whose text names them. It is a **lower bound**: skipping what it does not name is your risk decision, since reflection and service loaders are invisible. Detail: `reference/changed-and-tests.md`. ## path — asking the graph @@ -92,6 +101,8 @@ and service loaders are invisible. Detail: `reference/changed-and-tests.md`. (with the unresolved sites that might connect them). Endpoints as written: `Owner.method`, `Type`, `file.ts:123`, `'new File'`, `'@GetMapping'`, `'*'`, or a bare word. A misspelt name stops with the close ones. Detail: `reference/path.md`. +A fact no verb prints (decorations, bases, entry points by reason, field writers): `reference/schema.md` names the table per language. + ## What it cannot see — say so instead of guessing Reflection, string dispatch, event buses; receivers the engine could not type; callbacks invoked by a library; diff --git a/skills/axiomcode/reference/changed-and-tests.md b/skills/axiomcode/reference/changed-and-tests.md index e90684db0..844eb7ad2 100644 --- a/skills/axiomcode/reference/changed-and-tests.md +++ b/skills/axiomcode/reference/changed-and-tests.md @@ -11,6 +11,31 @@ commits (when the graph is at the newer side, the declarations are the new text' `--staged` the index, `--old/--new/--file` two texts of one file. Each line ends with the target `impact` takes for it — a signature with one parameter changed is `Owner.m(param)` — and `--impact` runs impact on all of them as one change set. +What to pass, and what the answer says when the question cannot be answered the way it was asked: + +| situation | ask | what comes back | +|---|---|---| +| uncommitted edits | `changed` · `test-impact` | the edits against the baseline | +| your branch's commits | `changed --range ..HEAD` (MCP `range='..HEAD'`) | read from `git merge-base HEAD`, not from ``'s tip: commits the base branch received after you branched are not yours and are left out. A `note: range base: merge-base …` line says so whenever `` has moved. `a...b` means the same; `a` alone is `a..HEAD` | +| committed work, clean tree | `changed` | `no change …` followed by `next: … HEAD is N commit(s) ahead of — ask --range ..HEAD` | +| a copy without git | `changed` | a refusal: no base to diff against. Name the files instead | +| named files | `changed …` · `test-impact …` (MCP `files=[…]`) | each file's edit; a named file with no edit (or any named file on a copy without git) counts **whole**: every callable declared in it is `named`, and test-impact selects the tests of all of them | +| a file the base does not have | (any) | one line, `added — new file, N declaration(s)`, plus each new declaration something outside the file already calls, with its impact target. Never its parameters or docstring words | +| fixtures, case data, a schema | (any) | named as `outside every indexed language`, never "no change"; test-impact lists the test files whose text names them (the path, the file name, or a quoted directory), as a `[text]` tier, and says when no test names them | + +**A lambda is part of what encloses it.** Every lambda a front end declares carries one name (``), so it is never +the declaration an edit is charged to: an edit inside a lambda in a method is that method's `body` change, and one inside a +field's initializer is that field's. A lambda nothing encloses (an entry in a module-level table) is its own `body` +change, named by where it is, `module.` or `Owner.method.`, and its target is `file:line`; that +name is also a target `impact` and `path` accept. Its parameter list is read from the lambda's own header, so an unchanged +header is never reported as a parameter change. `impact :` on a field, a property, a constant or a type +header line answers for that declaration; a callable written on the line still wins. + +`test-impact` also lists an edited or new **test file** as one to run, and adds it to the command. Code that is also run as a +program (`if __name__ == '__main__'`, `static void main`, `Main`) is looked for by name in the tests, since a test that starts +it as a subprocess or drives it from case data has no call edge to it; when no test names it the answer says the selection is +a lower bound for it. + Measured against 270 real fixes (a Java defect-benchmark arena: the fix applied to the buggy files, the declarations it reports against the benchmark's own scanner's reading of the same hunks, its class-level state expansion taken out): exact agreement on 255, 465 declarations reported for the scanner's 473 — recall 0.968, precision 0.985. Every remaining @@ -55,7 +80,7 @@ absent, never wrong, and the `? n` count says how many. ## test-impact — which tests this edit reaches -`axiomcode test-impact` takes the edit (the working tree by default, `--range a..b` or `--staged`), maps it onto the +`axiomcode test-impact` takes the edit (the working tree by default, `--range a..b`, `--staged`, or named files), maps it onto the declarations through `changed`, asks `impact` which tests reach any of them, and prints the test files with the runner command that runs exactly those. It is `changed` + `impact --tests` with the answer shaped for a pipeline rather than for a reader. @@ -75,5 +100,12 @@ library a `[sound]` route (every hop a single resolved target) was right **29 ti `[by name]` 0 in 1; a `[fixture]` route is right 30 times in 30 on a service where a fixture is the only way in and about 1 in 4 on a framework where every test builds an app. Run the sound rung first, and decide about the rest with the number in front of you. Skipping what it does not name is a decision about risk that this tool cannot make for -you: a test reached only through reflection, a service loader, or a case built at runtime does not appear here. +you: a test reached only through reflection, a service loader, a subprocess, or a case built at runtime does not appear +here (the `[text]` tier above recovers the ones whose test names the file it loads). + +A test that only **stubs** a changed declaration on a mock (`when(repo.find(1))`, `mock.Setup(r => r.Find(1))`) is not +selected for a body edit: it runs none of the body. It is named on a `not selected:` line, and it is selected when the +change is a signature change or a removal, which breaks the stub. A test that reaches the change only through a +framework-entered entry point (an HTTP route, an event, a mediator send) is named on a `NOT COUNTED` line, with the +search that finds it, unless a `[by key]` route already joined it. diff --git a/skills/axiomcode/reference/impact.md b/skills/axiomcode/reference/impact.md index 967f21f51..67e77124f 100644 --- a/skills/axiomcode/reference/impact.md +++ b/skills/axiomcode/reference/impact.md @@ -13,10 +13,11 @@ first uses the type). Several targets in one call are one change set. A name dec Every judgement is a rule in `dl/impact.dl`: the Python side exports facts from graph.sqlite once per graph (members, owners, extends, nesting, decorations, overrides, resolved and unresolved call sites, references with the qualifier written on the line, type references, string literals, tests and fixtures), writes the target and the few text-level facts for the query, and -runs one Soufflé program — compiled to a native binary by `axiomcode index` (~20 s once for all of them, cached by the -program's hash under `dl/.cache/` and shared by every repository on the machine; on first use if the index did not warm it, -the interpreter when there is no `c++`). Warming it at index time is what keeps a caller with a timeout — `hooks/changes.py` -runs impact with `timeout=14` on every edit — from killing the compile before it can finish and caching nothing. +runs one Soufflé program, compiled to a native binary once per machine (45-140 s for impact.dl), cached by the program's +hash under `~/.cache/axiomcode/queries/` and shared by every repository and every plugin copy with the same rules. `axiomcode +index` starts that compile in the background when the build starts; a query never waits for it: until it is done, and when +there is no `c++`, the same program runs in the Soufflé interpreter, with the same answer. The compile runs detached, so a +caller with a timeout (`hooks/changes.py` runs impact with `timeout=14` on every edit) cannot kill it half-way. Direct dependents, the contract, the seeds, the closure, the chains (`parent_up`) and the tests are all derived in the same run; nothing is recomputed a second way. What is verified afterwards is the export: every printed chain hop and every `[resolved]` entry is looked up again in `graph.sqlite` (the `verified:` line). @@ -29,6 +30,9 @@ every printed chain hop and every `[resolved]` entry is looked up again in `grap - **what the container injects** — a type registered as a bean, or a method that defines one, lists the callables the container hands it to (`ctor_param`, a field injection): `receives it by dependency injection — the container hands it over, no call site`. Swapping a `@Bean` implementation reaches its consumers this way. + A class that registers the type from another class (`@EnableConfigurationProperties({T.class})`, a `@MapperScan` + or properties package scan) is listed as `registers it as a bean`, and a configuration class lists who is injected + with the beans its own `@Bean` methods define (`is injected with a bean this class defines`). - **what a framework hands over (Python)**: the engine's `framework_edge` joins a task body to its `.delay()` / `.apply_async()` producer, a `@receiver` to the `send` of the same signal object, a view to its route table, a `Depends()` provider to the handler declaring it, and a fixture to the test naming it. The end that hands over is listed @@ -75,7 +79,9 @@ line and names the constructor query to run; take that suggestion before acting field's value there, through the generated setters). A field's declared or generated setter, a generated constructor. - **reads or uses it** — every callable whose text uses the declaration, grouped by *why* (calls it, reads it, instantiates it, names it in a signature, uses a member imported from it, …) and by *how sure*: `[resolved]` an edge the engine resolved (a call - — `[one of a set]` when it is a multi_inferred target set —, an override, a subtype, a constructor); `[in scope]` a reference by + — `[one of a set]` when it is a multi_inferred target set —, an override, a subtype, a constructor; a call written against + the interface or base method this one implements is a direct row too, worded `calls it (via the interface)` or `(via the + base class)`, and `[resolved]` only when nothing else can run there); `[in scope]` a reference by that name inside the owner type, a subtype or a nested type; `[by name]` a reference by that name elsewhere — the receiver was not typed, so it may be a same-named other thing — including a read written through a variable from a callable with no owner type at all, which is what a module-level function in Python or JavaScript is; `[text]` the name found in the source where the parser records no line (Java @@ -175,6 +181,18 @@ line and names the constructor query to run; take that suggestion before acting of what a service's suite does. The two spellings of a path are matched segment by segment (`/orders/o-1/price` against `/orders/{order_id}/price`, ``, `:id`), never normalised. It is **not** an edge the engine resolved and is never shown as one: the hop is `[by key]`, and a literal can be a same-valued other thing. +- **a stub on a mock is NOT a hop** — `when(repo.find(1))`, `verify(repo).save(x)`, `doReturn(v).when(repo).find(1)`, + `mock.Setup(r => r.Find(1))`, `mock.Verify(...)`, `sub.Received().Find(1)`, `sub.Find(1).Returns(v)`: the engine + resolves the call to the declared method, which is right about the name and wrong about execution, since the receiver + is a mock. Such a site is marked by its position against the mocking library's own call (a knob table per language in + `scripts/ax_edges.py`, `STUB_WRAPPERS`), and it is a `[stubs it]` row: a rename or a new parameter breaks it, a body + change never does. It is kept out of the closure, so a test whose only contact is a stub is not counted under `tests:`; + it is listed on its own `[stubs it]` line, and `test-impact` selects it only for a signature change or a removal. A call + in the stub's ARGUMENT list (`when(repo.find(Ids.first()))`) runs for real and stays a route. A test that drives the + class under test with a mock injected still counts through the class under test: the graph cannot see which object + is injected. An entry point of the change that a framework enters (a route handler, a listener) is named on a + `NOT COUNTED` line with the search that finds the tests driving it, since those are counted only where a `[by key]` + route joins them. - **a decorator that rebinds the name is a hop** — `@audited def summarise(…)` leaves `summarise` denoting what `audited(summarise)` RETURNED, so every caller written with that name runs the wrapper. That is the engine's own resolution (`ext_decorated_name_target`), not a name match, so the hop is `[sound]`; without it a `functools.wraps` diff --git a/skills/axiomcode/reference/schema.md b/skills/axiomcode/reference/schema.md new file mode 100644 index 000000000..b423e757c --- /dev/null +++ b/skills/axiomcode/reference/schema.md @@ -0,0 +1,103 @@ +# schema — which table holds X, per language + +Ask a verb first; open the graph only for a fact no verb prints. Every graph holds ONE language, and the same fact +lives in a different table per language. This page says where, for Python, Java and C#, and what is not recorded +at all, so you stop looking. Measured on a Django app, a Spring Boot app and an ASP.NET app, one fresh index each. + +## Which graph + +| | | +|---|---| +| the main language (most files) | `.axiomcode/out/graph.sqlite`, a symlink to `.axiomcode/out//graph.sqlite` | +| every other language | `.axiomcode/lang//out/graph.sqlite` | +| which one you opened | `sqlite3 -readonly "SELECT value FROM run WHERE key='language'"` | +| tested SQL, caveats, value meanings | the `schema_queries`, `schema_notes`, `schema_vocab` tables in the same file | + +Ids are opaque (`PY_METHOD_…`, `METHOD_REGISTRY_…`, `CS_PROPERTY_…`): join on them, never parse them. `symbols` +holds every declaration of every kind with `file`, `line`, `owner`, `is_test`; `symbols.id` is the id the other +tables use, and `symbols.method_id` / `type_id` join it to `methods` / `types`. + +## Fact by language + +| fact | Python | Java | C# | +|---|---|---|---| +| decoration / annotation / attribute | `decorations`; owner is a method or type | `decorations`; owner is a method, type, field or a **parameter** (`METHOD_PARAMETER_…`, joins nothing) | `decorations`; owner is a method, type or property (`CS_PROPERTY_…`) | +| its name and text | `name` = last dotted segment (`@admin.register(X)` → `register`); `text` = as written, args included | `name` as written after `@`; `text` with args, string quotes tripled (`"""/articles"""`) | `name` as written; `text` = `@Name` **only**, even for `[Endpoint(Name = "x")]`: the arguments are in `literals` at the same file:line | +| base types, resolved | `type_ancestors` (transitive) | `type_ancestors`, library bases included as `types.provenance='external'` | `type_ancestors` (transitive) | +| base types, library / unresolved | **not** in `type_ancestors`: `type_refs` `context='BASE_CLASS'` (last segment only, `Model`) and `ext_type_base_unresolved` (c1 = type id, c3 = text, `models.Model`) | as above; also `type_use` `context='SUPER_TYPE'` with `owner_type_id` | **not** in `type_ancestors`: `type_refs` `context='BASE_LIST'` (name without type args); `ext_type_base_unresolved` c3 = name, but c1 is a declaration group, not a `types.id` | +| entry points | `entry_points(method_id, reason)`: `url`, `orm_hook` seen; rules also emit `http`, `task`, `signal_receiver`, `fixture`, `di_provider`, `grpc_service`. **No** `test` or `main` reason | `test`, `http`, `bean_ctor`, `factory`, `main` seen; also `cli`, `queue`, `scheduled`, `lifecycle`, `spring_factories`; config keys in `ext_config_entry_point` | `test`, `http`, `orm_hook`, `framework_hook`, `main` seen; also `queue`, `grpc_service` | +| field declarations | `symbols` `kind='field'` (`PY_FIELD_…`, `owner` `Form` or `Form.Meta`); `fields` is **empty** | `fields` | `fields` = true fields and consts only; a property is `symbols` `kind='field'` with a `CS_PROPERTY_…` id, and its accessors are `methods` `kind` `PROPERTY_GET` / `PROPERTY_SET` / `PROPERTY_INIT` (`get_X`, `set_X`). In `symbols` every field, const, property and enum member has `owner` = its declaring type (`Outer.Inner` when nested) and `qualified_name` `..` | +| who writes / reads a field | **not recorded**: `field_access` is empty; `refs` `ATTRIBUTE_ACCESS` / `FIELD` is every mention by name and line, read and write alike, with no field id | `field_access` (`access` read / write, `tier`, `caller_id`) | property: `call_edges` `kind` `property_write` / `property_read` to the accessor. Field and const: **not recorded** (`field_access` empty; `refs` `MEMBER_ACCESS` and `NAME_REFERENCE` by name and line, with no field id) | +| call edges | `call_edges`; tiers `known_edge`, `multi_inferred`, `boundary_lib`, `ambiguous_unknown`; kinds `METHOD_CALL`, `SELF_CALL`, `DECORATOR_*`, `PROPERTY_READ`, … | tiers add `ambiguous_anon`; kinds `method`, `new`, `anon_new`, `ctor_delegate`, `ref` | tiers add `known_builtin_operator`, `known_implicit_ctor`; kinds add `property_read`/`_write`, `operator`, `conversion`, `indexer`, `delegate` | +| why a call is unresolved | `ext_call_site_unresolved` (c0 site, c1 caller, c2 reason, c3 call kind) | `unresolved_sites` only, no reason | `ext_site_unresolved_named` (c0 site, c1 receiver type or ``, c2 name) | +| strings in source | `literals(value, file, line)` | `literals`; config keys: `ext_config_binding` (key, mechanism, target kind, target id, owner), `ext_config_class_ref` | `literals` | +| text outside the source (XML, YAML, SQL, …) | **not in the graph**: scanned per query, cached in `.axiomcode/out/dl/nonsource.sqlite` (`files(id, rel)`, `tok(tok, fid)`) | same | same | +| tests | `symbols.is_test` (by file path); no test entry point | `is_test` + `entry_points` `reason='test'` | `is_test` + `entry_points` `reason='test'` | +| test rungs (`[sound]`, `[fixture]`, `[at import]`, …) | **not stored**: computed per query | same | same | + +`field_access`, `type_use` and `type_instantiated` are empty in some languages (`type_use` in Python and C#, +`type_instantiated` in C#): run `SELECT count(*)` before reading an empty answer as "nothing". `overrides` is empty +in Python: its dispatch set is `dispatch_candidates` (basis `mro`). + +## The verb for each fact + +| fact | verb | +|---|---| +| methods carrying a decoration | `path '@login_required' '*'` (or `'@GetMapping'`): the decorated methods and what they reach | +| subtypes of a type | `impact `: "must change with it" | +| who writes a Java field | `impact .`: "produces or writes it" | +| who writes a C# property | `impact .` (or `:`): readers and writers `[resolved]` through its accessors | +| who reads a C# field or const | `impact .`: readers `[in scope]` inside the type, `[by name]` elsewhere, since no C# field access is resolved; `:` of a field answers nothing (no callable spans it), so ask by name | +| a Python field | `impact .` lists readers `[in scope]` / `[by name]` only; there is no writer section, because no writer relation exists | +| text files naming a declaration | `impact X`: "bound from outside the source"; `context ""`: "text files that name these" | +| a config key or a quoted string | `impact app.cache.ttl` · `impact '"some-string"'` | +| test rungs and routes | `impact X --tests-only --why` · `test-impact --why` | +| entry points by reason | no verb: SQL below | + +## Queries + +```sh +G=.axiomcode/out/graph.sqlite +# [all] decorations, with the owner whatever its kind (a Java parameter's owner comes back NULL) +sqlite3 -readonly $G "SELECT d.text, s.kind, s.qualified_name, d.file, d.line FROM decorations d + LEFT JOIN symbols s ON s.id = d.owner_id WHERE d.name = 'GetMapping'" +# [all] entry points by reason, then one reason's methods +sqlite3 -readonly $G "SELECT reason, count(*) FROM entry_points GROUP BY 1" +sqlite3 -readonly $G "SELECT m.qualified_name, m.file_path, m.start_line FROM entry_points e + JOIN methods m ON m.id = e.method_id WHERE e.reason = 'http'" +# [all] resolved ancestors (Java: library ones too, provenance 'external') +sqlite3 -readonly $G "SELECT a.qualified_name, a.provenance FROM type_ancestors x JOIN types t ON t.id = x.type_id + JOIN types a ON a.id = x.ancestor_type_id WHERE t.name = ''" +# [python] library bases, full text as written +sqlite3 -readonly $G "SELECT t.qualified_name, u.c3 FROM ext_type_base_unresolved u JOIN types t ON t.id = u.c1" +# [csharp] library bases: the owner is the innermost type whose span holds the base-list line +sqlite3 -readonly $G "SELECT r.name, (SELECT t.qualified_name FROM types t WHERE t.file_path = r.file + AND r.line BETWEEN t.start_line AND t.end_line ORDER BY t.start_line DESC LIMIT 1) AS owner + FROM type_refs r WHERE r.context = 'BASE_LIST'" +# [java] field writers +sqlite3 -readonly $G "SELECT m.qualified_name, a.file_path, a.start_line, a.tier FROM field_access a + JOIN fields f ON f.id = a.field_id JOIN methods m ON m.id = a.caller_id + WHERE f.owner_qualified_name LIKE '%.' AND f.name = '' AND a.access = 'write'" +# [csharp] property writers (property_read for readers) +sqlite3 -readonly $G "SELECT c.qualified_name, s.file_path, s.start_line FROM call_edges e + JOIN methods t ON t.id = e.callee_method_id JOIN methods c ON c.id = e.caller_id + JOIN call_sites s ON s.id = e.call_site_id WHERE e.kind = 'property_write' AND t.name = 'set_'" +# [all] tiers in this graph; [python] what the unresolved sites are waiting on +sqlite3 -readonly $G "SELECT tier, count(*) FROM call_edges GROUP BY 1" +sqlite3 -readonly $G "SELECT c2, count(*) FROM ext_call_site_unresolved GROUP BY 1 ORDER BY 2 DESC" +``` + +## Traps + +- **Paths.** Java `methods`, `types`, `fields`, `call_sites` and `field_access` hold ABSOLUTE paths; its `symbols`, + `decorations` and `type_refs` hold repo-relative ones, as every Python and C# table does. Match Java with + `LIKE '%/rel/path.java'`. +- **Lines.** Java `type_refs` rows carry `line = 0` in every context but the two annotation ones: locate a Java + base through `type_use` or the type. A C# `BASE_LIST` line is where the base list is written, which is below + `types.start_line` when attributes or a line break come first: join by span, not by equal line. In a graph built + before the field-line fix, every Python field line is one early (0-based): a nested class's first field sits on + its `class Meta:` line. +- **Names.** Python `type_refs` and `decorations` keep only the last dotted segment; the full text is in + `ext_type_base_unresolved.c3` and `decorations.text`. String cells are CSV-escaped: match with `LIKE '%x%'`. +- **`ext_*` tables** have positional columns `c0…cN`; `SELECT description FROM schema_tables WHERE name = ''` + names them. diff --git a/tests/README.md b/tests/README.md index 3f05fdf9a..58fe4e775 100644 --- a/tests/README.md +++ b/tests/README.md @@ -14,10 +14,17 @@ One check needs no graph and is its own script: python3 tests/surfaces.py every dispatched verb is documented on --help, SKILL.md and MCP python3 tests/fastpath.py the hooks' SQL fast path agrees with the rules, shape by shape python3 tests/directive.py the PreToolUse directive hook keeps its promises (never blocks, - never raises, silent without a graph) + never raises, silent without a graph, once per session across + repositories, and names the verb for a declared name it is searched for) + python3 tests/hook_languages.py the edit hooks speak for C# as for Java and Python, from one extension table, + and a body edit's command runs the classes that extend an abstract test base + (indexes a small C# project, so it needs the engine) python3 tests/refresh.py the graph refreshes itself after an edit in every language: a query sees the edit, `changed` answers the same before and after, a burst costs one rebuild and queries during it answer (#1305; builds real graphs, needs the engine) + python3 tests/graph_verb.py `axiomcode graph` draws the existing graph and rebuilds a stale one with the flags it + was indexed with; no rebuild path (refresh, repair, bare index) solves a language an + explicit --lang left out (builds real graphs, needs the engine) python3 tests/indexed_tree.py changed compares against the tree the graph was indexed from, so an index taken with uncommitted edits reports only later edits (#1222) python3 tests/mcp.py `axiomcode mcp` answers initialize, lists every tool and runs one, @@ -61,7 +68,8 @@ One check needs no graph and is its own script: ax_exec.py (#1640; indexes a case, so it needs the engine) python3 tests/multi_language.py a repository in several languages is indexed in all of them and every query asks each graph: nothing dropped, a one-graph answer unchanged, an edit reported once, - refreshed, upgraded from a one-language graph (builds real graphs, needs the engine) + refreshed, upgraded from a one-language graph; a first query or an index stopped + mid-build still leaves the main graph published (builds real graphs, needs the engine) python3 tests/latency.py what a query repeats on every call is done once, with the same answer: the non-source scan cached per graph, a repeated impact solve read back, one tree walk for every language, the verb and the Python found without starting programs, a first build that reports its @@ -73,6 +81,15 @@ One check needs no graph and is its own script: when the last one is: the others are held back, a query answers and names what it cannot see, a first query does not wait for them, one language alone is unchanged (#1555; builds real graphs, needs the engine) + python3 tests/query_rules.py the query rules compile once per machine, never while a query waits: a first query + answers from the interpreter and starts one background compile into the user + cache, a second plugin copy reuses it, rules one line apart do not, an older + plugin's dl/.cache binary is still used (#1606; needs soufflé and c++, no engine) + python3 tests/test_command.py the command test-impact prints for TypeScript/JavaScript runs those files: each with + the runner whose include globs collect it, else its package script, header or + package README, else named as collected by nothing; each runner's config read + as that runner reads it (vitest projects/exclude/vite.config, jest rootDir/ + testRegex/--config, playwright, node --test), each shape with a control (#1570; no engine) python3 tests/tiers.py every call_edges tier the schema documents is ranked, labelled and given a certainty by the frontend, so a new tier cannot read as the weakest claim diff --git a/tests/cases/csharp/callers-through-the-interface/case.json b/tests/cases/csharp/callers-through-the-interface/case.json new file mode 100644 index 000000000..5ef233527 --- /dev/null +++ b/tests/cases/csharp/callers-through-the-interface/case.json @@ -0,0 +1,18 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "a caller holding the interface in a constructor-injected field is a direct caller of the implementation, via the interface, resolved when it is the only one (#1542)", + "run": ["impact", "SqlStore.Save"], + "want": ["[resolved] OrderService.Place", "— calls it (via the interface)"]}, + {"why": "control: a caller that builds the concrete class keeps its plain row", + "run": ["impact", "SqlStore.Save"], + "want": ["[resolved] SelfMade.Place"]}, + {"why": "with two implementations, a call through the interface is one of a set for each, worded as via the interface", + "run": ["impact", "Circle.Area"], + "want": ["[one of a set] ShapeUser.Total", "calls it (via the interface)"]}, + {"why": "near-miss control: a caller whose receiver is typed on the other implementation is not a caller of this one", + "run": ["impact", "Circle.Area"], + "avoid": ["ShapeUser.SquareOnly"]}, + {"why": "the interface method itself: the call through the interface names it, resolved", + "run": ["impact", "IShape.Area"], + "want": ["[resolved] ShapeUser.Total"], + "avoid": ["[one of a set] ShapeUser.Total"]}]} diff --git a/tests/cases/csharp/callers-through-the-interface/src/Shop.csproj b/tests/cases/csharp/callers-through-the-interface/src/Shop.csproj new file mode 100644 index 000000000..d3b2f0306 --- /dev/null +++ b/tests/cases/csharp/callers-through-the-interface/src/Shop.csproj @@ -0,0 +1 @@ +net8.0 diff --git a/tests/cases/csharp/callers-through-the-interface/src/Store.cs b/tests/cases/csharp/callers-through-the-interface/src/Store.cs new file mode 100644 index 000000000..f5d4069c3 --- /dev/null +++ b/tests/cases/csharp/callers-through-the-interface/src/Store.cs @@ -0,0 +1,26 @@ +namespace App; + +public interface IStore +{ + void Save(string k); +} + +public class SqlStore : IStore +{ + public void Save(string k) { } +} + +public interface IShape +{ + double Area(); +} + +public class Circle : IShape +{ + public double Area() => 3.14; +} + +public class Square : IShape +{ + public double Area() => 1.0; +} diff --git a/tests/cases/csharp/callers-through-the-interface/src/Users.cs b/tests/cases/csharp/callers-through-the-interface/src/Users.cs new file mode 100644 index 000000000..e9f76b97a --- /dev/null +++ b/tests/cases/csharp/callers-through-the-interface/src/Users.cs @@ -0,0 +1,37 @@ +namespace App; + +public class OrderService +{ + private readonly IStore _store; + + public OrderService(IStore store) + { + _store = store; + } + + public void Place() + { + _store.Save("k"); + } +} + +public class SelfMade +{ + public void Place() + { + new SqlStore().Save("k"); + } +} + +public class ShapeUser +{ + public double Total(IShape s) + { + return s.Area(); + } + + public double SquareOnly(Square sq) + { + return sq.Area(); + } +} diff --git a/tests/cases/csharp/entry-worded-by-reason/case.json b/tests/cases/csharp/entry-worded-by-reason/case.json new file mode 100644 index 000000000..0fa8bf9f0 --- /dev/null +++ b/tests/cases/csharp/entry-worded-by-reason/case.json @@ -0,0 +1,16 @@ +{ + "lang": "csharp", + "checks": [ + { + "why": "a model-building callback the ORM runs is worded from its reason: no 'a orm_hook entry point', no 'what the outside world can call', and next: names no route to break", + "run": ["impact", "ShopDbContext.OnModelCreating"], + "want": ["is a model hook the ORM or validation library runs — the framework calls it back", "so the framework calls it back: read"], + "avoid": ["orm_hook entry point", "outside world can call", "a change to its name, route,"] + }, + { + "why": "control: an HTTP action is a surface the outside world calls and keeps today's sentence", + "run": ["impact", "ItemsController.Get"], + "want": ["entry point — a framework invokes it, so no client call site reaches it. Changing it changes what the outside world can call.", "entry point, so its callers are outside the graph"] + } + ] +} diff --git a/tests/cases/csharp/entry-worded-by-reason/src/ItemsController.cs b/tests/cases/csharp/entry-worded-by-reason/src/ItemsController.cs new file mode 100644 index 000000000..b8e0a4128 --- /dev/null +++ b/tests/cases/csharp/entry-worded-by-reason/src/ItemsController.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Shop; + +[ApiController] +[Route("items")] +public class ItemsController : ControllerBase +{ + [HttpGet] + public string Get() => "x"; +} diff --git a/tests/cases/csharp/entry-worded-by-reason/src/Shop.csproj b/tests/cases/csharp/entry-worded-by-reason/src/Shop.csproj new file mode 100644 index 000000000..ef8279905 --- /dev/null +++ b/tests/cases/csharp/entry-worded-by-reason/src/Shop.csproj @@ -0,0 +1,3 @@ + + net8.0 + diff --git a/tests/cases/csharp/entry-worded-by-reason/src/ShopDbContext.cs b/tests/cases/csharp/entry-worded-by-reason/src/ShopDbContext.cs new file mode 100644 index 000000000..2c0920cd2 --- /dev/null +++ b/tests/cases/csharp/entry-worded-by-reason/src/ShopDbContext.cs @@ -0,0 +1,11 @@ +using Microsoft.EntityFrameworkCore; + +namespace Shop; + +public class ShopDbContext : DbContext +{ + protected override void OnModelCreating(ModelBuilder builder) + { + builder.HasDefaultSchema("shop"); + } +} diff --git a/tests/cases/csharp/lambda-is-named-by-its-place/case.json b/tests/cases/csharp/lambda-is-named-by-its-place/case.json new file mode 100644 index 000000000..f9860d7bf --- /dev/null +++ b/tests/cases/csharp/lambda-is-named-by-its-place/case.json @@ -0,0 +1,46 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "an edit inside a lambda in a method body is a body change of THAT METHOD, never a signature change of `Owner.`: the lambdas of a class all carry that one name", + "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-in-lambda.txt", "--file", "src/App/Orders.cs"], + "want": ["body Orders.Totals"], + "avoid": ["` is not its header", + "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-expression-body.txt", "--file", "src/App/Orders.cs"], + "want": ["body Orders.Positive"], + "avoid": [""], + "avoid": ["", "declarations"]}, + {"why": "and the name printed for a lambda is a target that answers for that lambda alone", + "run": ["impact", "Orders.Totals."], + "want": ["change: Orders.Totals."], + "avoid": ["", "declarations"]}, + {"why": "file:line on a field line is that field, not `no callable spans`", + "run": ["impact", "src/App/Orders.cs:9"], + "want": ["field _count"], + "avoid": ["no callable spans"]}, + {"why": "file:line on a field whose initializer is a lambda is the field, not the lambda", + "run": ["impact", "src/App/Orders.cs:10"], + "want": ["field _twice"], + "avoid": ["no callable spans", " _twice = x => x * 2; + + public List Totals(List xs) + { + return xs.Where(x => x > 0) + .Select(y => y * 2) + .ToList(); + } + + public int Names() + { + return _count; + } + + public int Positive(List xs) => xs.Count(x => x >= 0); +} diff --git a/tests/cases/csharp/lambda-is-named-by-its-place/new-field-lambda.txt b/tests/cases/csharp/lambda-is-named-by-its-place/new-field-lambda.txt new file mode 100644 index 000000000..abd0dc7c5 --- /dev/null +++ b/tests/cases/csharp/lambda-is-named-by-its-place/new-field-lambda.txt @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace App; + +public class Orders +{ + private int _count = 0; + private readonly Func _twice = x => x * 3; + + public List Totals(List xs) + { + return xs.Where(x => x > 0) + .Select(y => y * 2) + .ToList(); + } + + public int Names() + { + return _count; + } + + public int Positive(List xs) => xs.Count(x => x > 0); +} diff --git a/tests/cases/csharp/lambda-is-named-by-its-place/new-in-lambda.txt b/tests/cases/csharp/lambda-is-named-by-its-place/new-in-lambda.txt new file mode 100644 index 000000000..ebb5e4265 --- /dev/null +++ b/tests/cases/csharp/lambda-is-named-by-its-place/new-in-lambda.txt @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace App; + +public class Orders +{ + private int _count = 0; + private readonly Func _twice = x => x * 2; + + public List Totals(List xs) + { + return xs.Where(x => x >= 0) + .Select(y => y * 2) + .ToList(); + } + + public int Names() + { + return _count; + } + + public int Positive(List xs) => xs.Count(x => x > 0); +} diff --git a/tests/cases/csharp/lambda-is-named-by-its-place/new-named.txt b/tests/cases/csharp/lambda-is-named-by-its-place/new-named.txt new file mode 100644 index 000000000..a19816709 --- /dev/null +++ b/tests/cases/csharp/lambda-is-named-by-its-place/new-named.txt @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace App; + +public class Orders +{ + private int _count = 0; + private readonly Func _twice = x => x * 2; + + public List Totals(List xs) + { + return xs.Where(x => x > 0) + .Select(y => y * 2) + .ToList(); + } + + public int Names() + { + return _count + 1; + } + + public int Positive(List xs) => xs.Count(x => x > 0); +} diff --git a/tests/cases/csharp/lambda-is-named-by-its-place/old.txt b/tests/cases/csharp/lambda-is-named-by-its-place/old.txt new file mode 100644 index 000000000..90a479d5f --- /dev/null +++ b/tests/cases/csharp/lambda-is-named-by-its-place/old.txt @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace App; + +public class Orders +{ + private int _count = 0; + private readonly Func _twice = x => x * 2; + + public List Totals(List xs) + { + return xs.Where(x => x > 0) + .Select(y => y * 2) + .ToList(); + } + + public int Names() + { + return _count; + } + + public int Positive(List xs) => xs.Count(x => x > 0); +} diff --git a/tests/cases/csharp/lambda-is-named-by-its-place/src/App/App.csproj b/tests/cases/csharp/lambda-is-named-by-its-place/src/App/App.csproj new file mode 100644 index 000000000..c3c355498 --- /dev/null +++ b/tests/cases/csharp/lambda-is-named-by-its-place/src/App/App.csproj @@ -0,0 +1,3 @@ + + net8.0 + diff --git a/tests/cases/csharp/lambda-is-named-by-its-place/src/App/Orders.cs b/tests/cases/csharp/lambda-is-named-by-its-place/src/App/Orders.cs new file mode 100644 index 000000000..90a479d5f --- /dev/null +++ b/tests/cases/csharp/lambda-is-named-by-its-place/src/App/Orders.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace App; + +public class Orders +{ + private int _count = 0; + private readonly Func _twice = x => x * 2; + + public List Totals(List xs) + { + return xs.Where(x => x > 0) + .Select(y => y * 2) + .ToList(); + } + + public int Names() + { + return _count; + } + + public int Positive(List xs) => xs.Count(x => x > 0); +} diff --git a/tests/cases/csharp/lambda-is-named-by-its-place/src/App/Widgets.cs b/tests/cases/csharp/lambda-is-named-by-its-place/src/App/Widgets.cs new file mode 100644 index 000000000..dedff2d02 --- /dev/null +++ b/tests/cases/csharp/lambda-is-named-by-its-place/src/App/Widgets.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Linq; + +namespace App; + +public class Widgets +{ + public List Sorted(List ws) + { + return ws.OrderBy(w => w.Length).ToList(); + } +} diff --git a/tests/cases/csharp/member-owner-is-its-type/case.json b/tests/cases/csharp/member-owner-is-its-type/case.json new file mode 100644 index 000000000..d2507329a --- /dev/null +++ b/tests/cases/csharp/member-owner-is-its-type/case.json @@ -0,0 +1,40 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "a field is named by its declaring type, so Type.Field resolves to the field and lists its reader, not an undeclared type name called local (#1445)", + "run": ["impact", "AuditOptions.Limit"], + "want": ["change: field AuditOptions.Limit", "AuditService.Max src/App/AuditService.cs:8"], + "avoid": ["not declared here", "the change is local"]}, + {"why": "a property is named by its declaring type, so Type.Prop reaches its readers and writers through the accessors (#1445)", + "run": ["impact", "AuditOptions.Enabled"], + "want": ["change: field AuditOptions.Enabled", "[resolved] AuditService.IsOn src/App/AuditService.cs:7 — reads it through get_Enabled()", + "[resolved] AuditService.Enable src/App/AuditService.cs:10 — writes it through set_Enabled()"], + "avoid": ["not declared here", "[resolved] RetryService.RetryOn"]}, + {"why": "near-miss control: another type's same-named property is its own declaration, with its own resolved reader", + "run": ["impact", "RetryOptions.Enabled"], + "want": ["change: field RetryOptions.Enabled", "[resolved] RetryService.RetryOn src/App/RetryService.cs:7 — reads it through get_Enabled()"], + "avoid": ["[resolved] AuditService.IsOn", "[resolved] AuditService.Enable", "+1 declarations of that name"]}, + {"why": "a const is named by its declaring type (#1445)", + "run": ["impact", "AuditOptions.SectionName"], + "want": ["change: const AuditOptions.SectionName", "AuditService.Section src/App/AuditService.cs:9"], + "avoid": ["not declared here", "the change is local"]}, + {"why": "a private field is read in scope by its owner's methods, and by no other type", + "run": ["impact", "AuditService._o"], + "want": ["change: field AuditService._o", "[in scope] AuditService.Max"], + "avoid": ["RetryService", "not declared here"]}, + {"why": "a nested type's member is named through its outer type", + "run": ["impact", "Limits.Inner.Cap"], + "want": ["change: field Limits.Inner.Cap", "Limits.Read"], + "avoid": ["not declared here"]}, + {"why": "the bare name answers for both same-named fields, each under its own owner", + "run": ["impact", "Limit", "--kind", "field"], + "want": ["AuditOptions.Limit", "+1 declarations of that name"]}, + {"why": "a read of another type's same-named field is not this field's reader; needs the engine to resolve C# member accesses to fields (field_access is empty for C#), so today it is a [by name] row", + "pending": "C# field_access", + "run": ["impact", "AuditOptions.Limit"], + "avoid": ["RetryService.Attempts"]}, + {"why": "the declaration line of a field is the field, as a property's line is its accessors", + "pending": "file:line on a field declaration", "expect_error": true, + "run": ["impact", "src/App/Options.cs:7"], + "want": ["AuditService.Max"], + "avoid": ["no callable spans"]} + ]} diff --git a/tests/cases/csharp/member-owner-is-its-type/src/App/App.csproj b/tests/cases/csharp/member-owner-is-its-type/src/App/App.csproj new file mode 100644 index 000000000..ec2cce143 --- /dev/null +++ b/tests/cases/csharp/member-owner-is-its-type/src/App/App.csproj @@ -0,0 +1,5 @@ + + + net8.0 + + diff --git a/tests/cases/csharp/member-owner-is-its-type/src/App/AuditService.cs b/tests/cases/csharp/member-owner-is-its-type/src/App/AuditService.cs new file mode 100644 index 000000000..1fbb3b3fd --- /dev/null +++ b/tests/cases/csharp/member-owner-is-its-type/src/App/AuditService.cs @@ -0,0 +1,12 @@ +namespace App; + +public sealed class AuditService +{ + private readonly AuditOptions _o = new(); + + public bool IsOn() => _o.Enabled; + public int Max() => _o.Limit; + public string Section() => AuditOptions.SectionName; + public void Enable() { _o.Enabled = true; } + public AuditOptions Options() => _o; +} diff --git a/tests/cases/csharp/member-owner-is-its-type/src/App/Options.cs b/tests/cases/csharp/member-owner-is-its-type/src/App/Options.cs new file mode 100644 index 000000000..e744a07eb --- /dev/null +++ b/tests/cases/csharp/member-owner-is-its-type/src/App/Options.cs @@ -0,0 +1,25 @@ +namespace App; + +public sealed class AuditOptions +{ + public const string SectionName = "Audit"; + public bool Enabled { get; set; } + public int Limit; +} + +public sealed class RetryOptions +{ + public const string SectionName = "Retry"; + public bool Enabled { get; set; } + public int Limit; +} + +public static class Limits +{ + public sealed class Inner + { + public int Cap; + } + + public static int Read(Inner i) => i.Cap; +} diff --git a/tests/cases/csharp/member-owner-is-its-type/src/App/RetryService.cs b/tests/cases/csharp/member-owner-is-its-type/src/App/RetryService.cs new file mode 100644 index 000000000..f0d21bae8 --- /dev/null +++ b/tests/cases/csharp/member-owner-is-its-type/src/App/RetryService.cs @@ -0,0 +1,10 @@ +namespace App; + +public sealed class RetryService +{ + private readonly RetryOptions _r = new(); + + public bool RetryOn() => _r.Enabled; + public int Attempts() => _r.Limit; + public string RetrySection() => RetryOptions.SectionName; +} diff --git a/tests/cases/csharp/mock-stub-is-not-a-test-route/Shop.Tests/PricesTests.cs b/tests/cases/csharp/mock-stub-is-not-a-test-route/Shop.Tests/PricesTests.cs new file mode 100644 index 000000000..731b67120 --- /dev/null +++ b/tests/cases/csharp/mock-stub-is-not-a-test-route/Shop.Tests/PricesTests.cs @@ -0,0 +1,77 @@ +using System.Threading.Tasks; +using Moq; +using NSubstitute; +using Shop; +using VerifyXunit; +using Xunit; + +namespace Shop.Tests; + +public class MoqStubTests +{ + private readonly Mock _prices = new Mock(); + + public MoqStubTests() + { + _prices.Setup(p => p.Price(1)).Returns(5); + } + + [Fact] + public void StubsInTheConstructor() + { + Assert.NotNull(_prices.Object); + } + + [Fact] + public void VerifiesTheStub() + { + _prices.Verify(p => p.Price(1)); + } +} + +public class SubstituteStubTests +{ + private readonly Prices _prices = Substitute.For(); + + [Fact] + public void StubsWithReturns() + { + _prices.Price(Ids.First()).Returns(4); + _prices.Received().Price(2); + } +} + +public class RealPriceTests +{ + [Fact] + public void RunsThePrice() + { + Assert.Equal(2, new Prices().Price(1)); + } + + [Fact] + public void TotalsWithTheRealPrice() + { + Assert.Equal(3, new Checkout(new Prices()).Total(1)); + } +} + +public class CheckoutWithMockTests +{ + private readonly Mock _prices = new Mock(); + + [Fact] + public void TotalsWithAMockedPrice() + { + Assert.Equal(1, new Checkout(_prices.Object).Total(1)); + } +} + +public class SnapshotTests +{ + [Fact] + public async Task VerifiesASnapshot() + { + await Verifier.Verify(new Prices().Price(3)); + } +} diff --git a/tests/cases/csharp/mock-stub-is-not-a-test-route/Shop/Prices.cs b/tests/cases/csharp/mock-stub-is-not-a-test-route/Shop/Prices.cs new file mode 100644 index 000000000..924339947 --- /dev/null +++ b/tests/cases/csharp/mock-stub-is-not-a-test-route/Shop/Prices.cs @@ -0,0 +1,32 @@ +namespace Shop; + +public class Prices +{ + public virtual int Price(int id) + { + return id * 2; + } +} + +public class Ids +{ + public static int First() + { + return 1; + } +} + +public class Checkout +{ + private readonly Prices _prices; + + public Checkout(Prices prices) + { + _prices = prices; + } + + public int Total(int id) + { + return _prices.Price(id) + 1; + } +} diff --git a/tests/cases/csharp/mock-stub-is-not-a-test-route/case.json b/tests/cases/csharp/mock-stub-is-not-a-test-route/case.json new file mode 100644 index 000000000..05ccdb04b --- /dev/null +++ b/tests/cases/csharp/mock-stub-is-not-a-test-route/case.json @@ -0,0 +1,68 @@ +{ + "lang": "csharp", + "checks": [ + { + "why": "a call written in a Moq Setup/Verify lambda or on an NSubstitute Received()/Returns() runs none of the real method, in a test or in the test class's constructor: those tests are not counted, and the tests that call the real object, directly or through the class under test, are. A stub the engine resolved is listed apart; one in a lambda whose parameter it did not type is only a name, and is neither counted nor listed", + "run": [ + "impact", + "Prices.Price", + "--tests" + ], + "want": [ + "tests: 3 of 7 test method(s)", + "RealPriceTests::RunsThePrice", + "RealPriceTests::TotalsWithTheRealPrice", + "[stubs it] 2 test(s) in 1 file(s), not counted", + "SubstituteStubTests::StubsWithReturns", + "CheckoutWithMockTests::TotalsWithAMockedPrice" + ], + "avoid": [ + "[fixture]", + "MoqStubTests::", + "[by name] 1 test", + "[by name] 2 test", + "callable(s) in test code listed above reach it" + ] + }, + { + "why": "near miss: a snapshot library's Verify(value) takes no lambda, so the call that builds its argument runs and the test is counted", + "run": [ + "impact", + "Prices.Price", + "--tests" + ], + "want": [ + "SnapshotTests::VerifiesASnapshot" + ], + "avoid": [ + "callable(s) in test code listed above reach it" + ] + }, + { + "why": "near miss: a call in the stubbed call's argument list runs for real to build the argument", + "run": [ + "impact", + "Ids.First", + "--tests" + ], + "want": [ + "tests: 1 of 7", + "SubstituteStubTests::StubsWithReturns" + ] + }, + { + "why": "the stubs stay dependents of the declaration, as stubs, so a rename still finds them", + "run": [ + "impact", + "Prices.Price" + ], + "want": [ + "only stub it on a mock", + "[resolved] Checkout.Total" + ], + "avoid": [ + "callable(s) in test code listed above reach it" + ] + } + ] +} diff --git a/tests/cases/csharp/unmodelled-entry-not-local/case.json b/tests/cases/csharp/unmodelled-entry-not-local/case.json index 20324c61d..510128444 100644 --- a/tests/cases/csharp/unmodelled-entry-not-local/case.json +++ b/tests/cases/csharp/unmodelled-entry-not-local/case.json @@ -4,7 +4,7 @@ { "why": "--delete on a hosted service says a framework calls it, not the dead-code verdict (#1446)", "run": ["impact", "SweepWorker", "--kind", "type", "--delete"], - "want": ["NOT SAFE TO ASSUME", "lifecycle entry point (above) — a framework calls it"], + "want": ["NOT SAFE TO ASSUME", "a lifecycle callback (above) — the framework calls it back"], "avoid": ["· nothing", "no dependent at any certainty in this graph. Before deleting"] }, { diff --git a/tests/cases/csharp/using-line-is-not-a-field/case.json b/tests/cases/csharp/using-line-is-not-a-field/case.json new file mode 100644 index 000000000..6c7040099 --- /dev/null +++ b/tests/cases/csharp/using-line-is-not-a-field/case.json @@ -0,0 +1,6 @@ +{"lang": "csharp", "src": "src", + "checks": [ + {"why": "an added `using X;` line declares nothing (it read as a new field X); control: a field added inside the class is still a new field of that class", + "run": ["changed", "{repo}", "--old", "{repo}/old.cs", "--new", "{repo}/new.cs", "--file", "src/Cart.cs"], + "want": ["Cart.limit", "new field"], + "avoid": ["Pricing ", "added Pricing"]}]} diff --git a/tests/cases/csharp/using-line-is-not-a-field/new.cs b/tests/cases/csharp/using-line-is-not-a-field/new.cs new file mode 100644 index 000000000..55d3c051a --- /dev/null +++ b/tests/cases/csharp/using-line-is-not-a-field/new.cs @@ -0,0 +1,16 @@ +using System; +using Pricing; + +namespace Shop +{ + public class Cart + { + private int count; + private int limit; + + public int Count() + { + return count; + } + } +} diff --git a/tests/cases/csharp/using-line-is-not-a-field/old.cs b/tests/cases/csharp/using-line-is-not-a-field/old.cs new file mode 100644 index 000000000..4083f51dc --- /dev/null +++ b/tests/cases/csharp/using-line-is-not-a-field/old.cs @@ -0,0 +1,14 @@ +using System; + +namespace Shop +{ + public class Cart + { + private int count; + + public int Count() + { + return count; + } + } +} diff --git a/tests/cases/csharp/using-line-is-not-a-field/src/Cart.cs b/tests/cases/csharp/using-line-is-not-a-field/src/Cart.cs new file mode 100644 index 000000000..4083f51dc --- /dev/null +++ b/tests/cases/csharp/using-line-is-not-a-field/src/Cart.cs @@ -0,0 +1,14 @@ +using System; + +namespace Shop +{ + public class Cart + { + private int count; + + public int Count() + { + return count; + } + } +} diff --git a/tests/cases/java/abstract-test-base-command/case.json b/tests/cases/java/abstract-test-base-command/case.json new file mode 100644 index 000000000..c724c9901 --- /dev/null +++ b/tests/cases/java/abstract-test-base-command/case.json @@ -0,0 +1,10 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a test declared in an abstract base runs only as part of the classes that extend it, so the command names those; mvn -Dtest= runs nothing", + "run": ["test-impact", "{repo}", "--old", "{repo}/save-old.java", "--new", "{repo}/save-new.java", "--file", "src/pkg/Repo.java"], + "want": ["mvn test -Dtest=JdbcRepoTest,MemoryRepoTest", "AbstractRepoContractTest -> JdbcRepoTest, MemoryRepoTest"], + "avoid": ["-Dtest=AbstractRepoContractTest"]}, + {"why": "control: a CONCRETE base keeps its place in the command (its tests run in it), and the class inheriting the reached test is named too, as the ownerless note says", + "run": ["test-impact", "{repo}", "--old", "{repo}/load-old.java", "--new", "{repo}/load-new.java", "--file", "src/pkg/Repo.java"], + "want": ["mvn test -Dtest=CachedLoadTest,LoadTest"], + "avoid": ["abstract test class"]}]} diff --git a/tests/cases/java/abstract-test-base-command/load-new.java b/tests/cases/java/abstract-test-base-command/load-new.java new file mode 100644 index 000000000..8dc3fd69d --- /dev/null +++ b/tests/cases/java/abstract-test-base-command/load-new.java @@ -0,0 +1,11 @@ +package pkg; + +public class Repo { + public int save(int x) { + return x + 1; + } + + public int load(int x) { + return x - 2; + } +} diff --git a/tests/cases/java/abstract-test-base-command/load-old.java b/tests/cases/java/abstract-test-base-command/load-old.java new file mode 100644 index 000000000..b18efe2e5 --- /dev/null +++ b/tests/cases/java/abstract-test-base-command/load-old.java @@ -0,0 +1,11 @@ +package pkg; + +public class Repo { + public int save(int x) { + return x + 1; + } + + public int load(int x) { + return x - 1; + } +} diff --git a/tests/cases/java/abstract-test-base-command/save-new.java b/tests/cases/java/abstract-test-base-command/save-new.java new file mode 100644 index 000000000..0d0b000b4 --- /dev/null +++ b/tests/cases/java/abstract-test-base-command/save-new.java @@ -0,0 +1,11 @@ +package pkg; + +public class Repo { + public int save(int x) { + return x + 2; + } + + public int load(int x) { + return x - 1; + } +} diff --git a/tests/cases/java/abstract-test-base-command/save-old.java b/tests/cases/java/abstract-test-base-command/save-old.java new file mode 100644 index 000000000..b18efe2e5 --- /dev/null +++ b/tests/cases/java/abstract-test-base-command/save-old.java @@ -0,0 +1,11 @@ +package pkg; + +public class Repo { + public int save(int x) { + return x + 1; + } + + public int load(int x) { + return x - 1; + } +} diff --git a/tests/cases/java/abstract-test-base-command/src/pkg/AbstractRepoContractTest.java b/tests/cases/java/abstract-test-base-command/src/pkg/AbstractRepoContractTest.java new file mode 100644 index 000000000..bc06ab873 --- /dev/null +++ b/tests/cases/java/abstract-test-base-command/src/pkg/AbstractRepoContractTest.java @@ -0,0 +1,12 @@ +package pkg; + +import org.junit.jupiter.api.Test; + +public abstract class AbstractRepoContractTest { + protected abstract Repo repo(); + + @Test + void savesOne() { + repo().save(1); + } +} diff --git a/tests/cases/java/abstract-test-base-command/src/pkg/CachedLoadTest.java b/tests/cases/java/abstract-test-base-command/src/pkg/CachedLoadTest.java new file mode 100644 index 000000000..a2b970655 --- /dev/null +++ b/tests/cases/java/abstract-test-base-command/src/pkg/CachedLoadTest.java @@ -0,0 +1,4 @@ +package pkg; + +public class CachedLoadTest extends LoadTest { +} diff --git a/tests/cases/java/abstract-test-base-command/src/pkg/JdbcRepoTest.java b/tests/cases/java/abstract-test-base-command/src/pkg/JdbcRepoTest.java new file mode 100644 index 000000000..330a54c2a --- /dev/null +++ b/tests/cases/java/abstract-test-base-command/src/pkg/JdbcRepoTest.java @@ -0,0 +1,8 @@ +package pkg; + +public class JdbcRepoTest extends AbstractRepoContractTest { + @Override + protected Repo repo() { + return new Repo(); + } +} diff --git a/tests/cases/java/abstract-test-base-command/src/pkg/LoadTest.java b/tests/cases/java/abstract-test-base-command/src/pkg/LoadTest.java new file mode 100644 index 000000000..92103fa1b --- /dev/null +++ b/tests/cases/java/abstract-test-base-command/src/pkg/LoadTest.java @@ -0,0 +1,10 @@ +package pkg; + +import org.junit.jupiter.api.Test; + +public class LoadTest { + @Test + void loadsOne() { + new Repo().load(1); + } +} diff --git a/tests/cases/java/abstract-test-base-command/src/pkg/MemoryRepoTest.java b/tests/cases/java/abstract-test-base-command/src/pkg/MemoryRepoTest.java new file mode 100644 index 000000000..837c4d5c2 --- /dev/null +++ b/tests/cases/java/abstract-test-base-command/src/pkg/MemoryRepoTest.java @@ -0,0 +1,8 @@ +package pkg; + +public class MemoryRepoTest extends AbstractRepoContractTest { + @Override + protected Repo repo() { + return new Repo(); + } +} diff --git a/tests/cases/java/abstract-test-base-command/src/pkg/Repo.java b/tests/cases/java/abstract-test-base-command/src/pkg/Repo.java new file mode 100644 index 000000000..b18efe2e5 --- /dev/null +++ b/tests/cases/java/abstract-test-base-command/src/pkg/Repo.java @@ -0,0 +1,11 @@ +package pkg; + +public class Repo { + public int save(int x) { + return x + 1; + } + + public int load(int x) { + return x - 1; + } +} diff --git a/tests/cases/java/bean-registered-or-defined-by-a-class/case.json b/tests/cases/java/bean-registered-or-defined-by-a-class/case.json new file mode 100644 index 000000000..6102f0c17 --- /dev/null +++ b/tests/cases/java/bean-registered-or-defined-by-a-class/case.json @@ -0,0 +1,16 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a class registered as a bean by another class's annotation (@EnableConfigurationProperties) names that class as a dependent, not only as a text match", + "run": ["impact", "MailProperties"], + "want": ["registers it as a bean (enabled_properties)", "PropsConfig", "dependency injection (ctor_param)", "Mailer.Mailer"]}, + {"why": "near miss: the same shape registered by nothing has no registration row", + "run": ["impact", "QueueProperties"], + "want": ["Mailer.Mailer"], + "avoid": ["registers it as a bean"]}, + {"why": "a @Configuration class's answer names who is injected with the beans its @Bean methods define; before, it said nothing resolved depended on it", + "run": ["impact", "WidgetConfig"], + "want": ["is injected with a bean this class defines (ctor_param)", "Assembler.Assembler"], + "avoid": ["nothing resolved in the source depends on it"]}, + {"why": "near miss: a plain class whose method returns an injected type defines no bean", + "run": ["impact", "GadgetFactory"], + "avoid": ["is injected with a bean this class defines", "Assembler.Assembler"]}]} diff --git a/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/Gadget.java b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/Gadget.java new file mode 100644 index 000000000..36f9a2b3e --- /dev/null +++ b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/Gadget.java @@ -0,0 +1,5 @@ +package shop.config; + +public class Gadget { + public String name() { return "gadget"; } +} diff --git a/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/GadgetFactory.java b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/GadgetFactory.java new file mode 100644 index 000000000..f33649b0b --- /dev/null +++ b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/GadgetFactory.java @@ -0,0 +1,6 @@ +package shop.config; + +/** Near miss: a method that returns a Gadget, with no @Bean and no @Configuration. */ +public class GadgetFactory { + public Gadget gadget() { return new Gadget(); } +} diff --git a/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/MailProperties.java b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/MailProperties.java new file mode 100644 index 000000000..5bbfe4775 --- /dev/null +++ b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/MailProperties.java @@ -0,0 +1,10 @@ +package shop.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.mail") +public class MailProperties { + private String host; + public String getHost() { return host; } + public void setHost(String host) { this.host = host; } +} diff --git a/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/PropsConfig.java b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/PropsConfig.java new file mode 100644 index 000000000..f367a2d29 --- /dev/null +++ b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/PropsConfig.java @@ -0,0 +1,8 @@ +package shop.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties({MailProperties.class}) +public class PropsConfig { } diff --git a/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/QueueProperties.java b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/QueueProperties.java new file mode 100644 index 000000000..5a4fdf27e --- /dev/null +++ b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/QueueProperties.java @@ -0,0 +1,11 @@ +package shop.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Near miss: the same shape as MailProperties, registered by nothing. */ +@ConfigurationProperties(prefix = "app.queue") +public class QueueProperties { + private int depth; + public int getDepth() { return depth; } + public void setDepth(int depth) { this.depth = depth; } +} diff --git a/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/Widget.java b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/Widget.java new file mode 100644 index 000000000..21da6fc1b --- /dev/null +++ b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/Widget.java @@ -0,0 +1,5 @@ +package shop.config; + +public class Widget { + public String name() { return "widget"; } +} diff --git a/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/WidgetConfig.java b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/WidgetConfig.java new file mode 100644 index 000000000..07486e597 --- /dev/null +++ b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/config/WidgetConfig.java @@ -0,0 +1,10 @@ +package shop.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class WidgetConfig { + @Bean + public Widget widget() { return new Widget(); } +} diff --git a/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/service/Assembler.java b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/service/Assembler.java new file mode 100644 index 000000000..157f0a700 --- /dev/null +++ b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/service/Assembler.java @@ -0,0 +1,18 @@ +package shop.service; + +import org.springframework.stereotype.Service; +import shop.config.Gadget; +import shop.config.Widget; + +@Service +public class Assembler { + private final Widget widget; + private final Gadget gadget; + + public Assembler(Widget widget, Gadget gadget) { + this.widget = widget; + this.gadget = gadget; + } + + public String build() { return widget.name() + gadget.name(); } +} diff --git a/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/service/Mailer.java b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/service/Mailer.java new file mode 100644 index 000000000..ee33ba748 --- /dev/null +++ b/tests/cases/java/bean-registered-or-defined-by-a-class/src/shop/service/Mailer.java @@ -0,0 +1,18 @@ +package shop.service; + +import org.springframework.stereotype.Service; +import shop.config.MailProperties; +import shop.config.QueueProperties; + +@Service +public class Mailer { + private final MailProperties mail; + private final QueueProperties queue; + + public Mailer(MailProperties mail, QueueProperties queue) { + this.mail = mail; + this.queue = queue; + } + + public String host() { return mail.getHost() + queue.getDepth(); } +} diff --git a/tests/cases/java/callers-through-the-interface/case.json b/tests/cases/java/callers-through-the-interface/case.json new file mode 100644 index 000000000..73a686bd0 --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/case.json @@ -0,0 +1,33 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a caller holding the interface in an @Autowired field, which the engine narrowed to the one bean, is a direct caller of the interface method, and the change is not local (#1542)", + "run": ["impact", "WidgetService.build"], + "want": ["WidgetController.get src/app/widgets/WidgetController.java:14 — calls it (via the interface)", "WidgetReport.line"], + "avoid": ["the change is local", "directly touches it: nothing"]}, + {"why": "a call typed on an interface with one implementation names the interface method: resolved, not one of a set", + "run": ["impact", "WidgetService.build"], + "want": ["[resolved] WidgetReport.line"], + "avoid": ["[one of a set] WidgetReport.line"]}, + {"why": "the implementation lists the interface-typed caller as a direct caller via the interface, resolved when it is the only implementation", + "run": ["impact", "WidgetServiceImpl.build"], + "want": ["[resolved] WidgetReport.line src/app/widgets/WidgetReport.java:11 — calls it (via the interface)"]}, + {"why": "an @Autowired interface field the engine wired to the bean receives the injected bean, not an instance it obtained itself (#1384)", + "run": ["impact", "JdbcStore.save"], + "want": ["FieldIface.place src/app/svc/FieldIface.java:10 — calls it on the injected bean", "CtorIface.place src/app/svc/CtorIface.java:10 — calls it on the injected bean", "FieldConcrete.place src/app/svc/FieldConcrete.java:10 — calls it on the injected bean"], + "avoid": ["FieldIface.place src/app/svc/FieldIface.java:10 — calls it on an instance it obtained itself", "CtorIface.place src/app/svc/CtorIface.java:10 — calls it on an instance it obtained itself"]}, + {"why": "control: new JdbcStore() is still an instance the caller obtained itself", + "run": ["impact", "JdbcStore.save"], + "want": ["SelfMade.place src/app/svc/SelfMade.java:6 — calls it on an instance it obtained itself"]}, + {"why": "the interface method lists the callers whose receiver is a field of the interface type, and no caller whose receiver is the concrete class", + "run": ["impact", "Store.save"], + "want": ["CtorIface.place src/app/svc/CtorIface.java:10 — calls it (via the interface)", "FieldIface.place src/app/svc/FieldIface.java:10 — calls it (via the interface)"], + "avoid": ["SelfMade.place src/app/svc/SelfMade.java:6 — calls it", "FieldConcrete.place src/app/svc/FieldConcrete.java:10 — calls it", "directly touches it: nothing"]}, + {"why": "with two implementations, a call through the interface is one of a set for each of them, worded as via the interface", + "run": ["impact", "Circle.area"], + "want": ["[one of a set] ShapeUser.total src/app/shapes/ShapeUser.java:5 — calls it (via the interface)"]}, + {"why": "near-miss control: a caller whose receiver is typed on the OTHER implementation is not a caller of this one", + "run": ["impact", "Circle.area"], + "avoid": ["ShapeUser.squareOnly"]}, + {"why": "control: the other implementation keeps its own concrete caller", + "run": ["impact", "Square.area"], + "want": ["[resolved] ShapeUser.squareOnly src/app/shapes/ShapeUser.java:9 — calls it"]}]} diff --git a/tests/cases/java/callers-through-the-interface/src/app/shapes/Circle.java b/tests/cases/java/callers-through-the-interface/src/app/shapes/Circle.java new file mode 100644 index 000000000..ea7c954c5 --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/shapes/Circle.java @@ -0,0 +1,5 @@ +package app.shapes; + +public class Circle implements Shape { + public double area() { return 3.14; } +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/shapes/Shape.java b/tests/cases/java/callers-through-the-interface/src/app/shapes/Shape.java new file mode 100644 index 000000000..824efb176 --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/shapes/Shape.java @@ -0,0 +1,5 @@ +package app.shapes; + +public interface Shape { + double area(); +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/shapes/ShapeUser.java b/tests/cases/java/callers-through-the-interface/src/app/shapes/ShapeUser.java new file mode 100644 index 000000000..0c2899dae --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/shapes/ShapeUser.java @@ -0,0 +1,17 @@ +package app.shapes; + +public class ShapeUser { + public double total(Shape s) { + return s.area(); + } + + public double squareOnly(Square sq) { + return sq.area(); + } + + public double make() { + Shape a = new Circle(); + Shape b = new Square(); + return a.area() + b.area(); + } +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/shapes/Square.java b/tests/cases/java/callers-through-the-interface/src/app/shapes/Square.java new file mode 100644 index 000000000..48db4d45f --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/shapes/Square.java @@ -0,0 +1,5 @@ +package app.shapes; + +public class Square implements Shape { + public double area() { return 1.0; } +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/store/JdbcStore.java b/tests/cases/java/callers-through-the-interface/src/app/store/JdbcStore.java new file mode 100644 index 000000000..da0420037 --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/store/JdbcStore.java @@ -0,0 +1,8 @@ +package app.store; + +import org.springframework.stereotype.Repository; + +@Repository +public class JdbcStore implements Store { + public void save(String k) { } +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/store/Store.java b/tests/cases/java/callers-through-the-interface/src/app/store/Store.java new file mode 100644 index 000000000..fec90f824 --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/store/Store.java @@ -0,0 +1,5 @@ +package app.store; + +public interface Store { + void save(String k); +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/svc/CtorIface.java b/tests/cases/java/callers-through-the-interface/src/app/svc/CtorIface.java new file mode 100644 index 000000000..2acb67b67 --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/svc/CtorIface.java @@ -0,0 +1,11 @@ +package app.svc; + +import app.store.Store; +import org.springframework.stereotype.Service; + +@Service +public class CtorIface { + private final Store store; + public CtorIface(Store store) { this.store = store; } + public void place() { store.save("k"); } +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/svc/FieldConcrete.java b/tests/cases/java/callers-through-the-interface/src/app/svc/FieldConcrete.java new file mode 100644 index 000000000..ca65bd382 --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/svc/FieldConcrete.java @@ -0,0 +1,11 @@ +package app.svc; + +import app.store.JdbcStore; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class FieldConcrete { + @Autowired private JdbcStore store; + public void place() { store.save("k"); } +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/svc/FieldIface.java b/tests/cases/java/callers-through-the-interface/src/app/svc/FieldIface.java new file mode 100644 index 000000000..bc15a78ca --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/svc/FieldIface.java @@ -0,0 +1,11 @@ +package app.svc; + +import app.store.Store; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class FieldIface { + @Autowired private Store store; + public void place() { store.save("k"); } +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/svc/SelfMade.java b/tests/cases/java/callers-through-the-interface/src/app/svc/SelfMade.java new file mode 100644 index 000000000..44c30140f --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/svc/SelfMade.java @@ -0,0 +1,7 @@ +package app.svc; + +import app.store.JdbcStore; + +public class SelfMade { + public void place() { new JdbcStore().save("k"); } +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetController.java b/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetController.java new file mode 100644 index 000000000..f050f17c0 --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetController.java @@ -0,0 +1,16 @@ +package app.widgets; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class WidgetController { + @Autowired + private WidgetService widgetService; + + @GetMapping("/widgets") + public String get(String name) { + return widgetService.build(name); + } +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetReport.java b/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetReport.java new file mode 100644 index 000000000..0a71a5a01 --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetReport.java @@ -0,0 +1,13 @@ +package app.widgets; + +public class WidgetReport { + private final WidgetService service; + + public WidgetReport(WidgetService service) { + this.service = service; + } + + public String line(String name) { + return service.build(name); + } +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetService.java b/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetService.java new file mode 100644 index 000000000..12976a34d --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetService.java @@ -0,0 +1,5 @@ +package app.widgets; + +public interface WidgetService { + String build(String name); +} diff --git a/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetServiceImpl.java b/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetServiceImpl.java new file mode 100644 index 000000000..c652a7e9a --- /dev/null +++ b/tests/cases/java/callers-through-the-interface/src/app/widgets/WidgetServiceImpl.java @@ -0,0 +1,11 @@ +package app.widgets; + +import org.springframework.stereotype.Service; + +@Service +public class WidgetServiceImpl implements WidgetService { + @Override + public String build(String name) { + return "widget:" + name; + } +} diff --git a/tests/cases/java/context-sql-tie-picks-the-bound-method/case.json b/tests/cases/java/context-sql-tie-picks-the-bound-method/case.json new file mode 100644 index 000000000..2f13107a3 --- /dev/null +++ b/tests/cases/java/context-sql-tie-picks-the-bound-method/case.json @@ -0,0 +1,15 @@ +{"lang": "java", + "checks": [ + {"why": "entry points that match the task's words equally are ordered by what the question asks for, not by symbol ids: a SQL question puts the mapper method its XML binds first, and lists that XML, though four other declarations match the same two words and sort before it by name", + "run": ["context", "which SQL runs when an order is loaded by number"], + "want": ["entry points", " OrderMapper.findByNumber ", "text files that name these declarations", + "src/main/resources/mapper/OrderMapper.xml:3", "names findByNumber (OrderMapper.findByNumber)"], + "avoid": ["WidgetMapper.xml"]}, + {"why": "near miss: the same words without asking about SQL get no text-file section, so the XML is not listed and no tree walk is paid for", + "run": ["context", "where is an order loaded by number"], + "want": ["entry points", "Order.toNumber"], + "avoid": ["text files that name", "OrderMapper.xml", "WidgetMapper.xml"]}, + {"why": "--source with a SQL question prints the code and still lists the XML (the printed code once replaced the question text, and the text-file check crashed on it)", + "run": ["context", "which SQL runs when an order is loaded by number", "--source"], + "want": ["src/main/resources/mapper/OrderMapper.xml:3"], + "avoid": ["Traceback", "WidgetMapper.xml"]}]} diff --git a/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/Order.java b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/Order.java new file mode 100644 index 000000000..dde47d435 --- /dev/null +++ b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/Order.java @@ -0,0 +1,8 @@ +package demo.order; + +public class Order { + public long id; + public String ref; + + public static String toNumber(String ref) { return ref.toUpperCase(); } +} diff --git a/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderAudit.java b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderAudit.java new file mode 100644 index 000000000..6b76a5cd5 --- /dev/null +++ b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderAudit.java @@ -0,0 +1,12 @@ +package demo.order; + +import java.util.HashMap; +import java.util.Map; + +public class OrderAudit { + private final Map seen = new HashMap<>(); + + public Order findByNumber(String number) { + return seen.get(Order.toNumber(number)); + } +} diff --git a/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderCache.java b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderCache.java new file mode 100644 index 000000000..1d16e4a04 --- /dev/null +++ b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderCache.java @@ -0,0 +1,12 @@ +package demo.order; + +import java.util.HashMap; +import java.util.Map; + +public class OrderCache { + private final Map seen = new HashMap<>(); + + public Order findByNumber(String number) { + return seen.get(Order.toNumber(number)); + } +} diff --git a/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderLabel.java b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderLabel.java new file mode 100644 index 000000000..a17d9424f --- /dev/null +++ b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderLabel.java @@ -0,0 +1,12 @@ +package demo.order; + +import java.util.HashMap; +import java.util.Map; + +public class OrderLabel { + private final Map seen = new HashMap<>(); + + public Order findByNumber(String number) { + return seen.get(Order.toNumber(number)); + } +} diff --git a/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderMapper.java b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderMapper.java new file mode 100644 index 000000000..8a015bb82 --- /dev/null +++ b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderMapper.java @@ -0,0 +1,8 @@ +package demo.order; + +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface OrderMapper { + Order findByNumber(String number); +} diff --git a/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderService.java b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderService.java new file mode 100644 index 000000000..0e18d89a6 --- /dev/null +++ b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/java/demo/order/OrderService.java @@ -0,0 +1,11 @@ +package demo.order; + +public class OrderService { + private final OrderMapper mapper; + + public OrderService(OrderMapper mapper) { this.mapper = mapper; } + + public Order resolve(String number) { + return mapper.findByNumber(number); + } +} diff --git a/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/resources/mapper/OrderMapper.xml b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/resources/mapper/OrderMapper.xml new file mode 100644 index 000000000..1d95075e1 --- /dev/null +++ b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/resources/mapper/OrderMapper.xml @@ -0,0 +1,4 @@ + + + + diff --git a/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/resources/mapper/WidgetMapper.xml b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/resources/mapper/WidgetMapper.xml new file mode 100644 index 000000000..21538f20c --- /dev/null +++ b/tests/cases/java/context-sql-tie-picks-the-bound-method/src/main/resources/mapper/WidgetMapper.xml @@ -0,0 +1,4 @@ + + + + diff --git a/tests/cases/java/entry-points-grouped/case.json b/tests/cases/java/entry-points-grouped/case.json index 6e90caec4..a3696e11b 100644 --- a/tests/cases/java/entry-points-grouped/case.json +++ b/tests/cases/java/entry-points-grouped/case.json @@ -1,13 +1,13 @@ {"lang": "java", "src": "src", "sql": ["CREATE TABLE IF NOT EXISTS entry_points (method_id TEXT, reason TEXT)", "DELETE FROM entry_points", - "INSERT INTO entry_points (method_id, reason) SELECT method_id, 'http route' FROM symbols WHERE display LIKE 'Api.%' AND method_id IS NOT NULL AND display <> 'Api.Api'", + "INSERT INTO entry_points (method_id, reason) SELECT method_id, 'http' FROM symbols WHERE display LIKE 'Api.%' AND method_id IS NOT NULL AND display <> 'Api.Api'", "INSERT INTO entry_points (method_id, reason) SELECT method_id, 'test' FROM symbols WHERE display LIKE 'ApiTest.%' AND method_id IS NOT NULL AND display <> 'ApiTest.ApiTest'"], "checks": [ {"why": "four entry points of one reason are explained ONCE and named, not one full sentence each (#1036)", "run": ["impact", "Api", "--kind", "type"], - "want": ["4 http route entry points", "Api.listOrders", "Api.deleteOrder"], - "avoid": ["Api.listOrders is a http route entry point"]}, + "want": ["4 http entry points", "Api.listOrders", "Api.deleteOrder"], + "avoid": ["Api.listOrders is a http entry point"]}, {"why": "test entry points are counted apart from the real ones and not explained one by one (#1036)", "run": ["impact", "Api", "--kind", "type"], "want": ["3 test entry point(s)"], diff --git a/tests/cases/java/entry-worded-by-reason/case.json b/tests/cases/java/entry-worded-by-reason/case.json new file mode 100644 index 000000000..14ced020d --- /dev/null +++ b/tests/cases/java/entry-worded-by-reason/case.json @@ -0,0 +1,16 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "path '*' next: names the one caller once, though it is listed both as the nearest caller and as an entry point (#1389)", + "run": ["path", "*", "Wire.initContext"], + "want": ["the nearest caller is at 1 hop(s): Initializer.initialize", "read it;"], + "avoid": ["callers are", "Initializer.java:4, Initializer.initialize"]}, + {"why": "control: a caller that is not an entry point is named once as before", + "run": ["path", "*", "Wire.other"], + "want": ["the nearest caller is at 1 hop(s): Mid.x"]}, + {"why": "a constructor the container runs is a framework callback: worded from its reason, never the bare token, and never 'what the outside world can call'", + "run": ["impact", "Store.Store"], + "want": ["is a constructor the container runs to build a bean — the framework calls it back", "breaks that framework contract"], + "avoid": ["bean_ctor entry point", "outside world can call", "a change to its name, route,"]}, + {"why": "control: an HTTP route handler is a surface the outside world calls, and keeps today's sentence", + "run": ["impact", "StoreApi.items"], + "want": ["is a http entry point — a framework invokes it, so no client call site reaches it. Changing it changes what the outside world can call.", "entry point, so its callers are outside the graph"]}]} diff --git a/tests/cases/java/entry-worded-by-reason/src/com/example/Initializer.java b/tests/cases/java/entry-worded-by-reason/src/com/example/Initializer.java new file mode 100644 index 000000000..489a88981 --- /dev/null +++ b/tests/cases/java/entry-worded-by-reason/src/com/example/Initializer.java @@ -0,0 +1,5 @@ +package com.example; +public class Initializer { + private final Wire w = new Wire(); + public void initialize() { w.initContext(); } +} diff --git a/tests/cases/java/entry-worded-by-reason/src/com/example/Mid.java b/tests/cases/java/entry-worded-by-reason/src/com/example/Mid.java new file mode 100644 index 000000000..f3587209d --- /dev/null +++ b/tests/cases/java/entry-worded-by-reason/src/com/example/Mid.java @@ -0,0 +1,2 @@ +package com.example; +public class Mid { private final Wire w = new Wire(); public void x() { w.other(); } } diff --git a/tests/cases/java/entry-worded-by-reason/src/com/example/Store.java b/tests/cases/java/entry-worded-by-reason/src/com/example/Store.java new file mode 100644 index 000000000..4d6ba882c --- /dev/null +++ b/tests/cases/java/entry-worded-by-reason/src/com/example/Store.java @@ -0,0 +1,16 @@ +package com.example; + +import org.springframework.stereotype.Component; + +@Component +public class Store { + private final Wire wire; + + public Store(Wire wire) { + this.wire = wire; + } + + public String find(String id) { + return id; + } +} diff --git a/tests/cases/java/entry-worded-by-reason/src/com/example/StoreApi.java b/tests/cases/java/entry-worded-by-reason/src/com/example/StoreApi.java new file mode 100644 index 000000000..efdf6e467 --- /dev/null +++ b/tests/cases/java/entry-worded-by-reason/src/com/example/StoreApi.java @@ -0,0 +1,12 @@ +package com.example; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class StoreApi { + @GetMapping("/items") + public String items() { + return "x"; + } +} diff --git a/tests/cases/java/entry-worded-by-reason/src/com/example/Top.java b/tests/cases/java/entry-worded-by-reason/src/com/example/Top.java new file mode 100644 index 000000000..63d16f77c --- /dev/null +++ b/tests/cases/java/entry-worded-by-reason/src/com/example/Top.java @@ -0,0 +1,2 @@ +package com.example; +public class Top { private final Mid m = new Mid(); public void y() { m.x(); } } diff --git a/tests/cases/java/entry-worded-by-reason/src/com/example/Wire.java b/tests/cases/java/entry-worded-by-reason/src/com/example/Wire.java new file mode 100644 index 000000000..ac78bdc56 --- /dev/null +++ b/tests/cases/java/entry-worded-by-reason/src/com/example/Wire.java @@ -0,0 +1,5 @@ +package com.example; +public class Wire { + public void initContext() { System.out.println("a"); } + public void other() { System.out.println("b"); } +} diff --git a/tests/cases/java/event-reaches-its-listeners/case.json b/tests/cases/java/event-reaches-its-listeners/case.json new file mode 100644 index 000000000..0fb5e9a8b --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/case.json @@ -0,0 +1,24 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a published Spring event reaches the @EventListener for its type: the chain is one event hop, not 'independent'", + "run": ["path", "OrderService.place", "Audit.on"], + "want": ["event_dispatch", "Audit.on"], + "avoid": ["independent in this graph"]}, + {"why": "an ApplicationListener implementation is reached through its type argument", + "run": ["path", "OrderService.ship", "ShipListener.onApplicationEvent"], + "want": ["event_dispatch"]}, + {"why": "the listener's answer names the publisher as an event, not as a value handed over and not as a call that bypasses the container proxy", + "run": ["impact", "Audit.on"], + "want": ["OrderService.place", "OrderService.placeExpress", "sends the request or event this handles"], + "avoid": ["obtained itself", "handed over as a value", "the change is local"]}, + {"why": "and its entry line says the framework runs it for what the publisher rows send, not that no client call site reaches it", + "run": ["impact", "Audit.on"], + "want": ["for the request or event the sender row(s) above send"], + "avoid": ["so no client call site reaches it"]}, + {"why": "control: a listener for a SUBTYPE does not receive the supertype's event", + "run": ["path", "OrderService.place", "Audit.onExpress"], + "expect_error": true, + "want": ["no chain"]}, + {"why": "control: a listener for a type nothing publishes has no publisher", + "run": ["impact", "WidgetListener.onApplicationEvent"], + "avoid": ["OrderService"]}]} diff --git a/tests/cases/java/event-reaches-its-listeners/src/probe/AllEvents.java b/tests/cases/java/event-reaches-its-listeners/src/probe/AllEvents.java new file mode 100644 index 000000000..f02e9bcd8 --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/src/probe/AllEvents.java @@ -0,0 +1,14 @@ +package probe; + +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +// a listener for the framework base type: every published event reaches it, an +// ApplicationEvent subtype (OrderShipped) as itself and a plain object (OrderPlaced) +// wrapped in a PayloadApplicationEvent +@Component +public class AllEvents implements ApplicationListener { + @Override + public void onApplicationEvent(ApplicationEvent event) { } +} diff --git a/tests/cases/java/event-reaches-its-listeners/src/probe/Audit.java b/tests/cases/java/event-reaches-its-listeners/src/probe/Audit.java new file mode 100644 index 000000000..da8938d4b --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/src/probe/Audit.java @@ -0,0 +1,34 @@ +package probe; + +import org.springframework.context.event.EventListener; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.stereotype.Component; + +@Component +public class Audit { + @EventListener + public void on(OrderPlaced e) { record(e.id); } + + @EventListener + public void onExpress(ExpressOrderPlaced e) { record(e.id); } + + @TransactionalEventListener + public void afterCommit(OrderPlaced e) { record("t"); } + + // the event type named on the annotation, no parameter + @EventListener(classes = OrderCancelled.class) + public void onCancelled() { record("c"); } + + // several types named on the annotation, in the array form + @EventListener({OrderPlaced.class, OrderCancelled.class}) + public void onEither() { record("either"); } + + // control: listens for a type nothing publishes + @EventListener + public void onWidget(WidgetBuilt w) { record("w"); } + + // control: the right parameter type, but not a listener + public void direct(OrderPlaced e) { record(e.id); } + + void record(String s) { } +} diff --git a/tests/cases/java/event-reaches-its-listeners/src/probe/ExpressOrderPlaced.java b/tests/cases/java/event-reaches-its-listeners/src/probe/ExpressOrderPlaced.java new file mode 100644 index 000000000..e8829c60a --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/src/probe/ExpressOrderPlaced.java @@ -0,0 +1,6 @@ +package probe; + +// a subtype: a listener for OrderPlaced receives it, a listener for this type does not receive a plain OrderPlaced +public class ExpressOrderPlaced extends OrderPlaced { + public ExpressOrderPlaced(String id) { super(id); } +} diff --git a/tests/cases/java/event-reaches-its-listeners/src/probe/LocalBus.java b/tests/cases/java/event-reaches-its-listeners/src/probe/LocalBus.java new file mode 100644 index 000000000..eafdbb69e --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/src/probe/LocalBus.java @@ -0,0 +1,9 @@ +package probe; + +import org.springframework.stereotype.Component; + +// control: a method called publishEvent on a project type; a call to it is an ordinary call +@Component +public class LocalBus { + public void publishEvent(Object event) { } +} diff --git a/tests/cases/java/event-reaches-its-listeners/src/probe/OrderCancelled.java b/tests/cases/java/event-reaches-its-listeners/src/probe/OrderCancelled.java new file mode 100644 index 000000000..468e17681 --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/src/probe/OrderCancelled.java @@ -0,0 +1,3 @@ +package probe; + +public class OrderCancelled { } diff --git a/tests/cases/java/event-reaches-its-listeners/src/probe/OrderPlaced.java b/tests/cases/java/event-reaches-its-listeners/src/probe/OrderPlaced.java new file mode 100644 index 000000000..fefee7106 --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/src/probe/OrderPlaced.java @@ -0,0 +1,6 @@ +package probe; + +public class OrderPlaced { + public final String id; + public OrderPlaced(String id) { this.id = id; } +} diff --git a/tests/cases/java/event-reaches-its-listeners/src/probe/OrderService.java b/tests/cases/java/event-reaches-its-listeners/src/probe/OrderService.java new file mode 100644 index 000000000..e26ba35f6 --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/src/probe/OrderService.java @@ -0,0 +1,38 @@ +package probe; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + +@Service +public class OrderService { + private final ApplicationEventPublisher publisher; + private final ApplicationContext ctx; + private final Audit audit; + private final LocalBus bus; + + public OrderService(ApplicationEventPublisher publisher, ApplicationContext ctx, Audit audit, LocalBus bus) { + this.publisher = publisher; this.ctx = ctx; this.audit = audit; this.bus = bus; + } + + // a field receiver and a `new` argument + public void place() { publisher.publishEvent(new OrderPlaced("1")); } + + // a subtype of OrderPlaced: reaches the OrderPlaced listeners AND the ExpressOrderPlaced one + public void placeExpress() { publisher.publishEvent(new ExpressOrderPlaced("2")); } + + // the context as publisher, and a local carrying the event + public void ship() { + OrderShipped shipped = new OrderShipped(this); + ctx.publishEvent(shipped); + } + + // a parameter as receiver and as event + public void relay(ApplicationEventPublisher p, OrderCancelled e) { p.publishEvent(e); } + + // control: a direct call, which resolved before this change + public void placeDirect() { audit.direct(new OrderPlaced("3")); } + + // control: a project class with its own publishEvent is not a Spring publisher + public void placeLocal() { bus.publishEvent(new OrderPlaced("4")); } +} diff --git a/tests/cases/java/event-reaches-its-listeners/src/probe/OrderShipped.java b/tests/cases/java/event-reaches-its-listeners/src/probe/OrderShipped.java new file mode 100644 index 000000000..3a2df8ee7 --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/src/probe/OrderShipped.java @@ -0,0 +1,7 @@ +package probe; + +import org.springframework.context.ApplicationEvent; + +public class OrderShipped extends ApplicationEvent { + public OrderShipped(Object source) { super(source); } +} diff --git a/tests/cases/java/event-reaches-its-listeners/src/probe/ShipListener.java b/tests/cases/java/event-reaches-its-listeners/src/probe/ShipListener.java new file mode 100644 index 000000000..c87bd1206 --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/src/probe/ShipListener.java @@ -0,0 +1,12 @@ +package probe; + +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +@Component +public class ShipListener implements ApplicationListener { + @Override + public void onApplicationEvent(OrderShipped event) { notifyWarehouse(); } + + void notifyWarehouse() { } +} diff --git a/tests/cases/java/event-reaches-its-listeners/src/probe/WidgetBuilt.java b/tests/cases/java/event-reaches-its-listeners/src/probe/WidgetBuilt.java new file mode 100644 index 000000000..9b629bee8 --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/src/probe/WidgetBuilt.java @@ -0,0 +1,8 @@ +package probe; + +import org.springframework.context.ApplicationEvent; + +// an unrelated event type: nothing in this case publishes it +public class WidgetBuilt extends ApplicationEvent { + public WidgetBuilt(Object source) { super(source); } +} diff --git a/tests/cases/java/event-reaches-its-listeners/src/probe/WidgetListener.java b/tests/cases/java/event-reaches-its-listeners/src/probe/WidgetListener.java new file mode 100644 index 000000000..a37b021ff --- /dev/null +++ b/tests/cases/java/event-reaches-its-listeners/src/probe/WidgetListener.java @@ -0,0 +1,11 @@ +package probe; + +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +// control: an ApplicationListener for a type nothing publishes +@Component +public class WidgetListener implements ApplicationListener { + @Override + public void onApplicationEvent(WidgetBuilt event) { } +} diff --git a/tests/cases/java/insert-inside-body/case.json b/tests/cases/java/insert-inside-body/case.json new file mode 100644 index 000000000..a7c52bfac --- /dev/null +++ b/tests/cases/java/insert-inside-body/case.json @@ -0,0 +1,10 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "lines added after an inner block's closing brace, or after a comment, inside a method are that method's body change, not anonymous new lines", + "run": ["changed", "{repo}", "--old", "{repo}/old.java", "--new", "{repo}/new.java", "--file", "src/pkg/Svc.java"], + "want": ["body Svc.build"], + "avoid": ["new line(s)", "Svc.other"]}, + {"why": "control: a method inserted after a method's closing brace is an addition, and the method above it is not a body change", + "run": ["changed", "{repo}", "--old", "{repo}/old.java", "--new", "{repo}/new-between.java", "--file", "src/pkg/Svc.java"], + "want": ["added", "Svc.extra"], + "avoid": ["body Svc.build"]}]} diff --git a/tests/cases/java/insert-inside-body/new-between.java b/tests/cases/java/insert-inside-body/new-between.java new file mode 100644 index 000000000..993f4f274 --- /dev/null +++ b/tests/cases/java/insert-inside-body/new-between.java @@ -0,0 +1,20 @@ +package pkg; +public class Svc { + public int build(int n) { + int r = 0; + if (n > 0) { + r = n; + } + + // keep r + return r; + } + + public int extra() { + return 2; + } + + public int other() { + return 1; + } +} diff --git a/tests/cases/java/insert-inside-body/new.java b/tests/cases/java/insert-inside-body/new.java new file mode 100644 index 000000000..e03a9f87f --- /dev/null +++ b/tests/cases/java/insert-inside-body/new.java @@ -0,0 +1,18 @@ +package pkg; +public class Svc { + public int build(int n) { + int r = 0; + if (n > 0) { + r = n; + } + r += 2; + + // keep r + r *= 1; + return r; + } + + public int other() { + return 1; + } +} diff --git a/tests/cases/java/insert-inside-body/old.java b/tests/cases/java/insert-inside-body/old.java new file mode 100644 index 000000000..d056266cc --- /dev/null +++ b/tests/cases/java/insert-inside-body/old.java @@ -0,0 +1,16 @@ +package pkg; +public class Svc { + public int build(int n) { + int r = 0; + if (n > 0) { + r = n; + } + + // keep r + return r; + } + + public int other() { + return 1; + } +} diff --git a/tests/cases/java/insert-inside-body/src/pkg/Svc.java b/tests/cases/java/insert-inside-body/src/pkg/Svc.java new file mode 100644 index 000000000..d056266cc --- /dev/null +++ b/tests/cases/java/insert-inside-body/src/pkg/Svc.java @@ -0,0 +1,16 @@ +package pkg; +public class Svc { + public int build(int n) { + int r = 0; + if (n > 0) { + r = n; + } + + // keep r + return r; + } + + public int other() { + return 1; + } +} diff --git a/tests/cases/java/lambda-is-named-by-its-place/case.json b/tests/cases/java/lambda-is-named-by-its-place/case.json new file mode 100644 index 000000000..0ed46cac3 --- /dev/null +++ b/tests/cases/java/lambda-is-named-by-its-place/case.json @@ -0,0 +1,22 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "an edit inside a lambda in a method body is a body change of that method", + "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-in-lambda.txt", "--file", "src/pkg/Orders.java"], + "want": ["body Orders.totals"], + "avoid": [" totals(List xs) { + return xs.stream() + .filter(x -> x >= 0) + .map(y -> y * 2) + .collect(Collectors.toList()); + } + + public int names() { + return limit; + } +} diff --git a/tests/cases/java/lambda-is-named-by-its-place/new-named.txt b/tests/cases/java/lambda-is-named-by-its-place/new-named.txt new file mode 100644 index 000000000..59236a586 --- /dev/null +++ b/tests/cases/java/lambda-is-named-by-its-place/new-named.txt @@ -0,0 +1,19 @@ +package pkg; + +import java.util.List; +import java.util.stream.Collectors; + +public class Orders { + private int limit = 3; + + public List totals(List xs) { + return xs.stream() + .filter(x -> x > 0) + .map(y -> y * 2) + .collect(Collectors.toList()); + } + + public int names() { + return limit + 1; + } +} diff --git a/tests/cases/java/lambda-is-named-by-its-place/old.txt b/tests/cases/java/lambda-is-named-by-its-place/old.txt new file mode 100644 index 000000000..22005c333 --- /dev/null +++ b/tests/cases/java/lambda-is-named-by-its-place/old.txt @@ -0,0 +1,19 @@ +package pkg; + +import java.util.List; +import java.util.stream.Collectors; + +public class Orders { + private int limit = 3; + + public List totals(List xs) { + return xs.stream() + .filter(x -> x > 0) + .map(y -> y * 2) + .collect(Collectors.toList()); + } + + public int names() { + return limit; + } +} diff --git a/tests/cases/java/lambda-is-named-by-its-place/src/pkg/Orders.java b/tests/cases/java/lambda-is-named-by-its-place/src/pkg/Orders.java new file mode 100644 index 000000000..22005c333 --- /dev/null +++ b/tests/cases/java/lambda-is-named-by-its-place/src/pkg/Orders.java @@ -0,0 +1,19 @@ +package pkg; + +import java.util.List; +import java.util.stream.Collectors; + +public class Orders { + private int limit = 3; + + public List totals(List xs) { + return xs.stream() + .filter(x -> x > 0) + .map(y -> y * 2) + .collect(Collectors.toList()); + } + + public int names() { + return limit; + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/case.json b/tests/cases/java/mock-stub-is-not-a-test-route/case.json new file mode 100644 index 000000000..a5678ad4f --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/case.json @@ -0,0 +1,94 @@ +{ + "lang": "java", + "src": "src", + "checks": [ + { + "why": "a call written inside when(...), verify(x).m() or doReturn(v).when(x).m() on a mock runs none of the real method, and neither does a route through a type the test class holds as a @Mock: those tests are not counted, and a test that calls the real object, directly or through the class under test, is", + "run": [ + "impact", + "PriceService.price", + "--tests" + ], + "want": [ + "tests: 2 of 6 test method(s)", + "PriceServiceTest::runsThePrice", + "CheckoutTest::totalsWithTheRealPrice", + "[stubs it] 3 test(s) in 3 file(s), not counted", + "CheckoutStubTest::stubsThePrice", + "FixtureStubTest::usesTheStubbedPrice", + "CheckoutWithMockTest::totalsWithAMockedPrice", + "a type they hold as a mock (PriceService)" + ], + "avoid": [ + "[sound] 3", + "[sound] 4", + "[fixture]", + "callable(s) in test code listed above reach it" + ] + }, + { + "why": "near miss: the class under test is not itself mocked, so the mock-holding test still reaches it", + "run": [ + "impact", + "Checkout.total", + "--tests" + ], + "want": [ + "tests: 2 of 6", + "CheckoutWithMockTest::totalsWithAMockedPrice", + "CheckoutTest::totalsWithTheRealPrice" + ] + }, + { + "why": "the stub stays a dependent of the declaration: a rename or a new parameter breaks it, so it is listed, as a stub and not a call", + "run": [ + "impact", + "PriceService.price" + ], + "want": [ + "only stub it on a mock", + "[resolved] Checkout.total" + ], + "avoid": [ + "callable(s) in test code listed above reach it" + ] + }, + { + "why": "near miss: a call written in the stub's ARGUMENT list runs for real to build the argument, so the stubbing test reaches it", + "run": [ + "impact", + "Ids.first", + "--tests" + ], + "want": [ + "tests: 1 of 6", + "CheckoutStubTest::stubsThePrice" + ] + }, + { + "why": "near miss: an HTTP test DSL's when() is a request step, not a stub, so a client call in its receiver chain is a route", + "run": [ + "impact", + "Payloads.make", + "--tests" + ], + "want": [ + "tests: 1 of 6", + "RequestTest::postsAPayload" + ] + }, + { + "why": "a stub is not a hop of a chain either: the stubbing test has no path to the real method", + "run": [ + "path", + "CheckoutStubTest.stubsThePrice", + "PriceService.price" + ], + "expect_error": true, + "avoid": [ + "CheckoutStubTest.stubsThePrice \u2192 PriceService.price" + ], + "want": [] + } + ] +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Checkout.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Checkout.java new file mode 100644 index 000000000..35af28e59 --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Checkout.java @@ -0,0 +1,13 @@ +package shop; + +public class Checkout { + private final PriceService prices; + + public Checkout(PriceService prices) { + this.prices = prices; + } + + public int total(int id) { + return prices.price(id) + 1; + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutStubTest.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutStubTest.java new file mode 100644 index 000000000..87cf69f8b --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutStubTest.java @@ -0,0 +1,19 @@ +package shop; + +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +public class CheckoutStubTest { + @Mock private PriceService prices; + + @Test + public void stubsThePrice() { + when(prices.price(Ids.first())).thenReturn(5); + verify(prices).price(1); + doReturn(3).when(prices).price(2); + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutTest.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutTest.java new file mode 100644 index 000000000..4f09f55ec --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutTest.java @@ -0,0 +1,12 @@ +package shop; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +public class CheckoutTest { + @Test + public void totalsWithTheRealPrice() { + assertEquals(3, new Checkout(new PriceService()).total(1)); + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutWithMockTest.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutWithMockTest.java new file mode 100644 index 000000000..194eec1a8 --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/CheckoutWithMockTest.java @@ -0,0 +1,15 @@ +package shop; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +public class CheckoutWithMockTest { + @Mock private PriceService prices; + + @Test + public void totalsWithAMockedPrice() { + assertEquals(1, new Checkout(prices).total(1)); + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/FixtureStubTest.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/FixtureStubTest.java new file mode 100644 index 000000000..52690baed --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/FixtureStubTest.java @@ -0,0 +1,28 @@ +package shop; + +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +public class FixtureStubTest { + @Mock private PriceService prices; + + @BeforeEach + public void setUp() { + stubPrices(); + } + + private void stubPrices() { + when(prices.price(7)).thenReturn(0); + } + + @Test + public void usesTheStubbedPrice() { + assertStubbed(); + } + + private void assertStubbed() { + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Ids.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Ids.java new file mode 100644 index 000000000..125db5ddc --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Ids.java @@ -0,0 +1,7 @@ +package shop; + +public class Ids { + public static int first() { + return 1; + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Payloads.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Payloads.java new file mode 100644 index 000000000..c04c801ff --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/Payloads.java @@ -0,0 +1,7 @@ +package shop; + +public class Payloads { + public static String make() { + return "{}"; + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/PriceService.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/PriceService.java new file mode 100644 index 000000000..58731b080 --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/PriceService.java @@ -0,0 +1,7 @@ +package shop; + +public class PriceService { + public int price(int id) { + return id * 2; + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/PriceServiceTest.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/PriceServiceTest.java new file mode 100644 index 000000000..942b28ca1 --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/PriceServiceTest.java @@ -0,0 +1,13 @@ +package shop; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +public class PriceServiceTest { + @Test + public void runsThePrice() { + PriceService prices = new PriceService(); + assertEquals(2, prices.price(1)); + } +} diff --git a/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/RequestTest.java b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/RequestTest.java new file mode 100644 index 000000000..83621ebbf --- /dev/null +++ b/tests/cases/java/mock-stub-is-not-a-test-route/src/shop/RequestTest.java @@ -0,0 +1,12 @@ +package shop; + +import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; + +import org.junit.jupiter.api.Test; + +public class RequestTest { + @Test + public void postsAPayload() { + given().body(Payloads.make()).when().post("/orders").then().statusCode(200); + } +} diff --git a/tests/cases/java/new-type-below-type/case.json b/tests/cases/java/new-type-below-type/case.json new file mode 100644 index 000000000..7227e5afc --- /dev/null +++ b/tests/cases/java/new-type-below-type/case.json @@ -0,0 +1,6 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a type written below another type's closing brace is its own, with its own fields; an added import line declares nothing. Control: a field added inside the class is still that class's", + "run": ["changed", "{repo}", "--old", "{repo}/old.java", "--new", "{repo}/new.java", "--file", "src/pkg/A.java"], + "want": ["added B ", "B.LIMIT", "A.y"], + "avoid": ["A.B", "A.LIMIT", "Map", "util"]}]} diff --git a/tests/cases/java/new-type-below-type/new.java b/tests/cases/java/new-type-below-type/new.java new file mode 100644 index 000000000..a48d72cf4 --- /dev/null +++ b/tests/cases/java/new-type-below-type/new.java @@ -0,0 +1,19 @@ +package pkg; + +import java.util.List; +import java.util.Map; + +public class A { + int x; + + void f(List xs) { + x = xs.size(); + } + + int y; +} + +interface B { + int LIMIT = 3; + void m(); +} diff --git a/tests/cases/java/new-type-below-type/old.java b/tests/cases/java/new-type-below-type/old.java new file mode 100644 index 000000000..6a4d08cf2 --- /dev/null +++ b/tests/cases/java/new-type-below-type/old.java @@ -0,0 +1,11 @@ +package pkg; + +import java.util.List; + +public class A { + int x; + + void f(List xs) { + x = xs.size(); + } +} diff --git a/tests/cases/java/new-type-below-type/src/pkg/A.java b/tests/cases/java/new-type-below-type/src/pkg/A.java new file mode 100644 index 000000000..6a4d08cf2 --- /dev/null +++ b/tests/cases/java/new-type-below-type/src/pkg/A.java @@ -0,0 +1,11 @@ +package pkg; + +import java.util.List; + +public class A { + int x; + + void f(List xs) { + x = xs.size(); + } +} diff --git a/tests/cases/java/new-type-between-types/case.json b/tests/cases/java/new-type-between-types/case.json new file mode 100644 index 000000000..af722ab6c --- /dev/null +++ b/tests/cases/java/new-type-between-types/case.json @@ -0,0 +1,6 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a type added between two others is its own, with its own members: the diff put it before the upper type's closing brace, which named its field and method as members of that type", + "run": ["changed", "{repo}", "--old", "{repo}/old.java", "--new", "{repo}/new.java", "--file", "src/pkg/A.java"], + "want": ["added C ", "C.MAX", "C.file"], + "avoid": ["A.MAX", "A.file", "D.MAX"]}]} diff --git a/tests/cases/java/new-type-between-types/new.java b/tests/cases/java/new-type-between-types/new.java new file mode 100644 index 000000000..b09c1b3b4 --- /dev/null +++ b/tests/cases/java/new-type-between-types/new.java @@ -0,0 +1,15 @@ +package pkg; + +public class A { + int x; +} + +interface C { + int MAX = 1; + String file(); +} + +/** the D type */ +class D { + int z; +} diff --git a/tests/cases/java/new-type-between-types/old.java b/tests/cases/java/new-type-between-types/old.java new file mode 100644 index 000000000..53d441c3f --- /dev/null +++ b/tests/cases/java/new-type-between-types/old.java @@ -0,0 +1,10 @@ +package pkg; + +public class A { + int x; +} + +/** the D type */ +class D { + int z; +} diff --git a/tests/cases/java/new-type-between-types/src/pkg/A.java b/tests/cases/java/new-type-between-types/src/pkg/A.java new file mode 100644 index 000000000..53d441c3f --- /dev/null +++ b/tests/cases/java/new-type-between-types/src/pkg/A.java @@ -0,0 +1,10 @@ +package pkg; + +public class A { + int x; +} + +/** the D type */ +class D { + int z; +} diff --git a/tests/cases/java/next-step-reads-the-whole-answer/case.json b/tests/cases/java/next-step-reads-the-whole-answer/case.json index 5d439f594..aa4993759 100644 --- a/tests/cases/java/next-step-reads-the-whole-answer/case.json +++ b/tests/cases/java/next-step-reads-the-whole-answer/case.json @@ -115,7 +115,7 @@ ] }, { - "why": "a method reached only through the generic abstract base it overrides (the base's own body calls the abstract method) names that caller in next:, the caller the reach above printed, instead of calling the change local", + "why": "a method reached only through the generic abstract base it overrides (the base's own body calls the abstract method) names that caller in next:, at the call written against the base (a direct caller via the base class, #1542), instead of calling the change local", "run": [ "impact", "RowExtractor.add" @@ -123,7 +123,7 @@ "want": [ "how each is reached", "Extractor.extractData", - "place(s) bound to it: src/com/example/Extractor.java:14" + "place(s) bound to it: src/com/example/Extractor.java:19" ], "avoid": [ "the change is local" @@ -159,10 +159,10 @@ ] }, { - "why": "a publisher and a listener joined only through a library call are not called independent, and the call is named", + "why": "a publisher and a listener joined only through a library call are not called independent, and the call is named (an event typed Object matches no listener's declared type, so the event hop is not drawn)", "run": [ "path", - "OrderService.place", + "OrderService.relay", "Audit.on" ], "expect_error": true, @@ -182,7 +182,7 @@ "run": [ "path", "Audit.on", - "OrderService.place" + "OrderService.relay" ], "expect_error": true, "want": [ diff --git a/tests/cases/java/next-step-reads-the-whole-answer/src/com/example/OrderService.java b/tests/cases/java/next-step-reads-the-whole-answer/src/com/example/OrderService.java index f1567e34a..7d242e8b2 100644 --- a/tests/cases/java/next-step-reads-the-whole-answer/src/com/example/OrderService.java +++ b/tests/cases/java/next-step-reads-the-whole-answer/src/com/example/OrderService.java @@ -8,4 +8,6 @@ public class OrderService { public OrderService(ApplicationEventPublisher publisher, Helper helper) { this.publisher = publisher; this.helper = helper; } public void place(String id) { publisher.publishEvent(new OrderPlaced(id)); } public void direct(String id) { helper.run(id); } + // the event is typed Object here, so no listener type can be matched to it statically + public void relay(Object payload) { publisher.publishEvent(payload); } } diff --git a/tests/cases/java/route-under-type-prefix-and-verb/case.json b/tests/cases/java/route-under-type-prefix-and-verb/case.json new file mode 100644 index 000000000..c1fb67b9e --- /dev/null +++ b/tests/cases/java/route-under-type-prefix-and-verb/case.json @@ -0,0 +1,13 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a handler's route is its type's @RequestMapping prefix and its own mapping, and it is joined only to the requests that send its HTTP method: the three GETs of /orders drive list, the two POSTs do not", + "run": ["impact", "OrdersController.list", "--tests"], + "want": ["tests: 3 of 6 test method(s)", "OrdersListTest::listsOrders", "[by key]"], + "avoid": ["OrdersCreateTest"]}, + {"why": "near miss: the same path under the other verb reaches the other handler, and five tests writing one path are not refused as a key that identifies nothing, since each handler is written by fewer than the cap", + "run": ["impact", "OrdersController.create", "--tests"], + "want": ["tests: 2 of 6 test method(s)", "OrdersCreateTest::createsAnOrder", "OrdersCreateTest::rejectsAnEmptyOrder"], + "avoid": ["OrdersListTest", "OrdersCreateTest::addsALine"]}, + {"why": "a relative method path joins the type's prefix: @PostMapping(\"lines\") under /orders serves /orders/lines", + "run": ["impact", "OrdersController.addLine", "--tests"], + "want": ["tests: 1 of 6", "OrdersCreateTest::addsALine"]}]} diff --git a/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersController.java b/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersController.java new file mode 100644 index 000000000..758e470f3 --- /dev/null +++ b/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersController.java @@ -0,0 +1,25 @@ +package shop; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/orders") +public class OrdersController { + @GetMapping + public String list() { + return "[]"; + } + + @PostMapping + public String create() { + return "{}"; + } + + @PostMapping("lines") + public String addLine() { + return "{}"; + } +} diff --git a/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersCreateTest.java b/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersCreateTest.java new file mode 100644 index 000000000..0301211e1 --- /dev/null +++ b/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersCreateTest.java @@ -0,0 +1,22 @@ +package shop; + +import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; + +import org.junit.jupiter.api.Test; + +public class OrdersCreateTest { + @Test + public void createsAnOrder() { + given().body("{}").when().post("/orders").then().statusCode(201); + } + + @Test + public void rejectsAnEmptyOrder() { + given().body("").when().post("/orders").then().statusCode(422); + } + + @Test + public void addsALine() { + given().body("{}").when().post("/orders/lines").then().statusCode(201); + } +} diff --git a/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersListTest.java b/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersListTest.java new file mode 100644 index 000000000..8b2fc7762 --- /dev/null +++ b/tests/cases/java/route-under-type-prefix-and-verb/src/shop/OrdersListTest.java @@ -0,0 +1,22 @@ +package shop; + +import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; + +import org.junit.jupiter.api.Test; + +public class OrdersListTest { + @Test + public void listsOrders() { + given().when().get("/orders").then().statusCode(200); + } + + @Test + public void listsOrdersAgain() { + given().when().get("/orders").then().statusCode(200); + } + + @Test + public void listsOrdersPaged() { + given().param("page", 2).when().get("/orders").then().statusCode(200); + } +} diff --git a/tests/cases/javascript/field-initializer-is-a-sibling/case.json b/tests/cases/javascript/field-initializer-is-a-sibling/case.json new file mode 100644 index 000000000..17ac8a0d2 --- /dev/null +++ b/tests/cases/javascript/field-initializer-is-a-sibling/case.json @@ -0,0 +1,13 @@ +{"lang": "javascript", "src": "src", + "checks": [ + {"why": "a static field initializer defines only what is written in it, not the methods of its class", "run": ["path", "Registry.", "tick"], "want": ["no chain of resolved calls connects"], "avoid": ["defines"], "expect_error": true}, + {"why": "nor does an instance field initializer", "run": ["path", "Panel.", "tick"], "want": ["no chain of resolved calls connects"], "avoid": ["defines"], "expect_error": true}, + {"why": "a method written between two initialized fields is their sibling, not written inside them", "run": ["path", "Mixed.", "tick"], "want": ["no chain of resolved calls connects"], "avoid": ["defines"], "expect_error": true}, + {"why": "control: an arrow handed to a call in the initializer is still reached from it, and a function inside a method is not", "run": ["path", "Host.", "tick"], "want": ["callback_registered", "", "tick"], "avoid": ["defines", "helper"]}, + {"why": "control: a function declared inside a method is still defined by it", "run": ["path", "Host.go", "tick"], "want": ["Host.go", "helper", "tick"]}, + {"why": "a one-line static initializer defines the object-literal method it hands to a call", "run": ["path", "ObjLit.", "tick"], "want": ["[defines", "run", "tick"]}, + {"why": "and the method of a class expression written in it", "run": ["path", "ClsExpr.", "tick"], "want": ["[defines", ".m", "tick"]}, + {"why": "a one-line instance initializer defines the method of the plain object it holds", "run": ["path", "PlainObj.", "tick"], "want": ["[defines", "onClick", "tick"]}, + {"why": "and of the object literal it hands to a call", "run": ["path", "InstObj.", "tick"], "want": ["[defines", "fetch", "tick"]}, + {"why": "the line of such an initializer names the callable written inside it, not both", "run": ["impact", "src/app.js:32"], "want": ["change: run (at src/app.js:32)"], "avoid": ["callables at src/app.js:32"]}, + {"why": "control: on a one-line class the method beside the initializer, and a function after the class, stay its siblings", "run": ["path", "OneLine.", "tick"], "want": ["no chain of resolved calls connects"], "avoid": ["defines"], "expect_error": true}]} diff --git a/tests/cases/javascript/field-initializer-is-a-sibling/src/app.js b/tests/cases/javascript/field-initializer-is-a-sibling/src/app.js new file mode 100644 index 000000000..97fab3bda --- /dev/null +++ b/tests/cases/javascript/field-initializer-is-a-sibling/src/app.js @@ -0,0 +1,53 @@ +function boot() { return 1; } +function tick() { return 2; } +function wrap(f) { return f; } + +class Registry { + static count = boot(); + add(x) { return tick(); } + remove(x) { return tick(); } +} + +class Panel { + size = boot(); + open() { return tick(); } + close() { return tick(); } +} + +class Mixed { + static a = boot(); + run() { return tick(); } + static b = boot(); + stop() { return tick(); } +} + +class Host { + static cb = wrap(() => tick()); + go() { function helper() { return tick(); } return helper(); } +} + +function make(o) { return o; } + +class ObjLit { + static cfg = make({ run() { return tick(); } }); + other() { return 1; } +} + +class ClsExpr { + static Inner = wrap(class { m() { return tick(); } }); + other() { return 1; } +} + +class PlainObj { + handlers = { onClick() { return tick(); } }; + other() { return 1; } +} + +class InstObj { + api = make({ fetch() { return tick(); } }); + other() { return 1; } +} + +class OneLine { static n = boot(); m() { return tick(); } } function after() { return tick(); } + +module.exports = { Registry, Panel, Mixed, Host, ObjLit, ClsExpr, PlainObj, InstObj, OneLine, after }; diff --git a/tests/cases/javascript/fileline-dotted-basename/case.json b/tests/cases/javascript/fileline-dotted-basename/case.json index 4bcedc31f..051783940 100644 --- a/tests/cases/javascript/fileline-dotted-basename/case.json +++ b/tests/cases/javascript/fileline-dotted-basename/case.json @@ -15,4 +15,29 @@ {"why": "control: a dotted basename under a directory the index does not have is still refused", "run": ["impact", "zzz/order.service.js:3"], "expect_error": true, - "want": ["no callable spans zzz/order.service.js:3"]}]} + "want": ["no callable spans zzz/order.service.js:3"]}, + {"why": "a file:line with a leading ./ (as an editor prints it) is the same declaration", + "run": ["impact", "./src/services/userService.js:2"], + "want": ["change: findUser (at ./src/services/userService.js:2)", "placeOrder"], + "avoid": ["no callable spans"]}, + {"why": "an absolute path under the repository root is read relative to that root", + "run": ["impact", "{repo}/src/services/order.service.js:3"], + "want": ["change: placeOrder", "createOrder"], + "avoid": ["no callable spans", "outside the indexed repository"]}, + {"why": "file:line:col, the Node stack-trace form: the column does not change the declaration", + "run": ["impact", "{repo}/src/services/userService.js:2:5"], + "want": ["change: findUser", "placeOrder"], + "avoid": ["no callable spans"]}, + {"why": "path takes the same normalised file:line", + "run": ["path", "*", "./src/services/userService.js:2:5"], + "want": ["findUser", "placeOrder"], + "avoid": ["no callable spans"]}, + {"why": "control: a root-relative /src/... still answers, as before", + "run": ["impact", "/src/services/userService.js:2"], + "want": ["change: findUser"], + "avoid": ["no callable spans", "outside the indexed repository"]}, + {"why": "an absolute path outside the repository is named as outside, not as a line nothing spans", + "run": ["impact", "/nowhere-axiomcode/src/services/userService.js:2"], + "expect_error": true, + "want": ["is outside the indexed repository"], + "avoid": ["no callable spans"]}]} diff --git a/tests/cases/javascript/fileline-partial-path-two-files/case.json b/tests/cases/javascript/fileline-partial-path-two-files/case.json new file mode 100644 index 000000000..c3203ebc2 --- /dev/null +++ b/tests/cases/javascript/fileline-partial-path-two-files/case.json @@ -0,0 +1,38 @@ +{"lang": "javascript", + "checks": [ + {"why": "a basename matching two indexed files is refused with both files named, not answered for the one whose function at that line is shorter (#1599)", + "run": ["impact", "order.service.js:2"], + "expect_error": true, + "want": ["matches 2 indexed files", "src/services/order.service.js", "lib/services/order.service.js"]}, + {"why": "the same through path, with a dir/file suffix", + "run": ["path", "*", "services/order.service.js:2"], + "expect_error": true, + "want": ["matches 2 indexed files", "src/services/order.service.js", "lib/services/order.service.js"], + "avoid": ["0 method(s)"]}, + {"why": "the same without a dot in the basename, where the src function is the shorter one", + "run": ["impact", "userService.js:2"], + "expect_error": true, + "want": ["matches 2 indexed files", "src/services/userService.js", "lib/services/userService.js"]}, + {"why": "the same through path", + "run": ["path", "*", "services/userService.js:2"], + "expect_error": true, + "want": ["matches 2 indexed files"]}, + {"why": "control: the full path to the file with callers answers for it, with its caller", + "run": ["impact", "src/services/order.service.js:2"], + "want": ["change: placeOrder (at src/services/order.service.js:2)", "createOrder"]}, + {"why": "control: the full path to the other file answers for that one alone", + "run": ["impact", "lib/services/order.service.js:2"], + "want": ["change: placeOrder (at lib/services/order.service.js:2)"], + "avoid": ["createOrder", "matches 2 indexed files"]}, + {"why": "control: path from everything into the full path finds the caller", + "run": ["path", "*", "src/services/order.service.js:2"], + "want": ["createOrder"], + "avoid": ["matches 2 indexed files"]}, + {"why": "control: a partial path that matches one indexed file still answers", + "run": ["impact", "controllers/orders.controller.js:4"], + "want": ["change: createOrder"], + "avoid": ["matches 2 indexed files"]}, + {"why": "control: a partial path matching no indexed file is still refused as before", + "run": ["impact", "zzz/order.service.js:2"], + "expect_error": true, + "want": ["no callable spans zzz/order.service.js:2"]}]} diff --git a/tests/cases/javascript/fileline-partial-path-two-files/lib/services/order.service.js b/tests/cases/javascript/fileline-partial-path-two-files/lib/services/order.service.js new file mode 100644 index 000000000..5d67a55d2 --- /dev/null +++ b/tests/cases/javascript/fileline-partial-path-two-files/lib/services/order.service.js @@ -0,0 +1,4 @@ +function placeOrder() { + return null; +} +module.exports = { placeOrder }; diff --git a/tests/cases/javascript/fileline-partial-path-two-files/lib/services/userService.js b/tests/cases/javascript/fileline-partial-path-two-files/lib/services/userService.js new file mode 100644 index 000000000..ee701fa1f --- /dev/null +++ b/tests/cases/javascript/fileline-partial-path-two-files/lib/services/userService.js @@ -0,0 +1,6 @@ +function findUser(id) { + const key = String(id); + const row = { id: key }; + return row; +} +module.exports = { findUser }; diff --git a/tests/cases/javascript/fileline-partial-path-two-files/src/controllers/orders.controller.js b/tests/cases/javascript/fileline-partial-path-two-files/src/controllers/orders.controller.js new file mode 100644 index 000000000..1fb502cc7 --- /dev/null +++ b/tests/cases/javascript/fileline-partial-path-two-files/src/controllers/orders.controller.js @@ -0,0 +1,7 @@ +const { placeOrder } = require('../services/order.service'); + +async function createOrder(req, res) { + res.json(await placeOrder(req.params.id)); +} + +module.exports = { createOrder }; diff --git a/tests/cases/javascript/fileline-partial-path-two-files/src/services/order.service.js b/tests/cases/javascript/fileline-partial-path-two-files/src/services/order.service.js new file mode 100644 index 000000000..475246937 --- /dev/null +++ b/tests/cases/javascript/fileline-partial-path-two-files/src/services/order.service.js @@ -0,0 +1,6 @@ +const { findUser } = require('./userService'); +async function placeOrder(userId) { + const user = findUser(userId); + return { user }; +} +module.exports = { placeOrder }; diff --git a/tests/cases/javascript/fileline-partial-path-two-files/src/services/userService.js b/tests/cases/javascript/fileline-partial-path-two-files/src/services/userService.js new file mode 100644 index 000000000..70b9d91b5 --- /dev/null +++ b/tests/cases/javascript/fileline-partial-path-two-files/src/services/userService.js @@ -0,0 +1,4 @@ +function findUser(id) { + return { id }; +} +module.exports = { findUser }; diff --git a/tests/cases/javascript/one-line-siblings-are-not-nested/case.json b/tests/cases/javascript/one-line-siblings-are-not-nested/case.json new file mode 100644 index 000000000..0dc75683d --- /dev/null +++ b/tests/cases/javascript/one-line-siblings-are-not-nested/case.json @@ -0,0 +1,31 @@ +{"lang": "javascript", "src": "src", + "checks": [ + {"why": "a method-shorthand member on the arrow's line is not declared in the arrow: a caller of Keys.a does not reach b", + "run": ["path", "main", "b"], + "want": ["no chain of resolved calls connects"], + "avoid": ["defines"], "expect_error": true}, + {"why": "a function declared after an arrow on its line is its sibling", + "run": ["path", "run", "helper"], + "want": ["no chain of resolved calls connects"], + "avoid": ["defines"], "expect_error": true}, + {"why": "a function expression written after a named function on its line is not inside it: a caller of first does not reach what the expression calls", + "run": ["path", "callFirst", "ping"], + "want": ["no chain of resolved calls connects"], + "avoid": ["defines"], "expect_error": true}, + {"why": "an arrow written after a named arrow on its line is not inside it", + "run": ["path", "callNamed", "ping"], + "want": ["no chain of resolved calls connects"], + "avoid": ["defines"], "expect_error": true}, + {"why": "impact on helper names its own caller, and not run's", + "run": ["impact", "helper"], + "want": ["callHelper"], + "avoid": ["main", "callFirst", "callNamed"]}, + {"why": "control: a function expression written inside a function on the same line is still defined by it", + "run": ["path", "callOuter", "pong"], + "want": ["[defines · not a call] ", "pong"]}, + {"why": "control: an arrow written inside a named arrow on the same line is still defined by it", + "run": ["path", "callWrap", "pong"], + "want": ["[defines · not a call] ", "pong"]}, + {"why": "control: the real route through the arrow under a key is kept", + "run": ["path", "main", "ping"], + "want": ["Keys.a", "ping"]}]} diff --git a/tests/cases/javascript/one-line-siblings-are-not-nested/src/keys.js b/tests/cases/javascript/one-line-siblings-are-not-nested/src/keys.js new file mode 100644 index 000000000..05d13afe2 --- /dev/null +++ b/tests/cases/javascript/one-line-siblings-are-not-nested/src/keys.js @@ -0,0 +1,14 @@ +function ping(x) { return x; } +function pong(x) { return x; } +const Keys = { a: () => ping(1), b() { return 2; } }; +function main() { Keys.a(); } +const run = () => ping(2); function helper() { return pong(3); } +function callHelper() { helper(); } +function first() { return 4; } const mapped = [1].map(function (v) { return ping(v); }); +function callFirst() { first(); } +const named = () => pong(5); const listed = [2].map(() => ping(6)); +function callNamed() { named(); } +function outer() { return function (v) { return pong(v); }; } +function callOuter() { outer(); } +const wrap = () => () => pong(7); +function callWrap() { wrap(); } diff --git a/tests/cases/javascript/one-line-siblings-are-not-nested/src/package.json b/tests/cases/javascript/one-line-siblings-are-not-nested/src/package.json new file mode 100644 index 000000000..95aa4705a --- /dev/null +++ b/tests/cases/javascript/one-line-siblings-are-not-nested/src/package.json @@ -0,0 +1 @@ +{"name": "one-line-siblings", "version": "1.0.0"} diff --git a/tests/cases/javascript/value-callee-is-unknown/case.json b/tests/cases/javascript/value-callee-is-unknown/case.json new file mode 100644 index 000000000..723d51064 --- /dev/null +++ b/tests/cases/javascript/value-callee-is-unknown/case.json @@ -0,0 +1,170 @@ +{ + "lang": "javascript", + "src": "src", + "checks": [ + { + "why": "invoke calls a parameter: the callee can be any function handed to it, so path says unknown and names the site (#1649)", + "run": [ + "path", + "invoke", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "`cb()` in invoke at src/handlers.js:6 — the callee is a parameter", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "dispatch calls a computed member (table[k]()): the callee can be any function handed to it, so path says unknown and names the site (#1649)", + "run": [ + "path", + "dispatch", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "at src/handlers.js:5 — the callee is a computed member", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "runList calls a for-of loop variable: the callee can be any function handed to it, so path says unknown and names the site (#1649)", + "run": [ + "path", + "runList", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "`f()` in runList at src/handlers.js:7 — the callee is a loop variable", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "runAll calls an arrow's parameter inside forEach: the callee can be any function handed to it, so path says unknown and names the site (#1649)", + "run": [ + "path", + "runAll", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "`h()` in at src/handlers.js:4 — the callee is a parameter", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "viaLocal calls a local with no value: the callee can be any function handed to it, so path says unknown and names the site (#1649)", + "run": [ + "path", + "viaLocal", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "`fn()` in viaLocal at src/handlers.js:8 — the callee is a local", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "viaCall calls a parameter through .call: the callee can be any function handed to it, so path says unknown and names the site (#1649)", + "run": [ + "path", + "viaCall", + "alpha" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "at src/handlers.js:9 — the callee is a parameter", + "not shown to be independent" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "the reverse question is the same connection: the value call is on the other side (#1649)", + "run": [ + "path", + "alpha", + "invoke" + ], + "expect_error": true, + "want": [ + "connection is UNKNOWN, not absent", + "`cb()` in invoke" + ], + "avoid": [ + "the two are independent in this graph" + ] + }, + { + "why": "control: a function that calls nothing stays independent", + "run": [ + "path", + "pure", + "alpha" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] + }, + { + "why": "control: a call through a required module binding names a module member, not a value", + "run": [ + "path", + "joins", + "alpha" + ], + "expect_error": true, + "want": [ + "the two are independent in this graph" + ], + "avoid": [ + "connection is UNKNOWN, not absent", + "through a value" + ] + }, + { + "why": "control: a resolved call is still a chain", + "run": [ + "path", + "direct", + "alpha" + ], + "want": [ + "direct → alpha: 1 of 1 target(s) reached" + ], + "avoid": [ + "connection is UNKNOWN, not absent" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/javascript/value-callee-is-unknown/src/handlers.js b/tests/cases/javascript/value-callee-is-unknown/src/handlers.js new file mode 100644 index 000000000..9fa522427 --- /dev/null +++ b/tests/cases/javascript/value-callee-is-unknown/src/handlers.js @@ -0,0 +1,13 @@ +'use strict'; +const path = require('path'); +function alpha() { return 'a'; } +function runAll(handlers) { handlers.forEach(h => h()); } +function dispatch(table, k) { return table[k](); } +function invoke(cb) { return cb(); } +function runList(ctx) { for (const f of ctx.list) f(); } +function viaLocal(o) { const fn = o.pick; return fn(); } +function viaCall(cb) { return cb.call(null); } +function pure() { return 1 + 2; } +function joins() { return path.join('a', 'b'); } +function direct() { return alpha(); } +module.exports = { alpha, runAll, dispatch, invoke, runList, viaLocal, viaCall, pure, joins, direct }; diff --git a/tests/cases/javascript/wrapped-handler-route/case.json b/tests/cases/javascript/wrapped-handler-route/case.json new file mode 100644 index 000000000..e4c85a01f --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/case.json @@ -0,0 +1,113 @@ +{"lang": "javascript", "src": "src", + "checks": [ + {"why": "a handler written as `const listAdmins = catchAsync(async (req, res) => …)` is a const, not a method, so the route wording the method target gets never applied: the registration `router.get('/admins', listAdmins)` was printed as '[by name] … reads it'. The const's declaration hands a function to the wrapper and the route line hands one to the router, both engine edges", + "run": ["impact", "src/controllers/adminController.js:2"], + "want": ["const listAdmins", "[registered] admin.", "registered as a GET route \"/admins\"", "src/routes/admin.js:5"], + "avoid": ["[by name] admin. src/routes/admin.js:5"]}, + {"why": "the same when the const is declared in the file that registers it: the read was [in scope] and still not a route", + "run": ["impact", "localList"], + "want": ["[registered] local.", "registered as a GET route \"/local\""], + "avoid": ["reads it"]}, + {"why": "a file:line on a const targets the const. It used to resolve to the enclosing (other.js:2) or to the arrow in the initializer (adminController.js:2), so a const could only be asked by its bare name, which answers for every const so named", + "run": ["impact", "src/routes/other.js:2"], + "want": ["const listAdmins [field]", "[in scope] other. src/routes/other.js:3"], + "avoid": ["other. (at src/routes/other.js:2)", "registered as a GET route"]}, + {"why": "and a file that declares its own const of the name reads its own, not the wrapped handler's: other.js's `['root']` is not a reader of the controller's listAdmins", + "run": ["impact", "src/controllers/adminController.js:2"], + "avoid": ["other. src/routes/other.js:3"]}, + {"why": "CONTROL: a plain function handler keeps the wording it already had, from the method rule", + "run": ["impact", "plainAdmins"], + "want": ["[registered] admin.", "registered as a GET route \"/admins-plain\""]}, + {"why": "CONTROL: `const arrowAdmins = async (req, res) => …` IS the function, so its file:line stays the method, with the method's route row", + "run": ["impact", "src/controllers/arrowController.js:1"], + "want": ["arrowAdmins (at src/controllers/arrowController.js:1) [method]", "registered as a GET route \"/admins-arrow\""]}, + {"why": "CONTROL: a const that holds a value, read inside an inline handler on a route line, is a read — the inline arrow hands nothing over and the const's initializer is not a function", + "run": ["impact", "adminCount"], + "want": ["[by name] src/routes/admin.js:7 — reads it"], + "avoid": ["registered as a"]}, + {"why": "the handler read through its module (`.get(auth(), adminController.listAdmins)` after `.route(…)`) is registered there too: it was '[by name] … writes/reads it'", + "run": ["impact", "src/controllers/adminController.js:2"], + "want": ["[registered] qualified.", "src/routes/qualified.js:5"], + "avoid": ["writes/reads it"]}, + {"why": "a chained registration split over lines (`router\\n .route('/admins-c')\\n .get(auth(), adminController.listAdmins)`) records its hand-off on the statement's first line and names the handler two lines down: registered there too", + "run": ["impact", "src/controllers/adminController.js:2"], + "want": ["[registered] chained.", "src/routes/chained.js:7"], + "avoid": ["chained. src/routes/chained.js:7 — writes/reads it"]}, + {"why": "a service that declares its own function of the name reads its own in its exports, not the controller's const", + "run": ["impact", "src/controllers/adminController.js:2"], + "avoid": ["src/services/adminService.js"]}, + {"why": "a file:line in a file whose name carries a dot (`report.controller.js`) is a file and a line: the dots were read as a package prefix and every such target was refused", + "run": ["impact", "src/controllers/report.controller.js:2"], + "want": ["const listReports [field]"], + "avoid": ["matches no package"]}, + {"why": "CONTROL: a file:line in a file that is not in the graph is still refused", + "run": ["impact", "src/controllers/nope.controller.js:2"], + "expect_error": true, + "avoid": ["[field]", "[method]"]}, + {"why": "the registered row claims the hand-off edge on the route line, so `verified:` finds it: it printed '✗ 1 not found' for every such row", + "run": ["impact", "src/controllers/adminController.js:2"], + "want": ["all present"], + "avoid": ["not found"]}, + {"why": "`router.route('/reports').get(listReports)` is a GET route: the `route` call on the same line named the method ROUTE", + "run": ["impact", "src/controllers/report.controller.js:2"], + "want": ["[registered] routed.", "registered as a GET route \"/reports\""], + "avoid": ["ROUTE route", "not found"]}, + {"why": "a chain registering two methods on one line names both: a reference records a line, not the argument list it sat in", + "run": ["impact", "plainAdmins"], + "want": ["registered as a GET/POST route \"/both\""], + "avoid": ["ROUTE route"]}, + {"why": "a handler wrapped by a LIBRARY (`asyncHandler` from a package) has no engine edge for what the wrapper returns, so the row keeps the name's rung but says what the line does: it was '[by name] … reads it'", + "run": ["impact", "src/controllers/libController.js:2"], + "want": ["[by name] lib. src/routes/lib.js:5 — registered as a GET route \"/lib\""], + "avoid": ["src/routes/lib.js:5 — reads it", "[registered]"]}, + {"why": "CONTROL: a line that also hands over a plain function (`.post(libList, plainAdmins)`) is not evidence for the const: the hand-off is plainAdmins', so the const's row stays [by name]", + "run": ["impact", "src/controllers/libController.js:2"], + "want": ["registered as a POST route \"/lib-mixed\""], + "avoid": ["[registered] lib."]}, + {"why": "CONTROL: a const holding a module (`const adminController = require(…)`) read on a route line is read there, not registered", + "run": ["impact", "src/routes/chained.js:2"], + "avoid": ["registered as a"]}, + {"why": "CONTROL: a qualified read of a const holding a value, off any route line, stays a read", + "run": ["impact", "adminCount"], + "want": ["src/routes/qualified.js:6 — writes/reads it"], + "avoid": ["registered as a"]}, + {"why": "a const passed to a validation middleware on a route line (`validate(bodySchema)`) is an argument of that call, not a handler the router calls; and a schema built by a library (`Joi.object({…})`) is not a function even where it sits in a handler position (`.post('/schema-direct', bodySchema, h)`)", + "run": ["impact", "bodySchema"], + "want": ["[in scope] validated. src/routes/validated.js:8 — reads it"], + "avoid": ["registered as a"]}, + {"why": "an options object passed positionally (`router.post('/opts', routeOptions, h)`) is not callable: read, not registered", + "run": ["impact", "routeOptions"], + "want": ["reads it"], + "avoid": ["registered as a"]}, + {"why": "a path prefix held in a const (`router.use(PREFIX, h)`) is a string, not a handler", + "run": ["impact", "PREFIX"], + "avoid": ["registered as a"]}, + {"why": "a router mounted on another (`apiRouter.use('/admin', adminRoutes)`, `app.use('/api', apiRouter)`) is a mount, and the receiver of a route call is not its handler: `express.Router()` returns no function the graph knows of", + "run": ["impact", "apiRouter"], + "want": ["reads it"], + "avoid": ["registered as a"]}, + {"why": "the app itself, the receiver of every `app.use(…)`, is read there, not registered", + "run": ["impact", "src/app.js:3"], + "want": ["reads it"], + "avoid": ["registered as a"]}, + {"why": "a const passed into a nested call on a route line (`express.static(publicDir)`) is that call's argument", + "run": ["impact", "publicDir"], + "avoid": ["registered as a"]}, + {"why": "a router module's own router const, the receiver of each route line (`router.get('/admins', listAdmins)`), is read on those lines, not registered on them, here or in the other files that declare a `router` of their own", + "run": ["impact", "src/routes/admin.js:4"], + "want": ["[in scope] admin. src/routes/admin.js:5 — reads it"], + "avoid": ["registered as a"]}, + {"why": "a wrapped handler handed over inside an array of handlers (`.get('/array', [validate(s), ctrl.listAdmins])`) is registered: a router flattens the array", + "run": ["impact", "src/controllers/adminController.js:2"], + "want": ["registered as a GET route \"/array\""]}, + {"why": "a const that aliases a declared function (`const aliasOf = ctrl.plainAdmins`) holds that function, and the route line hands it over", + "run": ["impact", "aliasOf"], + "want": ["[registered] validated. src/routes/validated.js:17 — registered as a GET route \"/aliased\""]}, + {"why": "a declaration whose wrapper call starts on the next line (`const multiLine =` then `catchAsync(…)`) holds the wrapped function all the same", + "run": ["impact", "multiLine"], + "want": ["[registered] validated. src/routes/validated.js:18 — registered as a GET route \"/multi-line\""]}, + {"why": "nested wrappers (`catchAsync(validate(async (req, res) => …))`) still hold a function", + "run": ["impact", "nested"], + "want": ["registered as a GET route \"/nested\""], + "avoid": ["reads it"]} + ]} diff --git a/tests/cases/javascript/wrapped-handler-route/src/app.js b/tests/cases/javascript/wrapped-handler-route/src/app.js new file mode 100644 index 000000000..0a387d7d9 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/app.js @@ -0,0 +1,9 @@ +const express = require('express'); +const adminRoutes = require('./routes/admin'); +const app = express(); +const apiRouter = express.Router(); +const publicDir = __dirname + '/public'; +apiRouter.use('/admin', adminRoutes); +app.use('/api', apiRouter); +app.use('/static', express.static(publicDir)); +module.exports = app; diff --git a/tests/cases/javascript/wrapped-handler-route/src/controllers/adminController.js b/tests/cases/javascript/wrapped-handler-route/src/controllers/adminController.js new file mode 100644 index 000000000..da2033b3b --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/controllers/adminController.js @@ -0,0 +1,5 @@ +const catchAsync = require('../utils/catchAsync'); +const listAdmins = catchAsync(async (req, res) => { res.json([]); }); +async function plainAdmins(req, res) { res.json([]); } +const adminCount = 3; +module.exports = { listAdmins, plainAdmins, adminCount }; diff --git a/tests/cases/javascript/wrapped-handler-route/src/controllers/arrowController.js b/tests/cases/javascript/wrapped-handler-route/src/controllers/arrowController.js new file mode 100644 index 000000000..84bb86847 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/controllers/arrowController.js @@ -0,0 +1,2 @@ +const arrowAdmins = async (req, res) => { res.json([]); }; +module.exports = { arrowAdmins }; diff --git a/tests/cases/javascript/wrapped-handler-route/src/controllers/libController.js b/tests/cases/javascript/wrapped-handler-route/src/controllers/libController.js new file mode 100644 index 000000000..3c0dad615 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/controllers/libController.js @@ -0,0 +1,3 @@ +const asyncHandler = require('express-async-handler'); +const libList = asyncHandler(async (req, res) => { res.json([]); }); +module.exports = { libList }; diff --git a/tests/cases/javascript/wrapped-handler-route/src/controllers/report.controller.js b/tests/cases/javascript/wrapped-handler-route/src/controllers/report.controller.js new file mode 100644 index 000000000..d719d3960 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/controllers/report.controller.js @@ -0,0 +1,3 @@ +const catchAsync = require('../utils/catchAsync'); +const listReports = catchAsync(async (req, res) => { res.json([]); }); +module.exports = { listReports }; diff --git a/tests/cases/javascript/wrapped-handler-route/src/middlewares/validate.js b/tests/cases/javascript/wrapped-handler-route/src/middlewares/validate.js new file mode 100644 index 000000000..1515f492b --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/middlewares/validate.js @@ -0,0 +1,2 @@ +const validate = (schema) => (req, res, next) => (schema ? next() : next()); +module.exports = validate; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/admin.js b/tests/cases/javascript/wrapped-handler-route/src/routes/admin.js new file mode 100644 index 000000000..d87b78303 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/admin.js @@ -0,0 +1,9 @@ +const express = require('express'); +const { listAdmins, plainAdmins, adminCount } = require('../controllers/adminController'); +const { arrowAdmins } = require('../controllers/arrowController'); +const router = express.Router(); +router.get('/admins', listAdmins); +router.get('/admins-plain', plainAdmins); +router.get('/admins-count', (req, res) => res.json(adminCount)); +router.get('/admins-arrow', arrowAdmins); +module.exports = router; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/chained.js b/tests/cases/javascript/wrapped-handler-route/src/routes/chained.js new file mode 100644 index 000000000..8e3af8eb9 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/chained.js @@ -0,0 +1,8 @@ +const express = require('express'); +const adminController = require('../controllers/adminController'); +const auth = () => (req, res, next) => next(); +const router = express.Router(); +router + .route('/admins-c') + .get(auth(), adminController.listAdmins); +module.exports = router; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/lib.js b/tests/cases/javascript/wrapped-handler-route/src/routes/lib.js new file mode 100644 index 000000000..eefdaca47 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/lib.js @@ -0,0 +1,7 @@ +const express = require('express'); +const { libList } = require('../controllers/libController'); +const { plainAdmins } = require('../controllers/adminController'); +const router = express.Router(); +router.get('/lib', libList); +router.route('/lib-mixed').post(libList, plainAdmins); +module.exports = router; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/local.js b/tests/cases/javascript/wrapped-handler-route/src/routes/local.js new file mode 100644 index 000000000..2d9c0ccd3 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/local.js @@ -0,0 +1,6 @@ +const express = require('express'); +const catchAsync = require('../utils/catchAsync'); +const router = express.Router(); +const localList = catchAsync(async (req, res) => { res.json([]); }); +router.get('/local', localList); +module.exports = router; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/other.js b/tests/cases/javascript/wrapped-handler-route/src/routes/other.js new file mode 100644 index 000000000..59464624c --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/other.js @@ -0,0 +1,3 @@ +// an unrelated const of the same name: it is not the handler registered in admin.js +const listAdmins = ['root']; +module.exports = { listAdmins }; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/qualified.js b/tests/cases/javascript/wrapped-handler-route/src/routes/qualified.js new file mode 100644 index 000000000..7d13ff4c0 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/qualified.js @@ -0,0 +1,7 @@ +const express = require('express'); +const adminController = require('../controllers/adminController'); +const auth = () => (req, res, next) => next(); +const router = express.Router(); +router.route('/admins-q').get(auth(), adminController.listAdmins); +const limit = adminController.adminCount; +module.exports = { router, limit }; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/routed.js b/tests/cases/javascript/wrapped-handler-route/src/routes/routed.js new file mode 100644 index 000000000..b75ba9086 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/routed.js @@ -0,0 +1,7 @@ +const express = require('express'); +const { listReports } = require('../controllers/report.controller'); +const { plainAdmins } = require('../controllers/adminController'); +const router = express.Router(); +router.route('/reports').get(listReports); +router.route('/both').get(plainAdmins).post(plainAdmins); +module.exports = router; diff --git a/tests/cases/javascript/wrapped-handler-route/src/routes/validated.js b/tests/cases/javascript/wrapped-handler-route/src/routes/validated.js new file mode 100644 index 000000000..38e1daf95 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/routes/validated.js @@ -0,0 +1,22 @@ +const express = require('express'); +const Joi = require('joi'); +const validate = require('../middlewares/validate'); +const catchAsync = require('../utils/catchAsync'); +const adminController = require('../controllers/adminController'); +const router = express.Router(); +const bodySchema = Joi.object({ name: Joi.string().custom((v) => v) }); +const routeOptions = { schema: { body: bodySchema } }; +const PREFIX = '/v'; +const aliasOf = adminController.plainAdmins; +const multiLine = + catchAsync(async (req, res) => { res.json([]); }); +const nested = catchAsync(validate(async (req, res) => { res.json([]); })); +router.post('/validated', validate(bodySchema), adminController.listAdmins); +router.post('/opts', routeOptions, adminController.plainAdmins); +router.post('/schema-direct', bodySchema, adminController.plainAdmins); +router.get('/aliased', aliasOf); +router.get('/multi-line', multiLine); +router.get('/nested', nested); +router.get('/array', [validate(bodySchema), adminController.listAdmins]); +router.use(PREFIX, adminController.plainAdmins); +module.exports = router; diff --git a/tests/cases/javascript/wrapped-handler-route/src/services/adminService.js b/tests/cases/javascript/wrapped-handler-route/src/services/adminService.js new file mode 100644 index 000000000..fb6ae185f --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/services/adminService.js @@ -0,0 +1,3 @@ +// a service function of the same name: its own export is not a reader of the controller's wrapped handler +const listAdmins = async () => []; +module.exports = { listAdmins }; diff --git a/tests/cases/javascript/wrapped-handler-route/src/utils/catchAsync.js b/tests/cases/javascript/wrapped-handler-route/src/utils/catchAsync.js new file mode 100644 index 000000000..7ca747ca3 --- /dev/null +++ b/tests/cases/javascript/wrapped-handler-route/src/utils/catchAsync.js @@ -0,0 +1,2 @@ +const catchAsync = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); +module.exports = catchAsync; diff --git a/tests/cases/python/callers-through-the-base-class/case.json b/tests/cases/python/callers-through-the-base-class/case.json new file mode 100644 index 000000000..b0bbaa57f --- /dev/null +++ b/tests/cases/python/callers-through-the-base-class/case.json @@ -0,0 +1,15 @@ +{"lang": "python", + "checks": [ + {"why": "a caller typed on the base class is a direct caller of each subclass override, via the base class: one of a set, since the base or the other subclass may run there (#1542)", + "run": ["impact", "MailNotifier.send"], + "want": ["[one of a set] alert", "[one of a set] Desk.ring", "calls it (via the base class)"]}, + {"why": "near-miss control: a caller whose receiver is annotated with the other subclass is not a caller of this one", + "run": ["impact", "MailNotifier.send"], + "avoid": ["sms_only"]}, + {"why": "control: the other subclass keeps its own concrete caller", + "run": ["impact", "SmsNotifier.send"], + "want": ["[resolved] sms_only"]}, + {"why": "the base method: the call written against it names it, resolved", + "run": ["impact", "Notifier.send"], + "want": ["[resolved] alert", "[resolved] Desk.ring"], + "avoid": ["[one of a set] alert"]}]} diff --git a/tests/cases/python/callers-through-the-base-class/notifiers.py b/tests/cases/python/callers-through-the-base-class/notifiers.py new file mode 100644 index 000000000..67217f83f --- /dev/null +++ b/tests/cases/python/callers-through-the-base-class/notifiers.py @@ -0,0 +1,29 @@ +class Notifier: + def send(self, msg: str) -> str: + return msg + + +class MailNotifier(Notifier): + def send(self, msg: str) -> str: + return "mail:" + msg + + +class SmsNotifier(Notifier): + def send(self, msg: str) -> str: + return "sms:" + msg + + +def alert(n: Notifier) -> str: + return n.send("x") + + +def sms_only(n: SmsNotifier) -> str: + return n.send("y") + + +class Desk: + def __init__(self, notifier: Notifier): + self.notifier = notifier + + def ring(self) -> str: + return self.notifier.send("z") diff --git a/tests/cases/python/common-word-text-is-prose/.github/workflows/ci.yml b/tests/cases/python/common-word-text-is-prose/.github/workflows/ci.yml new file mode 100644 index 000000000..1270a5c87 --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/.github/workflows/ci.yml @@ -0,0 +1,5 @@ +name: note checks +on: push +jobs: + build: + runs-on: ubuntu-latest diff --git a/tests/cases/python/common-word-text-is-prose/case.json b/tests/cases/python/common-word-text-is-prose/case.json new file mode 100644 index 000000000..ae8254b53 --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/case.json @@ -0,0 +1,26 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a name in a shell script (by extension or by its #! line) or a Datalog file is never a binding: those files are out of scope, so none of them is a [text] row", + "run": ["impact", "notes.py:1"], + "avoid": ["run.sh:", "rules.dl:", "wrapper:"]}, + {"why": "a common word written as a word (template text, a CI job name) is prose: counted once with the search that lists it, never listed as a place a rename breaks", + "run": ["impact", "notes.py:1"], + "want": ["written as a plain word outside the source:", "name \"note\" with no call, quote or qualifier around it", "grep -rnw \"note\" ."], + "avoid": ["[text] templates/page.html:1", "[text] .github/workflows/ci.yml:1"]}, + {"why": "control: the same common word called (onclick=\"note()\") or quoted as a whole value (a YAML list item) is written the way a binding is, and stays a [text] row", + "run": ["impact", "notes.py:1"], + "want": ["[text] config.yml:2", "[text] templates/page.html:3"]}, + {"why": "--page all lists the prose lines too, marked, so the answer stays complete", + "run": ["impact", "notes.py:1", "--page", "all"], + "want": ["[text, prose] templates/page.html:1", "[text, prose] .github/workflows/ci.yml:1"]}, + {"why": "--json keeps every row and flags the prose ones", + "run": ["impact", "notes.py:1", "--json"], + "want": ["\"at\": \".github/workflows/ci.yml:1\"", "\"prose\": true"]}, + {"why": "control: a name that is not a plain word (make_note) keeps every text row, prose or not", + "run": ["impact", "make_note"], + "want": ["[text] templates/page.html:2"], + "avoid": ["written as a plain word"]}, + {"why": "control: a qualified mention of a common word (Journal.entry in a settings file) stays a binding, while the bare word in template prose does not", + "run": ["impact", "Journal.entry"], + "want": ["[text] routes.cfg:2", "names the method in full", "name \"entry\" with no call"], + "avoid": ["[text] templates/page.html:4"]}]} diff --git a/tests/cases/python/common-word-text-is-prose/config.yml b/tests/cases/python/common-word-text-is-prose/config.yml new file mode 100644 index 000000000..e109b6118 --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/config.yml @@ -0,0 +1,2 @@ +handlers: + - "note" diff --git a/tests/cases/python/common-word-text-is-prose/notes.py b/tests/cases/python/common-word-text-is-prose/notes.py new file mode 100644 index 000000000..e9f5ee619 --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/notes.py @@ -0,0 +1,19 @@ +def note(text): + return text.strip() + + +def make_note(text): + return note(text) + + +def handle(request): + return make_note(request) + + +class Journal: + def entry(self, text): + return note(text) + + +def log(j: Journal): + return j.entry('x') diff --git a/tests/cases/python/common-word-text-is-prose/routes.cfg b/tests/cases/python/common-word-text-is-prose/routes.cfg new file mode 100644 index 000000000..459705ec5 --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/routes.cfg @@ -0,0 +1,2 @@ +[routes] +handler = Journal.entry diff --git a/tests/cases/python/common-word-text-is-prose/rules.dl b/tests/cases/python/common-word-text-is-prose/rules.dl new file mode 100644 index 000000000..e7c202439 --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/rules.dl @@ -0,0 +1,2 @@ +// note: every note is a rule +.decl note(x:symbol) diff --git a/tests/cases/python/common-word-text-is-prose/run.sh b/tests/cases/python/common-word-text-is-prose/run.sh new file mode 100644 index 000000000..92d2da9dd --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/run.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# note: run the note tool +python -c "import notes; notes.note('x')" diff --git a/tests/cases/python/common-word-text-is-prose/templates/page.html b/tests/cases/python/common-word-text-is-prose/templates/page.html new file mode 100644 index 000000000..d5d82315c --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/templates/page.html @@ -0,0 +1,4 @@ +

Leave a note here, then press save.

+

The make_note helper formats it.

+ +

Each entry is kept for a week.

diff --git a/tests/cases/python/common-word-text-is-prose/wrapper b/tests/cases/python/common-word-text-is-prose/wrapper new file mode 100644 index 000000000..5c7c410eb --- /dev/null +++ b/tests/cases/python/common-word-text-is-prose/wrapper @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +note "$@" diff --git a/tests/cases/python/fixture-reaches-its-requesters/case.json b/tests/cases/python/fixture-reaches-its-requesters/case.json new file mode 100644 index 000000000..fe970a661 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/case.json @@ -0,0 +1,208 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "a fixture star-imported into conftest.py is that conftest's fixture, and reaches the tests that request it (#1537)", + "run": [ + "impact", + "make_repo", + "--tests" + ], + "want": [ + "test_svc.py::test_repo" + ] + }, + { + "why": "a fixture of a module named in pytest_plugins reaches every test that requests it, in any directory (#1537)", + "run": [ + "impact", + "make_service", + "--tests" + ], + "want": [ + "test_svc.py::test_service" + ] + }, + { + "why": "@pytest.fixture(name=...) is requested by the declared name, and the def name requests nothing (#1539)", + "run": [ + "impact", + "make_client", + "--tests" + ], + "want": [ + "test_svc.py::test_client" + ], + "avoid": [ + "test_client_by_def_name" + ] + }, + { + "why": "a module-level fixture reaches the tests in its file that request it, not every test in the file (#1527)", + "run": [ + "impact", + "make_cart", + "--tests" + ], + "want": [ + "test_forms.py::test_cart" + ], + "avoid": [ + "test_unrelated" + ] + }, + { + "why": "an argument the test parametrizes directly is not a request for the fixture of that name (#1529)", + "run": [ + "impact", + "make_user", + "--tests" + ], + "want": [ + "test_plain_user" + ], + "avoid": [ + "test_direct" + ] + }, + { + "why": "a usefixtures marker on a class reaches the tests the class holds, and not a sibling class's (#1528)", + "run": [ + "impact", + "start_server", + "--tests" + ], + "want": [ + "test_marker.py (1) via server: TestWithServer::test_uses_server" + ], + "avoid": [ + "test_no_server" + ] + }, + { + "why": "a sibling directory's conftest, star import and all, is not visible here", + "run": [ + "impact", + "make_widget", + "--tests" + ], + "want": [ + "test_other.py::test_widget" + ], + "avoid": [ + "test_widget_here" + ] + }, + { + "why": "an argument handed to the fixture with indirect=True still requests it (control for #1529)", + "run": [ + "impact", + "make_user", + "--tests" + ], + "want": [ + "test_indirect" + ] + }, + { + "why": "a fixture a class declares wins over the module fixture it overrides for that class's tests (#1512)", + "run": [ + "impact", + "module_account", + "--tests" + ], + "want": [ + "TestPlain::test_no_override", + "test_accounts.py::test_module_level" + ], + "avoid": [ + "test_in_class" + ] + }, + { + "why": "the class fixture serves its own class's tests only (control for #1512)", + "run": [ + "impact", + "class_account", + "--tests" + ], + "want": [ + "TestOverride::test_in_class" + ], + "avoid": [ + "test_no_override", + "test_module_level" + ] + }, + { + "why": "a fixture named test_* is not a test, and conftest.py is never collected (#1531)", + "run": [ + "impact", + "load_image", + "--tests" + ], + "want": [ + "test_images.py::test_size" + ], + "avoid": [ + "conftest.py::test_image" + ] + }, + { + "why": "a module's pytestmark usefixtures reaches the tests of that module only (#1528)", + "run": [ + "impact", + "make_clean_dir", + "--tests" + ], + "want": [ + "test_pytestmark.py::test_module_marker" + ], + "avoid": [ + "test_uses_server", + "test_repo" + ] + }, + { + "why": "the ini usefixtures option reaches every test that can see the fixture, and not a sibling directory's (#1528)", + "run": [ + "impact", + "load_config", + "--tests" + ], + "want": [ + "test_pytestmark.py::test_module_marker", + "test_svc.py::test_repo" + ], + "avoid": [ + "test_other.py::test_widget" + ] + }, + { + "why": "a pytestmark marker naming a pytest_plugins module's fixture reaches the module's tests (#1528, #1537)", + "run": [ + "impact", + "make_service", + "--tests" + ], + "want": [ + "test_plugin_marker.py::test_marks_plugin_fixture" + ] + }, + { + "why": "a marker naming a star-imported fixture reaches the tests below that conftest, not a sibling directory's (#1537)", + "run": [ + "impact", + "make_widget", + "--tests" + ], + "want": [ + "test_other.py::test_marks_widget" + ], + "avoid": [ + "test_pytestmark.py" + ] + } + ] +} diff --git a/tests/cases/python/fixture-reaches-its-requesters/conftest.py b/tests/cases/python/fixture-reaches-its-requesters/conftest.py new file mode 100644 index 000000000..979a9c965 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/conftest.py @@ -0,0 +1 @@ +pytest_plugins = ["support.plugin"] diff --git a/tests/cases/python/fixture-reaches-its-requesters/other/__init__.py b/tests/cases/python/fixture-reaches-its-requesters/other/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/fixture-reaches-its-requesters/other/conftest.py b/tests/cases/python/fixture-reaches-its-requesters/other/conftest.py new file mode 100644 index 000000000..34f198344 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/other/conftest.py @@ -0,0 +1 @@ +from other.more import * diff --git a/tests/cases/python/fixture-reaches-its-requesters/other/more.py b/tests/cases/python/fixture-reaches-its-requesters/other/more.py new file mode 100644 index 000000000..3feed055f --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/other/more.py @@ -0,0 +1,8 @@ +import pytest + +from shop.svc import make_widget + + +@pytest.fixture +def widget(): + return make_widget() diff --git a/tests/cases/python/fixture-reaches-its-requesters/other/test_other.py b/tests/cases/python/fixture-reaches-its-requesters/other/test_other.py new file mode 100644 index 000000000..7a3ce1171 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/other/test_other.py @@ -0,0 +1,9 @@ +import pytest + +def test_widget(widget): + assert widget + + +@pytest.mark.usefixtures("widget") +def test_marks_widget(): + assert True diff --git a/tests/cases/python/fixture-reaches-its-requesters/pytest.ini b/tests/cases/python/fixture-reaches-its-requesters/pytest.ini new file mode 100644 index 000000000..1549fbcc7 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +usefixtures = config_loaded diff --git a/tests/cases/python/fixture-reaches-its-requesters/shop/__init__.py b/tests/cases/python/fixture-reaches-its-requesters/shop/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/fixture-reaches-its-requesters/shop/svc.py b/tests/cases/python/fixture-reaches-its-requesters/shop/svc.py new file mode 100644 index 000000000..05557ccce --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/shop/svc.py @@ -0,0 +1,46 @@ +def make_repo(): + return "repo" + + +def make_service(): + return "service" + + +def make_client(): + return "client" + + +def make_user(name): + return {"name": name} + + +def make_cart(items): + return list(items) + + +def make_widget(): + return "widget" + + +def start_server(): + return "server" + + +def module_account(): + return "module" + + +def class_account(): + return "class" + + +def load_image(): + return "image" + + +def make_clean_dir(): + return "dir" + + +def load_config(): + return "config" diff --git a/tests/cases/python/fixture-reaches-its-requesters/support/__init__.py b/tests/cases/python/fixture-reaches-its-requesters/support/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/fixture-reaches-its-requesters/support/plugin.py b/tests/cases/python/fixture-reaches-its-requesters/support/plugin.py new file mode 100644 index 000000000..6d0eec7de --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/support/plugin.py @@ -0,0 +1,8 @@ +import pytest + +from shop.svc import make_service + + +@pytest.fixture +def service(): + return make_service() diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/__init__.py b/tests/cases/python/fixture-reaches-its-requesters/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/conftest.py b/tests/cases/python/fixture-reaches-its-requesters/tests/conftest.py new file mode 100644 index 000000000..9dde62b78 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/conftest.py @@ -0,0 +1,35 @@ +import pytest + +from shop.svc import load_config, load_image, make_clean_dir, make_client, make_user, start_server + +from .fixtures import * + + +@pytest.fixture(name="client") +def client_fixture(): + return make_client() + + +@pytest.fixture +def user(): + return make_user("default") + + +@pytest.fixture +def server(): + return start_server() + + +@pytest.fixture +def test_image(): + return load_image() + + +@pytest.fixture +def cleandir(): + return make_clean_dir() + + +@pytest.fixture +def config_loaded(): + return load_config() diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/fixtures.py b/tests/cases/python/fixture-reaches-its-requesters/tests/fixtures.py new file mode 100644 index 000000000..910df7999 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/fixtures.py @@ -0,0 +1,8 @@ +import pytest + +from shop.svc import make_repo + + +@pytest.fixture +def repo(): + return make_repo() diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/test_accounts.py b/tests/cases/python/fixture-reaches-its-requesters/tests/test_accounts.py new file mode 100644 index 000000000..d5be4e0ce --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/test_accounts.py @@ -0,0 +1,26 @@ +import pytest + +from shop.svc import class_account, module_account + + +@pytest.fixture +def account(): + return module_account() + + +class TestOverride: + @pytest.fixture + def account(self): + return class_account() + + def test_in_class(self, account): + assert account == "class" + + +class TestPlain: + def test_no_override(self, account): + assert account == "module" + + +def test_module_level(account): + assert account == "module" diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/test_forms.py b/tests/cases/python/fixture-reaches-its-requesters/tests/test_forms.py new file mode 100644 index 000000000..4d7cc7d9e --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/test_forms.py @@ -0,0 +1,16 @@ +import pytest + +from shop.svc import make_cart + + +@pytest.fixture +def cart(): + return make_cart([1]) + + +def test_cart(cart): + assert cart == [1] + + +def test_unrelated(): + assert True diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/test_images.py b/tests/cases/python/fixture-reaches-its-requesters/tests/test_images.py new file mode 100644 index 000000000..519bdac21 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/test_images.py @@ -0,0 +1,2 @@ +def test_size(test_image): + assert test_image diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/test_marker.py b/tests/cases/python/fixture-reaches-its-requesters/tests/test_marker.py new file mode 100644 index 000000000..5c3c8cfda --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/test_marker.py @@ -0,0 +1,12 @@ +import pytest + + +@pytest.mark.usefixtures("server") +class TestWithServer: + def test_uses_server(self): + assert True + + +class TestWithoutServer: + def test_no_server(self): + assert True diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/test_plugin_marker.py b/tests/cases/python/fixture-reaches-its-requesters/tests/test_plugin_marker.py new file mode 100644 index 000000000..010810341 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/test_plugin_marker.py @@ -0,0 +1,7 @@ +import pytest + +pytestmark = pytest.mark.usefixtures("service") + + +def test_marks_plugin_fixture(): + assert True diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/test_pytestmark.py b/tests/cases/python/fixture-reaches-its-requesters/tests/test_pytestmark.py new file mode 100644 index 000000000..b3af9c759 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/test_pytestmark.py @@ -0,0 +1,7 @@ +import pytest + +pytestmark = pytest.mark.usefixtures("cleandir") + + +def test_module_marker(): + assert True diff --git a/tests/cases/python/fixture-reaches-its-requesters/tests/test_svc.py b/tests/cases/python/fixture-reaches-its-requesters/tests/test_svc.py new file mode 100644 index 000000000..2971a8c58 --- /dev/null +++ b/tests/cases/python/fixture-reaches-its-requesters/tests/test_svc.py @@ -0,0 +1,35 @@ +import pytest + + +def test_repo(repo): + assert repo + + +def test_service(service): + assert service + + +def test_client(client): + assert client + + +def test_client_by_def_name(client_fixture): + assert client_fixture + + +@pytest.mark.parametrize("user", [{"name": "direct"}]) +def test_direct(user): + assert user["name"] == "direct" + + +@pytest.mark.parametrize("user", ["other"], indirect=True) +def test_indirect(user): + assert user + + +def test_plain_user(user): + assert user["name"] == "default" + + +def test_widget_here(widget): + assert widget diff --git a/tests/cases/python/lambda-is-named-by-its-place/case.json b/tests/cases/python/lambda-is-named-by-its-place/case.json new file mode 100644 index 000000000..4f253f04b --- /dev/null +++ b/tests/cases/python/lambda-is-named-by-its-place/case.json @@ -0,0 +1,42 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "an edit inside a lambda in a method body is a body change of THAT METHOD: the lambda is part of its body, and its one name, , is never the declaration an edit is charged to (it matched every lambda in the graph, and their tests)", + "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-in-lambda.txt", "--file", "lib.py"], + "want": ["body Orders.totals", "→ impact Orders.totals"], + "avoid": ["", "impact lib.py:15"], + "avoid": [" signature ", "impact "]}, + {"why": "its parameter list is read from the lambda's own header, never as a method signature", + "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new-top-params.txt", "--file", "lib.py"], + "want": ["lambda parameters (xs) → (ys)", "impact lib.py:15"], + "avoid": [" signature ", "-lambda"]}, + {"why": "two lambdas in one method are two declarations: file:line names one of them, by its enclosing method and line", + "run": ["impact", "lib.py:6"], + "want": ["Orders.totals."], + "avoid": ["", "declarations"]}, + {"why": "and the name printed for a lambda is a target that answers for that lambda alone", + "run": ["impact", "Orders.totals."], + "want": ["change: Orders.totals."], + "avoid": ["", "declarations"]}, + {"why": "file:line on a module-level constant is that constant, not the body of the class above it", + "run": ["impact", "lib.py:14"], + "want": ["const HANDLERS"], + "avoid": ["change: Orders.", "no callable spans"]}, + {"why": "file:line on a class line is the class", + "run": ["impact", "lib.py:1"], + "want": ["class Orders"], + "avoid": ["change: Orders."]}, + {"why": "control: file:line inside a method body is still that method, though the line assigns a field", + "run": ["impact", "lib.py:3"], + "want": ["Orders.__init__ (at lib.py:3)"], + "avoid": ["change: field"]}]} diff --git a/tests/cases/python/lambda-is-named-by-its-place/lib.py b/tests/cases/python/lambda-is-named-by-its-place/lib.py new file mode 100644 index 000000000..8fef54f59 --- /dev/null +++ b/tests/cases/python/lambda-is-named-by-its-place/lib.py @@ -0,0 +1,16 @@ +class Orders: + def __init__(self): + self.items = [] + + def totals(self, xs): + keep = filter(lambda x: x > 0, xs) + double = map(lambda y: y * 2, keep) + return list(double) + + def names(self): + return [i.name for i in self.items] + + +HANDLERS = { + "sum": lambda xs: sum(xs), +} diff --git a/tests/cases/python/lambda-is-named-by-its-place/new-in-lambda.txt b/tests/cases/python/lambda-is-named-by-its-place/new-in-lambda.txt new file mode 100644 index 000000000..bd5ee6a14 --- /dev/null +++ b/tests/cases/python/lambda-is-named-by-its-place/new-in-lambda.txt @@ -0,0 +1,16 @@ +class Orders: + def __init__(self): + self.items = [] + + def totals(self, xs): + keep = filter(lambda x: x >= 0, xs) + double = map(lambda y: y * 2, keep) + return list(double) + + def names(self): + return [i.name for i in self.items] + + +HANDLERS = { + "sum": lambda xs: sum(xs), +} diff --git a/tests/cases/python/lambda-is-named-by-its-place/new-named.txt b/tests/cases/python/lambda-is-named-by-its-place/new-named.txt new file mode 100644 index 000000000..78cd1830e --- /dev/null +++ b/tests/cases/python/lambda-is-named-by-its-place/new-named.txt @@ -0,0 +1,16 @@ +class Orders: + def __init__(self): + self.items = [] + + def totals(self, xs): + keep = filter(lambda x: x > 0, xs) + double = map(lambda y: y * 2, keep) + return list(double) + + def names(self): + return [i.title for i in self.items] + + +HANDLERS = { + "sum": lambda xs: sum(xs), +} diff --git a/tests/cases/python/lambda-is-named-by-its-place/new-top-params.txt b/tests/cases/python/lambda-is-named-by-its-place/new-top-params.txt new file mode 100644 index 000000000..2f20d1d39 --- /dev/null +++ b/tests/cases/python/lambda-is-named-by-its-place/new-top-params.txt @@ -0,0 +1,16 @@ +class Orders: + def __init__(self): + self.items = [] + + def totals(self, xs): + keep = filter(lambda x: x > 0, xs) + double = map(lambda y: y * 2, keep) + return list(double) + + def names(self): + return [i.name for i in self.items] + + +HANDLERS = { + "sum": lambda ys: sum(ys), +} diff --git a/tests/cases/python/lambda-is-named-by-its-place/new-top.txt b/tests/cases/python/lambda-is-named-by-its-place/new-top.txt new file mode 100644 index 000000000..609b59f6f --- /dev/null +++ b/tests/cases/python/lambda-is-named-by-its-place/new-top.txt @@ -0,0 +1,16 @@ +class Orders: + def __init__(self): + self.items = [] + + def totals(self, xs): + keep = filter(lambda x: x > 0, xs) + double = map(lambda y: y * 2, keep) + return list(double) + + def names(self): + return [i.name for i in self.items] + + +HANDLERS = { + "sum": lambda xs: sum(xs) + 1, +} diff --git a/tests/cases/python/lambda-is-named-by-its-place/old.txt b/tests/cases/python/lambda-is-named-by-its-place/old.txt new file mode 100644 index 000000000..8fef54f59 --- /dev/null +++ b/tests/cases/python/lambda-is-named-by-its-place/old.txt @@ -0,0 +1,16 @@ +class Orders: + def __init__(self): + self.items = [] + + def totals(self, xs): + keep = filter(lambda x: x > 0, xs) + double = map(lambda y: y * 2, keep) + return list(double) + + def names(self): + return [i.name for i in self.items] + + +HANDLERS = { + "sum": lambda xs: sum(xs), +} diff --git a/tests/cases/python/lambda-is-named-by-its-place/test_lib.py b/tests/cases/python/lambda-is-named-by-its-place/test_lib.py new file mode 100644 index 000000000..7d7ad277a --- /dev/null +++ b/tests/cases/python/lambda-is-named-by-its-place/test_lib.py @@ -0,0 +1,9 @@ +from lib import Orders + + +def test_totals(): + assert Orders().totals([1, -1]) == [2] + + +def test_names(): + assert Orders().names() == [] diff --git a/tests/cases/python/lambda-is-named-by-its-place/test_widgets.py b/tests/cases/python/lambda-is-named-by-its-place/test_widgets.py new file mode 100644 index 000000000..3d121ed70 --- /dev/null +++ b/tests/cases/python/lambda-is-named-by-its-place/test_widgets.py @@ -0,0 +1,5 @@ +from widgets import label + + +def test_label(): + assert label([]) == [] diff --git a/tests/cases/python/lambda-is-named-by-its-place/widgets.py b/tests/cases/python/lambda-is-named-by-its-place/widgets.py new file mode 100644 index 000000000..710a81484 --- /dev/null +++ b/tests/cases/python/lambda-is-named-by-its-place/widgets.py @@ -0,0 +1,2 @@ +def label(ws): + return sorted(ws, key=lambda w: w.name) diff --git a/tests/cases/python/one-row-per-dependent/a/__init__.py b/tests/cases/python/one-row-per-dependent/a/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/one-row-per-dependent/a/views.py b/tests/cases/python/one-row-per-dependent/a/views.py new file mode 100644 index 000000000..cbb6fc360 --- /dev/null +++ b/tests/cases/python/one-row-per-dependent/a/views.py @@ -0,0 +1,5 @@ +from greeters import Greeter + + +def show(g: Greeter, name): + return g.greet(name, 'plain') diff --git a/tests/cases/python/one-row-per-dependent/b/__init__.py b/tests/cases/python/one-row-per-dependent/b/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/one-row-per-dependent/b/views.py b/tests/cases/python/one-row-per-dependent/b/views.py new file mode 100644 index 000000000..e5d4e4d09 --- /dev/null +++ b/tests/cases/python/one-row-per-dependent/b/views.py @@ -0,0 +1,5 @@ +from greeters import Greeter + + +def show(g: Greeter, name): + return g.greet(name, 'loud') diff --git a/tests/cases/python/one-row-per-dependent/case.json b/tests/cases/python/one-row-per-dependent/case.json new file mode 100644 index 000000000..58a6451a7 --- /dev/null +++ b/tests/cases/python/one-row-per-dependent/case.json @@ -0,0 +1,18 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "an override's supertype method is bound to it twice (a dispatch candidate and a name match): it is ONE row, under the surer reason, with the other said on it, and the header counts it once", + "run": ["impact", "LoudGreeter.greet"], + "want": ["must change with it (1: bound by a contract the engine resolved):", "also: declares the same member in a supertype"]}, + {"why": "a sibling that shares two fields with the target is one row naming both fields, and the rungs in the header add up to the callables (#1423)", + "run": ["impact", "Box.grow"], + "want": ["using the same field height, width", "(7 callable(s): 7 alongside)"], + "avoid": ["using the same field width\n"]}, + {"why": "control: two distinct callables of one display name in two files are two dependents, and both stay", + "run": ["impact", "Greeter.greet"], + "want": ["show a/views.py:5", "show b/views.py:5"]}, + {"why": "--json keeps every reason with its own certainty and line", + "run": ["impact", "Box.grow", "--json"], + "want": ["\"reasons\": [", "using the same field width"]}, + {"why": "path '*' names each nearest caller once, and a caller that is also an entry point is not named twice (#1389)", + "run": ["path", "*", "greeters.py:7"], + "avoid": ["show a/views.py:5, show a/views.py"]}]} diff --git a/tests/cases/python/one-row-per-dependent/greeters.py b/tests/cases/python/one-row-per-dependent/greeters.py new file mode 100644 index 000000000..d3744b425 --- /dev/null +++ b/tests/cases/python/one-row-per-dependent/greeters.py @@ -0,0 +1,22 @@ +class Greeter: + def greet(self, name, style): + return name + + +class LoudGreeter(Greeter): + def greet(self, name, style): + return name.upper() + + +class Box: + def __init__(self): + self.width = 1 + self.height = 1 + + def grow(self): + self.width += 1 + self.height += 1 + + def shrink(self): + self.width -= 1 + self.height -= 1 diff --git a/tests/cases/python/route-table-reaches-view/case.json b/tests/cases/python/route-table-reaches-view/case.json new file mode 100644 index 000000000..831cc5c36 --- /dev/null +++ b/tests/cases/python/route-table-reaches-view/case.json @@ -0,0 +1,32 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a route table writes its pattern without the leading slash and names the view as a module attribute; a test that requests the URL reaches the view and what it calls", + "run": ["impact", "describe", "--tests"], + "want": ["test_orders.py", "by key"]}, + {"why": "the same join lands on the view itself, and only from the URL its accepts: /orders/archive/ is no integer and /orders/settings/ is its own route", + "run": ["impact", "order_detail", "--tests"], + "want": ["test_order_detail", "by key"], + "avoid": ["test_order_archive", "test_widgets", "test_order_settings"]}, + {"why": "a view under a library decorator is still the def its route dispatches to", + "run": ["impact", "order_settings", "--tests"], + "want": ["test_order_settings", "by key"], + "avoid": ["test_order_detail"]}, + {"why": "a class-based view routed with .as_view() is reached through its verb handler, by the one test that requests its URL", + "run": ["impact", "WidgetView.get", "--tests"], + "want": ["[by key] 1 test(s)", "(1): test_orders.py::test_widgets"]}, + {"why": "a view in a table that include() mounts is served under the mounting prefix: the test that requests /api/v1/status/ reaches it, and one requesting the bare pattern /status/ does not", + "run": ["impact", "api_status", "--tests"], + "want": ["test_api_status", "by key"], + "avoid": ["test_status_without_prefix"]}, + {"why": "what a URL runs: the view the engine says is served at a path only an include() prefix composes, and never the test that requests that path", + "run": ["context", "what view handles the /api/v1/status/ URL"], + "want": ["serves the route /api/v1/status/"]}, + {"why": "CONTROL: /status/ is a pattern of a mounted table and a URL a test requests, but no route serves it: neither the table module nor the requesting test is said to serve it", + "run": ["context", "what runs for a GET of /status/"], + "avoid": ["serves the route"]}, + {"why": "a one-segment route with a trailing slash is a route too, and a class-based view is answered by its verb handler", + "run": ["context", "what view handles the /widgets/ URL"], + "want": ["WidgetView.get", "serves the route /widgets/"]}, + {"why": "CONTROL: a function named like a view but routed nowhere gains no test from a request for a URL that has no route", + "run": ["impact", "order_archive", "--tests"], + "avoid": ["test_order_archive", "by key"]}]} diff --git a/tests/cases/python/route-table-reaches-view/shop/__init__.py b/tests/cases/python/route-table-reaches-view/shop/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/route-table-reaches-view/shop/urls.py b/tests/cases/python/route-table-reaches-view/shop/urls.py new file mode 100644 index 000000000..a94328009 --- /dev/null +++ b/tests/cases/python/route-table-reaches-view/shop/urls.py @@ -0,0 +1,15 @@ +from django.urls import include, path + +from shop import views +from shop.views import order_settings + +api_patterns = [ + path("status/", views.api_status), +] + +urlpatterns = [ + path("orders//", views.order_detail, name="order-detail"), + path("orders/settings/", order_settings, name="order-settings"), + path("widgets/", views.WidgetView.as_view(), name="widgets"), + path("api/v1/", include(api_patterns)), +] diff --git a/tests/cases/python/route-table-reaches-view/shop/views.py b/tests/cases/python/route-table-reaches-view/shop/views.py new file mode 100644 index 000000000..3a387b998 --- /dev/null +++ b/tests/cases/python/route-table-reaches-view/shop/views.py @@ -0,0 +1,28 @@ +from django.contrib.auth.decorators import login_required +from django.views import View + + +def order_detail(request, pk): + return describe(pk) + + +@login_required +def order_settings(request): + return "settings" + + +def api_status(request): + return "ok" + + +def order_archive(request): + return "archive" + + +class WidgetView(View): + def get(self, request): + return "widgets" + + +def describe(pk): + return str(pk) diff --git a/tests/cases/python/route-table-reaches-view/tests/test_orders.py b/tests/cases/python/route-table-reaches-view/tests/test_orders.py new file mode 100644 index 000000000..a30dbb198 --- /dev/null +++ b/tests/cases/python/route-table-reaches-view/tests/test_orders.py @@ -0,0 +1,22 @@ +def test_order_detail(client): + assert client.get("/orders/5/").status_code == 200 + + +def test_order_settings(client): + assert client.get("/orders/settings/").status_code == 302 + + +def test_widgets(client): + assert client.get("/widgets/").status_code == 200 + + +def test_order_archive(client): + assert client.get("/orders/archive/").status_code == 404 + + +def test_api_status(client): + assert client.get("/api/v1/status/").status_code == 200 + + +def test_status_without_prefix(client): + assert client.get("/status/").status_code == 404 diff --git a/tests/cases/python/shebang-script/case.json b/tests/cases/python/shebang-script/case.json index 885b16155..b6101f99e 100644 --- a/tests/cases/python/shebang-script/case.json +++ b/tests/cases/python/shebang-script/case.json @@ -4,10 +4,10 @@ "run": ["impact", "price"], "want": ["report"], "avoid": ["[text] make-report"]}, - {"why": "a shell script that only runs python3 is not read as Python: its mention of the function stays a text match, not a caller", + {"why": "a shell script that only runs python3 is not read as Python, and a shell script is out of scope: its mention of the function is neither a caller nor a [text] row", "run": ["impact", "price"], - "want": ["[text] deploy:2"], - "avoid": ["deploy."]}, + "want": ["report"], + "avoid": ["deploy.", "[text] deploy"]}, {"why": "an edit inside a shebang script names the function it changed, so test-impact has a declaration to start from", "run": ["changed", "{repo}", "--old", "{repo}/old.txt", "--new", "{repo}/new.txt", "--file", "make-report"], "want": ["body", "report"], diff --git a/tests/cases/python/sites-one-per-line/case.json b/tests/cases/python/sites-one-per-line/case.json new file mode 100644 index 000000000..4dd3bde0c --- /dev/null +++ b/tests/cases/python/sites-one-per-line/case.json @@ -0,0 +1,56 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "impact --grep: one site per line, path:line: the code on it, and what the graph knows as a trailing tag, surest first, then what reaches it by hop, then the tests; no section headers", + "run": ["impact", "total", "--grep"], + "want": ["shop/cart.py:5: return total(items) [resolved · checkout]", + "shop/cart.py:8: def receipt(items): [hop 2]", + "tests/test_cart.py:4: def test_receipt(): [test · resolved · hop 3]", + "verified: ✓"], + "avoid": ["reads or uses it (", "change: total", "entry points ("]}, + {"why": "--grep-limit caps the sites and counts the rest by kind (this case.json names `total`: those are the [text] rows)", + "run": ["impact", "total", "--grep", "--grep-limit", "1"], + "want": ["shop/cart.py:5: return total(items) [resolved · checkout]", "9 [text], 2 [test file], 1 [hop N]"], + "avoid": ["tests/test_cart.py:4:"]}, + {"why": "a name written in non-source files is a lead: at most five such rows, the rest counted, and all after the code sites", + "run": ["impact", "total", "--grep"], + "want": ["tests/test_cart.py:4: def test_receipt(): [test · resolved · hop 3]\ncase.json:"], + "avoid": ["[text · names the method]\nshop/"]}, + {"why": "tests come one row per test file, its nearest test first and the rest of that file counted: a file of many tests is what gets run, and listed one by one they spent the whole cap", + "run": ["impact", "total", "--grep"], + "want": ["tests/test_checkout.py:4: def test_empty(): [test · resolved · hop 2 · +2 more in this file]"], + "avoid": ["tests/test_checkout.py:8:", "tests/test_checkout.py:12:"]}, + {"why": "control: a test file with one test is its row, with no count", + "run": ["impact", "total", "--grep"], + "want": ["tests/test_cart.py:4: def test_receipt(): [test · resolved · hop 3]\n"], + "avoid": ["test_receipt(): [test · resolved · hop 3 · +"]}, + {"why": "test-impact --grep folds the same way (the edit named by file: every test that reaches shop/pricing.py)", + "run": ["test-impact", "{repo}", "shop/pricing.py", "--grep"], + "want": ["tests/test_checkout.py:4: def test_empty(): [test · resolved · hop 2 · +2 more in this file]", + "tests/test_cart.py:4: def test_receipt(): [test · resolved · hop 3]"], + "avoid": ["tests/test_checkout.py:8:"]}, + {"why": "control: without --grep the answer is the verb's own, sections and all, unchanged", + "run": ["impact", "total"], + "want": ["reads or uses it (1 callable(s): 1 resolved):", "[resolved] checkout shop/cart.py:5 — calls it"], + "avoid": ["shop/cart.py:5: return total(items)"]}, + {"why": "control: --json is still one document, untouched by the grep shape", + "run": ["impact", "total", "--json"], + "want": ["\"direct\""], + "stdout_json": true}, + {"why": "path --grep: each hop at the line its call is written on, caller → callee, and the hops looked up again", + "run": ["path", "receipt", "total", "--grep"], + "want": ["shop/cart.py:9: return \"paid \" + str(checkout(items)) [resolved · hop 1/2 receipt → checkout]", + "shop/cart.py:5: return total(items) [resolved · hop 2/2 checkout → total]", + "verified: ✓ (2 edge(s) looked up again)"]}, + {"why": "path --grep with no chain: the reason is the answer, said as the verb says it, never a JSON document", + "run": ["path", "unused", "checkout", "--grep"], + "want": ["no chain of resolved calls connects unused and checkout"], + "avoid": ["\"answers\""], + "expect_error": true}, + {"why": "context --grep: the declarations the task lands on, one per line", + "run": ["context", "the total of a checkout", "--grep"], + "want": ["shop/pricing.py:1: def total(items): [entry · matches 'total']", "shop/cart.py:4: def checkout(items): [entry"], + "avoid": ["where this task's words land"]}, + {"why": "a name that is not there is refused as the verb refuses it", + "run": ["impact", "nosuch", "--grep"], + "want": ["nothing named 'nosuch'"], + "expect_error": true}]} diff --git a/tests/cases/python/sites-one-per-line/shop/__init__.py b/tests/cases/python/sites-one-per-line/shop/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/sites-one-per-line/shop/cart.py b/tests/cases/python/sites-one-per-line/shop/cart.py new file mode 100644 index 000000000..588208e9a --- /dev/null +++ b/tests/cases/python/sites-one-per-line/shop/cart.py @@ -0,0 +1,9 @@ +from shop.pricing import total + + +def checkout(items): + return total(items) + + +def receipt(items): + return "paid " + str(checkout(items)) diff --git a/tests/cases/python/sites-one-per-line/shop/pricing.py b/tests/cases/python/sites-one-per-line/shop/pricing.py new file mode 100644 index 000000000..198eb7011 --- /dev/null +++ b/tests/cases/python/sites-one-per-line/shop/pricing.py @@ -0,0 +1,6 @@ +def total(items): + return sum(items) + + +def unused(): + return 0 diff --git a/tests/cases/python/sites-one-per-line/tests/test_cart.py b/tests/cases/python/sites-one-per-line/tests/test_cart.py new file mode 100644 index 000000000..da11135bb --- /dev/null +++ b/tests/cases/python/sites-one-per-line/tests/test_cart.py @@ -0,0 +1,5 @@ +from shop.cart import receipt + + +def test_receipt(): + assert receipt([1, 2]) == "paid 3" diff --git a/tests/cases/python/sites-one-per-line/tests/test_checkout.py b/tests/cases/python/sites-one-per-line/tests/test_checkout.py new file mode 100644 index 000000000..86809c1b1 --- /dev/null +++ b/tests/cases/python/sites-one-per-line/tests/test_checkout.py @@ -0,0 +1,13 @@ +from shop.cart import checkout + + +def test_empty(): + assert checkout([]) == 0 + + +def test_one(): + assert checkout([2]) == 2 + + +def test_two(): + assert checkout([1, 2]) == 3 diff --git a/tests/cases/python/untyped-receiver-sample-in-source-order/alpha.py b/tests/cases/python/untyped-receiver-sample-in-source-order/alpha.py new file mode 100644 index 000000000..38f0c5665 --- /dev/null +++ b/tests/cases/python/untyped-receiver-sample-in-source-order/alpha.py @@ -0,0 +1,2 @@ +def use_alpha(obj): + return obj.ping() diff --git a/tests/cases/python/untyped-receiver-sample-in-source-order/beta.py b/tests/cases/python/untyped-receiver-sample-in-source-order/beta.py new file mode 100644 index 000000000..0864f822f --- /dev/null +++ b/tests/cases/python/untyped-receiver-sample-in-source-order/beta.py @@ -0,0 +1,2 @@ +def use_beta(obj): + return obj.ping() diff --git a/tests/cases/python/untyped-receiver-sample-in-source-order/case.json b/tests/cases/python/untyped-receiver-sample-in-source-order/case.json new file mode 100644 index 000000000..8552dcb7d --- /dev/null +++ b/tests/cases/python/untyped-receiver-sample-in-source-order/case.json @@ -0,0 +1,9 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "the callers on an untyped receiver are sampled in the text answer, four of them, in source order (file, then line), so two builds of the same tree show the same four; ordered by symbol id the sample changed from build to build", + "run": ["impact", "Pinger.ping"], + "want": ["write this method's name on an untyped receiver", "use_alpha alpha.py:1, use_beta beta.py:1, use_gamma gamma.py:1, use_mid mid.py:1"], + "avoid": ["use_zeta zeta.py:1"]}, + {"why": "control: the sample only shortens that one line; every by-name caller is still a row, the one left out of the sample included", + "run": ["impact", "Pinger.ping"], + "want": ["[by name] use_zeta zeta.py:2", "[by name] use_alpha alpha.py:2", "[resolved] direct svc.py:7"]}]} diff --git a/tests/cases/python/untyped-receiver-sample-in-source-order/gamma.py b/tests/cases/python/untyped-receiver-sample-in-source-order/gamma.py new file mode 100644 index 000000000..bee63e424 --- /dev/null +++ b/tests/cases/python/untyped-receiver-sample-in-source-order/gamma.py @@ -0,0 +1,2 @@ +def use_gamma(obj): + return obj.ping() diff --git a/tests/cases/python/untyped-receiver-sample-in-source-order/mid.py b/tests/cases/python/untyped-receiver-sample-in-source-order/mid.py new file mode 100644 index 000000000..1a6a54225 --- /dev/null +++ b/tests/cases/python/untyped-receiver-sample-in-source-order/mid.py @@ -0,0 +1,2 @@ +def use_mid(obj): + return obj.ping() diff --git a/tests/cases/python/untyped-receiver-sample-in-source-order/svc.py b/tests/cases/python/untyped-receiver-sample-in-source-order/svc.py new file mode 100644 index 000000000..9a37e3936 --- /dev/null +++ b/tests/cases/python/untyped-receiver-sample-in-source-order/svc.py @@ -0,0 +1,7 @@ +class Pinger: + def ping(self): + return 1 + + +def direct(): + return Pinger().ping() diff --git a/tests/cases/python/untyped-receiver-sample-in-source-order/zeta.py b/tests/cases/python/untyped-receiver-sample-in-source-order/zeta.py new file mode 100644 index 000000000..bdc6eb3cf --- /dev/null +++ b/tests/cases/python/untyped-receiver-sample-in-source-order/zeta.py @@ -0,0 +1,2 @@ +def use_zeta(obj): + return obj.ping() diff --git a/tests/cases/typescript/context-tie-goes-to-code-not-typings/case.json b/tests/cases/typescript/context-tie-goes-to-code-not-typings/case.json new file mode 100644 index 000000000..16d70fc7c --- /dev/null +++ b/tests/cases/typescript/context-tie-goes-to-code-not-typings/case.json @@ -0,0 +1,11 @@ +{"lang": "typescript", + "src": "src", + "checks": [ + {"why": "entry points tied on score go to code a reader can follow before their names are compared: `\"store\".Statement.run` and `Alpha.run` are typings in a .d.ts and `AaaHarness.run` is test code, and all three sort before `Beta.run` by name", + "run": ["context", "how does run work"], + "want": [" 1 Beta.run ", " 3 Worker.run "], + "avoid": [" 1 \"store\"", " 1 Alpha.run ", " 1 AaaHarness.run "]}, + {"why": "control: a declaration the question names is the subject whatever file it is in; the tie-break does not move it", + "run": ["context", "how does Alpha.run work"], + "want": [" 1 Alpha.run "], + "avoid": []}]} diff --git a/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/app/beta.ts b/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/app/beta.ts new file mode 100644 index 000000000..c73d31d78 --- /dev/null +++ b/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/app/beta.ts @@ -0,0 +1,6 @@ +export class Beta { + run(): void { + this.prepare(); + } + prepare(): void {} +} diff --git a/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/app/worker.ts b/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/app/worker.ts new file mode 100644 index 000000000..185f6c06d --- /dev/null +++ b/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/app/worker.ts @@ -0,0 +1,6 @@ +export class Worker { + run(): void { + this.step(); + } + step(): void {} +} diff --git a/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/test/aaa.test.ts b/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/test/aaa.test.ts new file mode 100644 index 000000000..51c2bcb57 --- /dev/null +++ b/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/test/aaa.test.ts @@ -0,0 +1,6 @@ +export class AaaHarness { + run(): void { + this.check(); + } + check(): void {} +} diff --git a/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/typings/store.d.ts b/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/typings/store.d.ts new file mode 100644 index 000000000..a90d2d6fd --- /dev/null +++ b/tests/cases/typescript/context-tie-goes-to-code-not-typings/src/typings/store.d.ts @@ -0,0 +1,8 @@ +declare module "store" { + export class Statement { + run(): void; + } +} +export declare class Alpha { + run(): void; +} diff --git a/tests/cases/typescript/discriminant-literal/case.json b/tests/cases/typescript/discriminant-literal/case.json new file mode 100644 index 000000000..e5f91cb44 --- /dev/null +++ b/tests/cases/typescript/discriminant-literal/case.json @@ -0,0 +1,5 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "an object literal that carries a discriminated interface's tag is checked against it and never writes its name, so an added required property breaks it (#784)", "run": ["impact", "LiteralNode"], "want": ["transformLiteral src/transformer.ts:5 — builds an object literal with the tag kind: 'LiteralNode'"], "avoid": ["isLiteral", "makeRaw"]}, + {"why": "the base of the family reaches the literal through the member that extends it", "run": ["impact", "AstNode"], "want": ["builds an object literal with the tag kind: 'LiteralNode' of LiteralNode, which extends it"], "avoid": ["isLiteral"]}, + {"why": "control: the other member's literal is its own, and a comparison with the tag builds nothing", "run": ["impact", "RawNode"], "want": ["makeRaw"], "avoid": ["transformLiteral", "isLiteral"]}]} diff --git a/tests/cases/typescript/discriminant-literal/src/ast.ts b/tests/cases/typescript/discriminant-literal/src/ast.ts new file mode 100644 index 000000000..b671a46f5 --- /dev/null +++ b/tests/cases/typescript/discriminant-literal/src/ast.ts @@ -0,0 +1,13 @@ +export interface AstNode { readonly kind: string } + +export interface LiteralNode extends AstNode { + readonly kind: 'LiteralNode' + readonly fragments: ReadonlyArray +} + +export interface RawNode extends AstNode { + readonly kind: 'RawNode' + readonly sql: string +} + +export function freeze(o: T): Readonly { return Object.freeze(o) } diff --git a/tests/cases/typescript/discriminant-literal/src/transformer.ts b/tests/cases/typescript/discriminant-literal/src/transformer.ts new file mode 100644 index 000000000..f25068bf4 --- /dev/null +++ b/tests/cases/typescript/discriminant-literal/src/transformer.ts @@ -0,0 +1,18 @@ +import { freeze, RawNode } from './ast' + +// rebuilds a LiteralNode and never writes the interface's name +export function transformLiteral(node: { fragments: ReadonlyArray }) { + return accept(freeze({ kind: 'LiteralNode' as const, fragments: node.fragments })) +} + +// CONTROL: only compares the discriminant, builds nothing +export function isLiteral(node: { kind: string }): boolean { + return node.kind === 'LiteralNode' +} + +// CONTROL: builds the other member of the family +export function makeRaw(sql: string): RawNode { + return { kind: 'RawNode', sql } +} + +function accept(n: unknown): unknown { return n } diff --git a/tests/cases/typescript/discriminant-literal/tsconfig.json b/tests/cases/typescript/discriminant-literal/tsconfig.json new file mode 100644 index 000000000..bfebedc51 --- /dev/null +++ b/tests/cases/typescript/discriminant-literal/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "strict": true } } diff --git a/tests/cases/typescript/fileline-dotted-basename/case.json b/tests/cases/typescript/fileline-dotted-basename/case.json index d282220f8..701c6d748 100644 --- a/tests/cases/typescript/fileline-dotted-basename/case.json +++ b/tests/cases/typescript/fileline-dotted-basename/case.json @@ -15,4 +15,12 @@ {"why": "control: a dotted NAME with a fabricated prefix is still refused by the prefix check", "run": ["impact", "zzz.qqq.UserService.find"], "expect_error": true, - "want": ["'zzz.qqq' matches no package"]}]} + "want": ["'zzz.qqq' matches no package"]}, + {"why": "a file:line written ./ or absolute under the repository, with a :col, is the same declaration", + "run": ["impact", "{repo}/src/user.service.ts:3:10"], + "want": ["change: UserService.find", "UserController.get"], + "avoid": ["no callable spans"]}, + {"why": "path takes a ./ file:line", + "run": ["path", "*", "./src/user.service.ts:3"], + "want": ["UserController.get"], + "avoid": ["no callable spans"]}]} diff --git a/tests/cases/typescript/hof-callback-at-library-boundary/case.json b/tests/cases/typescript/hof-callback-at-library-boundary/case.json new file mode 100644 index 000000000..4f1d456b7 --- /dev/null +++ b/tests/cases/typescript/hof-callback-at-library-boundary/case.json @@ -0,0 +1,80 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "a callback handed to a library higher-order function (a `map` with no body) is reached from the function that hands it over, and from no other caller of `map`: one shared function-type node made every `.map` callback reachable from every `.map` call", + "run": [ + "impact", + "RATE" + ], + "want": [ + "scaled src/app.ts:11", + "atStartup src/app.ts:37", + "useEach src/app.ts:47" + ], + "avoid": [ + "src/app.ts:15", + "src/app.ts:24", + "src/app.ts:29" + ] + }, + { + "why": "the function that writes `xs.map((x) => …)` reaches its own callback", + "run": [ + "path", + "scaled", + "src/app.ts:12" + ], + "want": [ + "1 of 1 target(s) reached" + ], + "avoid": [ + "Array.map" + ] + }, + { + "why": "and never a callback another function handed to the same `map`", + "run": [ + "path", + "doubled", + "src/app.ts:12" + ], + "want": [], + "avoid": [ + "1 of 1 target(s) reached" + ], + "expect_error": true + }, + { + "why": "CONTROL: a NAMED function handed to `map` is still reached, from the site that names it", + "run": [ + "path", + "named", + "double" + ], + "want": [ + "1 of 1 target(s) reached", + "[callback_registered" + ], + "avoid": [ + "no chain of resolved calls" + ] + }, + { + "why": "CONTROL: a project higher-order function has a body that calls its parameter, so the callback it is handed is still reached through that call", + "run": [ + "path", + "each", + "src/app.ts:48" + ], + "want": [ + "1 of 1 target(s) reached", + "[dispatch]" + ], + "avoid": [ + "no chain of resolved calls" + ] + } + ] +} diff --git a/tests/cases/typescript/hof-callback-at-library-boundary/src/app.ts b/tests/cases/typescript/hof-callback-at-library-boundary/src/app.ts new file mode 100644 index 000000000..198ebefae --- /dev/null +++ b/tests/cases/typescript/hof-callback-at-library-boundary/src/app.ts @@ -0,0 +1,49 @@ +// A FUNCTION HANDED TO A HIGHER-ORDER FUNCTION WITH NO BODY. `map`, `forEach` and `onReady` are declarations +// only, so the site that hands the callback over is what reaches it: each function below reaches its own +// callback and nothing another function handed to the same `map`. +import { RATE } from './rate' + +const seen: number[] = [] +function record(n: number): void { + seen[0] = n +} + +export function scaled(xs: number[]): number[] { + return xs.map((x) => x * RATE) +} + +export function doubled(xs: number[]): number[] { + return xs.map((x) => x * 2) +} + +function double(n: number): number { + return n * 2 +} + +// a named function handed over: still reached, from the site that names it +export function named(xs: number[]): number[] { + return xs.map(double) +} + +// …and one held by a local first +export function viaLocal(xs: number[]): void { + const log = (n: number) => record(n) + xs.forEach(log) +} + +// an ambient declaration with no body anywhere: a host API that calls back +declare function onReady(cb: () => void): void + +export function atStartup(): void { + onReady(() => record(RATE)) +} + +// CONTROL: a project higher-order function has a body that calls its parameter, so the callback is +// still a dispatch candidate of the parameter's function type, reached through `each`'s own call. +export function each(xs: number[], fn: (n: number) => void): void { + for (const x of xs) fn(x) +} + +export function useEach(xs: number[]): void { + each(xs, (n) => record(n + RATE)) +} diff --git a/tests/cases/typescript/hof-callback-at-library-boundary/src/globals.d.ts b/tests/cases/typescript/hof-callback-at-library-boundary/src/globals.d.ts new file mode 100644 index 000000000..32f96ef81 --- /dev/null +++ b/tests/cases/typescript/hof-callback-at-library-boundary/src/globals.d.ts @@ -0,0 +1,15 @@ +// The slice of the standard library this case hands callbacks to. Declared here, as client code, so the +// client-only pass sees `map` and `forEach` as what they are to the project: signatures with no body. +interface Array { + map(fn: (v: T) => U): U[]; + forEach(fn: (v: T) => void): void; +} +interface Object {} +interface Boolean {} +interface Number {} +interface String {} +interface Function {} +interface CallableFunction extends Function {} +interface NewableFunction extends Function {} +interface IArguments {} +interface RegExp {} diff --git a/tests/cases/typescript/hof-callback-at-library-boundary/src/rate.ts b/tests/cases/typescript/hof-callback-at-library-boundary/src/rate.ts new file mode 100644 index 000000000..b8af0f524 --- /dev/null +++ b/tests/cases/typescript/hof-callback-at-library-boundary/src/rate.ts @@ -0,0 +1 @@ +export const RATE = 3 diff --git a/tests/cases/typescript/hof-callback-at-library-boundary/src/tsconfig.json b/tests/cases/typescript/hof-callback-at-library-boundary/src/tsconfig.json new file mode 100644 index 000000000..904cf7a11 --- /dev/null +++ b/tests/cases/typescript/hof-callback-at-library-boundary/src/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true + } +} diff --git a/tests/cases/typescript/injected-provider/case.json b/tests/cases/typescript/injected-provider/case.json new file mode 100644 index 000000000..7aa5e93d9 --- /dev/null +++ b/tests/cases/typescript/injected-provider/case.json @@ -0,0 +1,43 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "a Nest provider is handed to a controller's constructor by the container, with no call site; impact on the provider has to name the class that receives it", + "run": [ + "impact", + "CatalogService" + ], + "want": [ + "receives it by dependency injection (ctor_param)", + "CatalogController." + ] + }, + { + "why": "near miss: the same constructor parameter on a class no decorator marks is an ordinary constructor call, not an injection", + "run": [ + "impact", + "PriceTable" + ], + "want": [ + "Quote.total" + ], + "avoid": [ + "dependency injection" + ] + }, + { + "why": "near miss: a parameter wired by an integration's token decorator (@InjectRepository) is not a slot for its declared type, even when that type is itself a provider", + "run": [ + "impact", + "StockRepo" + ], + "want": [ + "StockService.inStock" + ], + "avoid": [ + "dependency injection" + ] + } + ] +} diff --git a/tests/cases/typescript/injected-provider/src/catalog.controller.ts b/tests/cases/typescript/injected-provider/src/catalog.controller.ts new file mode 100644 index 000000000..0bbae05d8 --- /dev/null +++ b/tests/cases/typescript/injected-provider/src/catalog.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { CatalogService, PriceTable } from './catalog.service'; + +@Controller('catalog') +export class CatalogController { + constructor(private readonly catalog: CatalogService) {} + + @Get(':sku') + findOne(@Param('sku') sku: string) { + return this.catalog.lookup(sku); + } +} + +// The same constructor shape on a class no decorator marks: its caller is below. +export class Quote { + constructor(private readonly prices: PriceTable) {} + total(sku: string): number { return this.prices.priceOf(sku); } +} + +export function quote(sku: string): number { + return new Quote(new PriceTable()).total(sku); +} diff --git a/tests/cases/typescript/injected-provider/src/catalog.service.ts b/tests/cases/typescript/injected-provider/src/catalog.service.ts new file mode 100644 index 000000000..515993096 --- /dev/null +++ b/tests/cases/typescript/injected-provider/src/catalog.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class CatalogService { + private items: string[] = []; + lookup(sku: string): string | undefined { + return this.items.find((i) => i === sku); + } +} + +// Handed to a constructor too, but only by code: nothing marks it as a provider. +export class PriceTable { + priceOf(sku: string): number { return sku.length; } +} diff --git a/tests/cases/typescript/injected-provider/src/stock.service.ts b/tests/cases/typescript/injected-provider/src/stock.service.ts new file mode 100644 index 000000000..d36bb7e73 --- /dev/null +++ b/tests/cases/typescript/injected-provider/src/stock.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +@Injectable() +export class StockRepo { + count(sku: string): number { return sku.length; } +} + +// Wired by a TOKEN the decorator names, not by the declared type: StockRepo is only +// the static type here, so this slot must not read as "receives StockRepo". +@Injectable() +export class StockService { + constructor(@InjectRepository(Object) private readonly repo: StockRepo) {} + inStock(sku: string): boolean { return this.repo.count(sku) > 0; } +} diff --git a/tests/cases/typescript/jsx-intrinsic-props/case.json b/tests/cases/typescript/jsx-intrinsic-props/case.json new file mode 100644 index 000000000..e7b6da1bc --- /dev/null +++ b/tests/cases/typescript/jsx-intrinsic-props/case.json @@ -0,0 +1,6 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a props type reached through a tag name:
and

map to FormElementProps in the project's IntrinsicElements, and the file that renders them never writes the name", "run": ["impact", "FormElementProps"], "want": ["Page src/page.tsx:3 — renders , whose attributes are checked against it", "also: renders

, whose attributes are checked against it"], "avoid": ["Picture", "Compare"]}, + {"why": "a base of the props types reaches every tag whose props type extends it", "run": ["impact", "HtmlGlobalProps"], "want": ["renders , whose attributes are checked against FormElementProps, which extends it", "Picture src/page.tsx:11 — renders , whose attributes are checked against ImageProps, which extends it"], "avoid": ["Compare"]}, + {"why": "control: a tag after `return` is an element; a comparison, a string and a comment are not, and a tag mapped to another props type is not listed", "run": ["impact", "ImageProps"], "want": ["Picture src/page.tsx:11 — renders "], "avoid": ["Page ", "Compare", "renders ", "renders

"]}, + {"why": "the answer for an interface says the structurally checked sites are not in it", "run": ["impact", "ImageProps"], "want": ["is checked against it structurally and is not in this answer", "tsc --noEmit"]}]} diff --git a/tests/cases/typescript/jsx-intrinsic-props/src/jsx.d.ts b/tests/cases/typescript/jsx-intrinsic-props/src/jsx.d.ts new file mode 100644 index 000000000..c7e2e90ef --- /dev/null +++ b/tests/cases/typescript/jsx-intrinsic-props/src/jsx.d.ts @@ -0,0 +1,14 @@ +// the project supplies its own JSX namespace: each tag is checked against the props type it maps to +export interface HtmlGlobalProps { class?: string } +export interface FormElementProps extends HtmlGlobalProps { action?: string } +export interface ImageProps extends HtmlGlobalProps { src: string } + +declare global { + namespace JSX { + interface IntrinsicElements { + form: FormElementProps + h1: FormElementProps + img: ImageProps + } + } +} diff --git a/tests/cases/typescript/jsx-intrinsic-props/src/page.tsx b/tests/cases/typescript/jsx-intrinsic-props/src/page.tsx new file mode 100644 index 000000000..aec412539 --- /dev/null +++ b/tests/cases/typescript/jsx-intrinsic-props/src/page.tsx @@ -0,0 +1,18 @@ +export function Page(): unknown { + return ( + +

title

+
+ ) +} + +// CONTROL: renders only an img, whose props are ImageProps +export function Picture(): unknown { + return +} + +// CONTROL: a comparison, a string and a comment that spell a tag render nothing +export function Compare(a: number, img: number): string { + //
in a comment + return a' : '' +} diff --git a/tests/cases/typescript/jsx-intrinsic-props/tsconfig.json b/tests/cases/typescript/jsx-intrinsic-props/tsconfig.json new file mode 100644 index 000000000..f75ffe5b1 --- /dev/null +++ b/tests/cases/typescript/jsx-intrinsic-props/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "jsx": "preserve", "strict": true } } diff --git a/tests/cases/typescript/registered-handler/case.json b/tests/cases/typescript/registered-handler/case.json index 97ed399d0..c259a1474 100644 --- a/tests/cases/typescript/registered-handler/case.json +++ b/tests/cases/typescript/registered-handler/case.json @@ -10,6 +10,6 @@ "avoid": ["no dependent at any certainty"]}, {"why": "a declaration passed to anything else is reported as handed over as a value, never as a route: the receiving call is not named as one that calls it later, since that was wrong for every synchronous collection operation (#1166)", "run": ["impact", "auditLog"], - "want": ["names it as a value", "src/server.ts:7"], + "want": ["handed over as a value", "src/server.ts:7"], "avoid": ["registered as a", "the graph can see: nothing"]} ]} diff --git a/tests/cases/typescript/sites-one-per-line/case.json b/tests/cases/typescript/sites-one-per-line/case.json new file mode 100644 index 000000000..dc87d7361 --- /dev/null +++ b/tests/cases/typescript/sites-one-per-line/case.json @@ -0,0 +1,21 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "impact --grep: one site per line with the code on it and a trailing tag; a test that is an anonymous callback is its line, with no name tacked on", + "run": ["impact", "total", "--grep"], + "want": ["src/cart.ts:4: return total(items); [resolved · checkout]", + "src/cart.ts:7: export function receipt(items: number[]): string { [hop 2]", + "src/cart.test.ts:3: it('prints a receipt', () => { [test · resolved · hop 3]", + "verified: ✓"], + "avoid": ["reads or uses it (", "]", "]"]}, + {"why": "control: without --grep the answer is the verb's own, unchanged", + "run": ["impact", "total"], + "want": ["reads or uses it (1 callable(s): 1 resolved):"], + "avoid": ["src/cart.ts:4: return total(items);"]}, + {"why": "path --grep: each hop at its call line, caller → callee", + "run": ["path", "receipt", "total", "--grep"], + "want": ["src/cart.ts:8: return 'paid ' + String(checkout(items)); [resolved · hop 1/2 receipt → checkout]", + "src/cart.ts:4: return total(items); [resolved · hop 2/2 checkout → total]"]}, + {"why": "context --grep: the declarations the task lands on", + "run": ["context", "the total of a checkout", "--grep"], + "want": ["src/pricing.ts:1: export function total(items: number[]): number { [entry · matches 'total']"], + "avoid": ["where this task's words land"]}]} diff --git a/tests/cases/typescript/sites-one-per-line/src/cart.test.ts b/tests/cases/typescript/sites-one-per-line/src/cart.test.ts new file mode 100644 index 000000000..8666f62a2 --- /dev/null +++ b/tests/cases/typescript/sites-one-per-line/src/cart.test.ts @@ -0,0 +1,5 @@ +import { receipt } from './cart'; + +it('prints a receipt', () => { + expect(receipt([1, 2])).toBe('paid 3'); +}); diff --git a/tests/cases/typescript/sites-one-per-line/src/cart.ts b/tests/cases/typescript/sites-one-per-line/src/cart.ts new file mode 100644 index 000000000..47989b37b --- /dev/null +++ b/tests/cases/typescript/sites-one-per-line/src/cart.ts @@ -0,0 +1,9 @@ +import { total } from './pricing'; + +export function checkout(items: number[]): number { + return total(items); +} + +export function receipt(items: number[]): string { + return 'paid ' + String(checkout(items)); +} diff --git a/tests/cases/typescript/sites-one-per-line/src/pricing.ts b/tests/cases/typescript/sites-one-per-line/src/pricing.ts new file mode 100644 index 000000000..d63879308 --- /dev/null +++ b/tests/cases/typescript/sites-one-per-line/src/pricing.ts @@ -0,0 +1,7 @@ +export function total(items: number[]): number { + return items.reduce((a, b) => a + b, 0); +} + +export function unused(): number { + return 0; +} diff --git a/tests/cases/typescript/test-command-per-runner/app/package.json b/tests/cases/typescript/test-command-per-runner/app/package.json new file mode 100644 index 000000000..93b2466ce --- /dev/null +++ b/tests/cases/typescript/test-command-per-runner/app/package.json @@ -0,0 +1,5 @@ +{ + "name": "app", + "scripts": { "test": "vitest" }, + "devDependencies": { "vitest": "^1.0.4", "tsx": "^4.7.0" } +} diff --git a/tests/cases/typescript/test-command-per-runner/app/src/cart.test.ts b/tests/cases/typescript/test-command-per-runner/app/src/cart.test.ts new file mode 100644 index 000000000..313aa3582 --- /dev/null +++ b/tests/cases/typescript/test-command-per-runner/app/src/cart.test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from 'vitest'; +import { total } from './cart'; + +describe('cart', () => { + it('sums the prices', () => { + expect(total([1, 2, 3])).toBe(6); + }); +}); diff --git a/tests/cases/typescript/test-command-per-runner/app/src/cart.ts b/tests/cases/typescript/test-command-per-runner/app/src/cart.ts new file mode 100644 index 000000000..6f6c7827a --- /dev/null +++ b/tests/cases/typescript/test-command-per-runner/app/src/cart.ts @@ -0,0 +1,5 @@ +export function total(prices: number[]): number { + let sum = 0; + for (const p of prices) sum += p; + return sum; +} diff --git a/tests/cases/typescript/test-command-per-runner/app/src/test/cart-tests.ts b/tests/cases/typescript/test-command-per-runner/app/src/test/cart-tests.ts new file mode 100644 index 000000000..d15f12451 --- /dev/null +++ b/tests/cases/typescript/test-command-per-runner/app/src/test/cart-tests.ts @@ -0,0 +1,12 @@ +/** + * CART TESTS — a script suite, not collected by vitest. + * + * npx tsx src/test/cart-tests.ts + */ +import { total } from '../cart'; + +function testTotalSums(): void { + if (total([2, 2]) !== 4) throw new Error('total'); +} + +testTotalSums(); diff --git a/tests/cases/typescript/test-command-per-runner/app/src/test/orphan-tests.ts b/tests/cases/typescript/test-command-per-runner/app/src/test/orphan-tests.ts new file mode 100644 index 000000000..26273f164 --- /dev/null +++ b/tests/cases/typescript/test-command-per-runner/app/src/test/orphan-tests.ts @@ -0,0 +1,7 @@ +import { total } from '../cart'; + +function testTotalEmpty(): void { + if (total([]) !== 0) throw new Error('empty'); +} + +testTotalEmpty(); diff --git a/tests/cases/typescript/test-command-per-runner/case.json b/tests/cases/typescript/test-command-per-runner/case.json new file mode 100644 index 000000000..69397a58a --- /dev/null +++ b/tests/cases/typescript/test-command-per-runner/case.json @@ -0,0 +1,29 @@ +{ + "lang": "typescript", + "src": "app", + "checks": [ + { + "why": "a script suite vitest does not collect (src/test/cart-tests.ts, run as `npx tsx src/test/cart-tests.ts` per its header) gets that command from its package, not `npx vitest run`; a file nothing runs is named as such (#1570)", + "run": ["test-impact", "--old", "{repo}/old.ts", "--new", "{repo}/new.ts", "--file", "app/src/cart.ts"], + "want": [ + "(cd app && npx tsx src/test/cart-tests.ts)", + "collected by no test runner", + "app/src/test/orphan-tests.ts" + ], + "avoid": ["npx vitest run app/src/test/cart-tests.ts", "vitest run src/test/cart-tests.ts", "npx tsx src/test/orphan-tests.ts", "Traceback"] + }, + { + "why": "CONTROL: a *.test.ts file the package's vitest collects keeps `npx vitest run`, run from its package", + "run": ["test-impact", "--old", "{repo}/old.ts", "--new", "{repo}/new.ts", "--file", "app/src/cart.ts"], + "want": ["(cd app && npx vitest run src/cart.test.ts)"], + "avoid": ["Traceback"] + }, + { + "why": "the --json answer carries the same commands and the files nothing collects", + "run": ["test-impact", "--old", "{repo}/old.ts", "--new", "{repo}/new.ts", "--file", "app/src/cart.ts", "--json"], + "stdout_json": true, + "want": ["npx tsx src/test/cart-tests.ts", "\"not_collected\"", "app/src/test/orphan-tests.ts"], + "avoid": ["Traceback"] + } + ] +} diff --git a/tests/cases/typescript/test-command-per-runner/new.ts b/tests/cases/typescript/test-command-per-runner/new.ts new file mode 100644 index 000000000..23924a4f7 --- /dev/null +++ b/tests/cases/typescript/test-command-per-runner/new.ts @@ -0,0 +1,3 @@ +export function total(prices: number[]): number { + return prices.reduce((a, b) => a + b, 0); +} diff --git a/tests/cases/typescript/test-command-per-runner/old.ts b/tests/cases/typescript/test-command-per-runner/old.ts new file mode 100644 index 000000000..6f6c7827a --- /dev/null +++ b/tests/cases/typescript/test-command-per-runner/old.ts @@ -0,0 +1,5 @@ +export function total(prices: number[]): number { + let sum = 0; + for (const p of prices) sum += p; + return sum; +} diff --git a/tests/cases/typescript/union-alias-member/case.json b/tests/cases/typescript/union-alias-member/case.json new file mode 100644 index 000000000..09b0ade4c --- /dev/null +++ b/tests/cases/typescript/union-alias-member/case.json @@ -0,0 +1,5 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a union member reaches the code that names only the union alias, and an alias over that alias, through the alias declaration", "run": ["impact", "MapState"], "want": ["DraftState src/state.ts:6 — names it (TYPE_ELEMENT)", "finalize src/consumer.ts:3 — names DraftState, a type alias over it (METHOD_PARAM)", "AnyState src/state.ts:9 — names DraftState, a type alias over it (TYPE_ELEMENT)", "maybe src/consumer.ts:7 — names AnyState, a type alias over it (METHOD_PARAM)"], "avoid": ["state.", "unrelated", "show "]}, + {"why": "the alias itself reaches an alias over it and that alias's users; a type alias is not listed as an entry point", "run": ["impact", "DraftState"], "want": ["finalize src/consumer.ts:3 — names it (METHOD_PARAM)", "maybe src/consumer.ts:7 — names AnyState, a type alias over it (METHOD_PARAM)"], "avoid": ["hop(s) AnyState", "unrelated"]}, + {"why": "control: an alias with no member in common is answered as before, by the one function that names it", "run": ["impact", "Plain"], "want": ["show src/unrelated.ts:3 — names it (METHOD_PARAM)"], "avoid": ["finalize", "maybe", "a type alias over it"]}]} diff --git a/tests/cases/typescript/union-alias-member/src/consumer.ts b/tests/cases/typescript/union-alias-member/src/consumer.ts new file mode 100644 index 000000000..7df49cc79 --- /dev/null +++ b/tests/cases/typescript/union-alias-member/src/consumer.ts @@ -0,0 +1,9 @@ +import { AnyState, DraftState } from './state' + +export function finalize(state: DraftState): string { + return state.kind +} + +export function maybe(state: AnyState): string { + return state ? finalize(state) : '' +} diff --git a/tests/cases/typescript/union-alias-member/src/state.ts b/tests/cases/typescript/union-alias-member/src/state.ts new file mode 100644 index 000000000..1a66bc758 --- /dev/null +++ b/tests/cases/typescript/union-alias-member/src/state.ts @@ -0,0 +1,12 @@ +export interface ObjectState { kind: 'object'; value: object } +export interface MapState { kind: 'map'; value: Map } +export interface SetState { kind: 'set'; value: Set } + +// the union the consumers write; none of them names a member +export type DraftState = ObjectState | MapState | SetState + +// a second hop: an alias over the alias +export type AnyState = DraftState | null + +// CONTROL: an alias whose members are unrelated to MapState +export type Plain = { n: number } | string diff --git a/tests/cases/typescript/union-alias-member/src/unrelated.ts b/tests/cases/typescript/union-alias-member/src/unrelated.ts new file mode 100644 index 000000000..b2d83626e --- /dev/null +++ b/tests/cases/typescript/union-alias-member/src/unrelated.ts @@ -0,0 +1,5 @@ +import { Plain } from './state' + +export function show(p: Plain): string { + return typeof p === 'string' ? p : String(p.n) +} diff --git a/tests/cases/typescript/wrapped-handler-route/case.json b/tests/cases/typescript/wrapped-handler-route/case.json new file mode 100644 index 000000000..73fff518d --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/case.json @@ -0,0 +1,33 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a wrapped handler const (`const listUsers = catchAsync(async (req, res) => …)`) handed to a route is registered there, in TypeScript as in JavaScript. The TypeScript graph records no returned-function fact for the wrapper, so the row keeps the name's rung, as for a library wrapper; it was '[by name] … reads it'", + "run": ["impact", "listUsers"], + "want": ["[by name] users. src/routes/users.ts:4 — registered as a GET route \"/users\"", "registered as a POST route \"/users\""], + "avoid": ["src/routes/users.ts:4 — reads it"]}, + {"why": "CONTROL: a function handler passed directly keeps its route row from the method rule", + "run": ["impact", "getUser"], + "want": ["registered as a GET route \"/users/:id\""]}, + {"why": "a schema built by a library, written inside a route's options object (`{ schema: { response: { 200: TaskSchema } } }`), is part of the options, not a handler the router calls: read, never registered", + "run": ["impact", "TaskSchema"], + "want": ["reads it"], + "avoid": ["registered as a"]}, + {"why": "the same for a schema on an options object split over several lines", + "run": ["impact", "UpdateTaskSchema"], + "want": ["reads it"], + "avoid": ["registered as a"]}, + {"why": "an options object passed positionally (`router.post('/users', userOptions, listUsers)`) is not callable", + "run": ["impact", "userOptions"], + "want": ["reads it"], + "avoid": ["registered as a"]}, + {"why": "the router a module registers routes on is their receiver, not a handler of them", + "run": ["impact", "src/routes/users.ts:3"], + "avoid": ["registered as a"]}, + {"why": "a router mounted with `app.use('/api', api)` is a mount, and the app is the receiver: neither is registered as a route", + "run": ["impact", "src/app.ts:4"], + "want": ["[by name] server. src/server.ts:3 — reads it"], + "avoid": ["registered as a"]}, + {"why": "…nor the app", + "run": ["impact", "src/app.ts:3"], + "want": ["reads it"], + "avoid": ["registered as a"]} + ]} diff --git a/tests/cases/typescript/wrapped-handler-route/src/app.ts b/tests/cases/typescript/wrapped-handler-route/src/app.ts new file mode 100644 index 000000000..78f0e3830 --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/src/app.ts @@ -0,0 +1,4 @@ +import express from 'express'; +import usersRouter from './routes/users'; +export const app = express(); +export const api = express.Router(); diff --git a/tests/cases/typescript/wrapped-handler-route/src/controllers/userController.ts b/tests/cases/typescript/wrapped-handler-route/src/controllers/userController.ts new file mode 100644 index 000000000..216a01342 --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/src/controllers/userController.ts @@ -0,0 +1,5 @@ +import { Request, Response } from 'express'; +import { catchAsync } from '../utils/catchAsync'; +export const listUsers = catchAsync(async (req: Request, res: Response) => { res.json([]); }); +export async function getUser(req: Request, res: Response) { res.json({}); } +export const userOptions = { strict: true }; diff --git a/tests/cases/typescript/wrapped-handler-route/src/routes/tasks.ts b/tests/cases/typescript/wrapped-handler-route/src/routes/tasks.ts new file mode 100644 index 000000000..8f4274dfc --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/src/routes/tasks.ts @@ -0,0 +1,20 @@ +import { FastifyInstance } from 'fastify'; +import { TaskSchema, CreateTaskSchema, UpdateTaskSchema } from '../schemas/task'; +export default async function plugin(fastify: FastifyInstance) { + fastify.get('/:id', { schema: { response: { 200: TaskSchema } } }, async function getTask(request: any) { + return { id: request.params.id }; + }); + fastify.post('/', { schema: { body: CreateTaskSchema, response: { 201: TaskSchema } } }, async (request: any) => request.body); + fastify.patch( + '/:id', + { + schema: { + body: UpdateTaskSchema, + response: { 200: TaskSchema } + } + }, + async function updateTask(request: any) { + return request.body; + } + ); +} diff --git a/tests/cases/typescript/wrapped-handler-route/src/routes/users.ts b/tests/cases/typescript/wrapped-handler-route/src/routes/users.ts new file mode 100644 index 000000000..785505488 --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/src/routes/users.ts @@ -0,0 +1,7 @@ +import express from 'express'; +import { listUsers, getUser, userOptions } from '../controllers/userController'; +const router = express.Router(); +router.get('/users', listUsers); +router.get('/users/:id', getUser); +router.post('/users', userOptions, listUsers); +export default router; diff --git a/tests/cases/typescript/wrapped-handler-route/src/schemas/task.ts b/tests/cases/typescript/wrapped-handler-route/src/schemas/task.ts new file mode 100644 index 000000000..0696333f5 --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/src/schemas/task.ts @@ -0,0 +1,4 @@ +import { Type } from '@sinclair/typebox'; +export const TaskSchema = Type.Object({ id: Type.String(), name: Type.String() }); +export const CreateTaskSchema = Type.Object({ name: Type.String() }); +export const UpdateTaskSchema = Type.Partial(CreateTaskSchema); diff --git a/tests/cases/typescript/wrapped-handler-route/src/server.ts b/tests/cases/typescript/wrapped-handler-route/src/server.ts new file mode 100644 index 000000000..1af292190 --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/src/server.ts @@ -0,0 +1,4 @@ +import usersRouter from './routes/users'; +import { app, api } from './app'; +api.use('/v1', usersRouter); +app.use('/api', api); diff --git a/tests/cases/typescript/wrapped-handler-route/src/utils/catchAsync.ts b/tests/cases/typescript/wrapped-handler-route/src/utils/catchAsync.ts new file mode 100644 index 000000000..027203028 --- /dev/null +++ b/tests/cases/typescript/wrapped-handler-route/src/utils/catchAsync.ts @@ -0,0 +1,2 @@ +export type Handler = (req: any, res: any, next: any) => Promise | void; +export const catchAsync = (fn: Handler): Handler => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); diff --git a/tests/changed_range.py b/tests/changed_range.py new file mode 100644 index 000000000..5866985a8 --- /dev/null +++ b/tests/changed_range.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""tests/changed_range.py — what `changed` and `test-impact` answer about a BRANCH, a new file, a copy without git, and a +changed file no graph reads. + +Each behaviour has a near-miss control that must keep today's answer: + + --range a..b after a moved only b's own commits (read from the merge-base), and a note saying a moved + control a range whose base IS an ancestor: the same answer, and no note + a clean tree over commits "no change", then the --range that holds them + control on the base branch itself: no suggestion + a new module one `added ` line; no docstring word, no parameter, nothing at line 1 + control a function added to an existing file keeps its own line + a copy without git a refusal naming what to pass, not invented "added" declarations + control the same copy with a named file: every declaration in it counts, and its tests are named + a changed fixture named as outside the index, with the test file that names it + control a data file no test names: said so, never "no change" + +Each run builds a real graph in a throwaway git repository (Python, so the rules compile once and are cached). + + python3 tests/changed_range.py +""" +import os, shutil, subprocess, sys, tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +AX = os.path.join(ROOT, 'bin', 'axiomcode') + +FILES = { + 'app/__init__.py': '', + 'app/pricing.py': 'def price(q):\n return q * 2\n\n\ndef discount(q):\n return q - 1\n', + 'app/stock.py': 'def level(n):\n return n\n', + 'tests/__init__.py': '', + 'tests/test_pricing.py': ('from app.pricing import price, discount\n\n\ndef test_price():\n assert price(2) == 4\n\n\n' + 'def test_discount():\n assert discount(2) == 1\n'), + 'tests/test_stock.py': 'from app.stock import level\n\n\ndef test_level():\n assert level(1) == 1\n', + 'tests/test_cases.py': ("import json, os\nHERE = os.path.dirname(__file__)\n\n\ndef test_cases():\n" + " for c in os.listdir(os.path.join(HERE, 'cases')):\n" + " json.load(open(os.path.join(HERE, 'cases', c, 'case.json')))\n"), + 'tests/cases/one/case.json': '{"q": 1}\n', + 'data/lookup.csv': 'a,1\n', +} +# a new module whose docstrings hold words a line-by-line reading took for methods (`works (…)`, `side(effect)`) +NEWMOD = '''"""New module. + +It works (see below): import it, and +side(effects) happen. +""" +from app.pricing import price + + +def total(a, b): + """total works + side(effect) + """ + return price(a) + b + + +def unused(x, y, z): + return x +''' + + +def sh(cwd, *cmd, env=None): + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, env=env) + + +def main(): + fails = [] + def check(ok, why, detail=''): + print(('ok ' if ok else 'FAIL ') + why + ('' if ok else '\n ' + detail.strip().replace('\n', '\n '))) + if not ok: fails.append(why) + + work = tempfile.mkdtemp(prefix='axiomcode-changed-range-') + env = dict(os.environ, AXIOMCODE_ENGINE=ROOT) + G = ('git', '-c', 'user.email=t@t', '-c', 'user.name=t') + def write(repo, rel, text): + p = os.path.join(repo, rel); os.makedirs(os.path.dirname(p), exist_ok=True); open(p, 'w').write(text) + def ax(repo, *a): + r = sh(repo, AX, *a, env=env); return r.returncode, r.stdout + r.stderr + try: + repo = os.path.join(work, 'repo'); os.makedirs(repo) + for rel, text in FILES.items(): write(repo, rel, text) + write(repo, '.gitignore', '.axiomcode/\n') + sh(repo, 'git', 'init', '-q', '-b', 'main'); sh(repo, 'git', 'add', '-A'); sh(repo, *G, 'commit', '-qm', 'base') + built = sh(repo, AX, 'index', '.', '--lang', 'python', env=env) + check(built.returncode == 0, 'the graph builds', built.stdout + built.stderr) + + # the branch: a body edit to discount, a new module with its test, a fixture edit + sh(repo, 'git', 'checkout', '-qb', 'feature') + write(repo, 'app/pricing.py', FILES['app/pricing.py'].replace('q - 1', 'q - 2')) + write(repo, 'app/newmod.py', NEWMOD) + write(repo, 'tests/test_newmod.py', 'from app.newmod import total\n\n\ndef test_total():\n assert total(1, 1) == 3\n') + write(repo, 'tests/cases/one/case.json', '{"q": 2}\n') + sh(repo, 'git', 'add', '-A'); sh(repo, *G, 'commit', '-qm', 'feature') + # the base branch moves on: an unrelated change to stock.py + sh(repo, 'git', 'checkout', '-q', 'main') + write(repo, 'app/stock.py', 'def level(n):\n return n + 1\n') + sh(repo, 'git', 'add', '-A'); sh(repo, *G, 'commit', '-qm', 'upstream') + sh(repo, 'git', 'checkout', '-q', 'feature') + + rc, out = ax(repo, 'changed', '.', '--range', 'main..feature') + check(rc == 0 and 'Traceback' not in out, 'changed --range answers', out) + check('range base: merge-base' in out and 'main has moved 1 commit' in out, 'a moved base is named: the answer reads from the merge-base', out) + check('app/stock.py' not in out, "upstream's stock.py is not reported as the branch's change", out) + check('discount' in out, "the branch's own body edit is reported", out) + check('added app/newmod.py' in out and 'new file, 2 declaration(s)' in out, 'a new module is one added line', out) + rows = [l.split() for l in out.split('\n') if l.startswith(' ') and len(l.split()) >= 2] + for word in ('works', 'side', 'import', 'It', 'unused', 'a', 'b', 'x'): + check(not any(r[1] == word or r[1].endswith('.' + word) for r in rows), f"no docstring word, unreferenced function or parameter listed from the new module ({word!r})", out) + check('app/newmod.py:1 ' not in out, 'nothing from the new module at line 1', out) + check('total app/newmod.py:9' in out, 'a new declaration already called from outside its file is listed at its own line', out) + check('outside every indexed language' in out and 'tests/cases/one/case.json' in out, 'the changed fixture is named, not dropped', out) + + rc, out = ax(repo, 'test-impact', '.', '--range', 'main..feature') + check(rc == 0 and 'tests/test_pricing.py' in out, 'test-impact --range selects the tests of the branch edit', out) + check('test_stock' not in out, "test-impact --range does not select the tests of upstream's commit", out) + check('tests/test_newmod.py' in out, 'a new test file is itself a test to run', out) + check("named as 'case.json' by: tests/test_cases.py" in out, 'the test that loads the changed fixture is named', out) + check('page 1 of' not in out, 'no page footer', out) + + # control: a range whose base is an ancestor keeps its answer and gets no note + rc, out = ax(repo, 'changed', '.', '--range', 'feature~1..feature') + check(rc == 0 and 'range base' not in out and 'discount' in out, 'control: an ancestor range has no merge-base note', out) + + # a clean tree whose work is committed: suggest the range + rc, out = ax(repo, 'changed', '.') + check('no change' in out and '--range main..HEAD' in out, 'a clean tree over committed work names the range to ask', out) + rc, out = ax(repo, 'test-impact', '.') + check('test-impact --range main..HEAD' in out, 'test-impact on a clean tree names the range to ask', out) + # control: on main itself there is nothing of its own to suggest + sh(repo, 'git', 'checkout', '-q', 'main') + rc, out = ax(repo, 'changed', '.') + check('no change' in out and '--range' not in out, 'control: the base branch gets no range suggestion', out) + # control: a function ADDED to an existing file keeps its own line + write(repo, 'app/stock.py', 'def level(n):\n return n + 1\n\n\ndef restock(n):\n return n\n') + rc, out = ax(repo, 'changed', '.') + check('restock app/stock.py:5' in out, 'control: an added function in an existing file is at its own line', out) + sh(repo, 'git', 'checkout', '-q', '--', '.') + # control: a data file no test names is said to be unnamed, never "no change" + write(repo, 'data/lookup.csv', 'a,2\n') + rc, out = ax(repo, 'test-impact', '.') + check('data/lookup.csv' in out and 'no test names data/lookup.csv' in out, 'control: an unnamed data file is reported as unnamed', out) + check('no changed declaration the graph can name' not in out, "control: it does not read as 'nothing to test'", out) + sh(repo, 'git', 'checkout', '-q', '--', '.') + + # a copy without git + copy = os.path.join(work, 'copy') + shutil.copytree(repo, copy, ignore=shutil.ignore_patterns('.git', '.axiomcode')) + built = sh(copy, AX, 'index', '.', '--lang', 'python', env=env) + check(built.returncode == 0, 'the copy builds', built.stdout + built.stderr) + write(copy, 'app/pricing.py', FILES['app/pricing.py'].replace('q * 2', 'q * 3')) + rc, out = ax(copy, 'changed', '.') + check(rc != 0 and 'no git base' in out and 'added' not in out, 'no git: changed refuses, naming what to pass', out) + rc, out = ax(copy, 'test-impact', '.') + check(rc != 0 and 'no git base' in out and 'page 1 of' not in out, 'no git: test-impact refuses, with no page footer', out) + rc, out = ax(copy, 'test-impact', '.', 'app/pricing.py') + check(rc == 0 and 'tests/test_pricing.py' in out and 'test_stock' not in out, 'control: test-impact on the copy names that file\'s tests', out) + rc, out = ax(copy, 'changed', '.', 'app/pricing.py') + check('named' in out and 'price' in out and 'discount' in out, 'control: changed on the copy counts its declarations', out) + finally: + shutil.rmtree(work, ignore_errors=True) + print(f"\n{'ok' if not fails else f'{len(fails)} FAILED'}") + return 1 if fails else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/directive.py b/tests/directive.py index 3fa22bc1c..b31ab8af5 100644 --- a/tests/directive.py +++ b/tests/directive.py @@ -7,7 +7,10 @@ Each check names the promise rather than the code path, so a failure here says which promise broke. """ -import json, os, subprocess, sys, tempfile +import json, os, sqlite3, subprocess, sys, tempfile +# the directive's once-per-session stamp lives in the temp directory, keyed on the session: a run of its own, or a +# second run of this script reuses the first run's session ids and hears nothing +os.environ['TMPDIR'] = tempfile.mkdtemp(prefix='ax-hooks-') HOOK = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'plugins', 'axiomcode', 'hooks', 'direct.py') @@ -91,12 +94,48 @@ def check(why, cond, detail=''): open(os.path.join(ro, '.axiomcode', 'out', 'graph.sqlite'), 'w').close() os.chmod(os.path.join(ro, '.axiomcode'), 0o500) # stamp cannot be written try: - rc, out, _ = fire(ro, 'Grep', {'pattern': 'f'}) + rc, out, _ = fire(ro, 'Grep', {'pattern': 'f'}, session='s5') check('a repository it cannot write to still gets its directive, not an error', rc == 0 and ctx(out), f'rc={rc} out={out[:120]}') finally: os.chmod(os.path.join(ro, '.axiomcode'), 0o700) +# ── once per SESSION, not per (session, repository); and the line names the verb for what the search names ── +def graph(repo, rows): + os.makedirs(os.path.join(repo, '.axiomcode', 'out'), exist_ok=True) + con = sqlite3.connect(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')) + con.execute("CREATE TABLE symbols(id TEXT, name TEXT, display TEXT, kind TEXT, qualified_name TEXT, signature TEXT," + " file TEXT, line INT, end_line INT, owner TEXT, is_test INT, method_id TEXT, type_id TEXT)") + con.executemany("INSERT INTO symbols(id, name, display, kind, file, line, is_test) VALUES (?,?,?,?,?,?,0)", rows) + con.commit(); con.close() + +with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b: + for r in (a, b): + os.makedirs(os.path.join(r, 'src')) + open(os.path.join(r, 'src', 'OrderStore.cs'), 'w').write('class OrderStore { public string FindById(int id) => ""; }\n') + graph(r, [('m1', 'FindById', 'OrderStore.FindById', 'method', 'src/OrderStore.cs', 1), + ('t1', 'OrderStore', 'OrderStore', 'class', 'src/OrderStore.cs', 1)]) + rc, out, _ = fire(a, 'Grep', {'pattern': 'FindById'}, session='k1') + said = ctx(out) or '' + check('a Grep for a declared name is told which verb answers it, on that declaration', + 'axiomcode_impact targets=["OrderStore.FindById"]' in said and 'src/OrderStore.cs:1' in said, said[:300]) + check('and the whole directive is short: at most 6 lines', 0 < len(said.splitlines()) <= 6, said) + rc, out, _ = fire(b, 'Grep', {'pattern': 'FindById'}, session='k1') + check('the same session in a second repository hears nothing more: the stamp is per session, not per repository', + rc == 0 and out == '', f'out={out[:160]}') + rc, out, _ = fire(b, 'Grep', {'pattern': 'FindById'}, session='k2') + check('control: a new session in that repository is told', bool(ctx(out)), f'out={out[:160]}') + rc, out, _ = fire(a, 'Grep', {'pattern': 'def fetch_all'}, session='k3') + said = ctx(out) or '' + check('control: a search for a name the graph does not declare gets the general directive only', + said and 'is declared here' not in said and 'axiomcode_impact' in said, said[:300]) + rc, out, _ = fire(a, 'Bash', {'command': "grep -rn 'OrderStore' src"}, session='k4') + said = ctx(out) or '' + check('a shell grep for a declared type is told which verb answers it', 'targets=["OrderStore"]' in said, said[:300]) + rc, out, _ = fire(a, 'Read', {'file_path': os.path.join(a, 'src', 'OrderStore.cs')}, session='k5') + said = ctx(out) or '' + check('a Read of an indexed C# file is told to ask context scoped to it', 'in_path="src/OrderStore.cs"' in said, said[:300]) + print() print(f"{len(checked) - len(fails)} of {len(checked)} promise(s) held" if not fails else f"{len(fails)} FAILED: " + '; '.join(fails)) sys.exit(1 if fails else 0) diff --git a/tests/enrich_lines.py b/tests/enrich_lines.py index 29e3dad7e..d723b263f 100644 --- a/tests/enrich_lines.py +++ b/tests/enrich_lines.py @@ -4,7 +4,7 @@ · a caller count of 0 says why where the graph knows: `entry (http)`, `0 resolved, N by name`, `? framework (@X)`; a method with no signal still reads `0` (the control); · the Grep preview names production callers before tests (#1507), and a name declared several times lists the base - first with its override count, two different files never print as the same line, and the rest are counted (#1546); + first with its override count, two different files never print as the same line, two overloads in one file print once, and the rest are counted (#1546); · a grep run through the shell over another command's output or over files no graph indexes adds nothing, and a constant-shaped word is not looked up as a callable (#1604); · an edit that changes no declaration adds nothing, a body-only edit adds one line of reaching tests and the command @@ -50,6 +50,9 @@ ' public void pokeAll() { new Plain().countAll(); }\n}\n'), P + 'Plain.java': ('package app.orders;\n\npublic class Plain {\n public String countAll() { return "1"; }\n' ' public int unusedCount() { return Integer.parseInt(countAll()); }\n}\n'), + # two overloads in one file: one name to the reader, printed once + P + 'Pricer.java': ('package app.orders;\n\npublic class Pricer {\n public int quoteAll(int n) { return n; }\n\n' + ' public int quoteAll(String s) { return quoteAll(s.length()); }\n}\n'), } # two test stubs in modules whose paths sort before core/, with the same file name and line for m in ('json', 'xml'): @@ -126,6 +129,9 @@ def git(repo, *a): check('the production override comes before test stubs', len(rows) > 1 and 'JsonConverterFactory.widgetConverter' in rows[1], g) check('the declarations not shown are counted', '(+2 more declaration(s))' in g, g) check('two printed lines are never the same', len(rows) == len(set(rows)), g) + g = grep(repo, 'quoteAll') + rows = [l for l in g.splitlines() if l.startswith(' ') and 'Pricer.quoteAll' in l] + check('two overloads in one file print as one line that says so', len(rows) == 1 and '(2 overloads)' in rows[0], g) sys.path.insert(0, HOOKS); import _graphline two = _graphline.distinct_paths(['adapter-json/src/test/java/app/json/Stub.java', 'adapter-xml/src/test/java/app/xml/Stub.java']) check('two same-named files in different modules print with the path that tells them apart', two == ['json/Stub.java', 'xml/Stub.java'], two) @@ -165,6 +171,48 @@ def edit(session, text, hook='enrich.py', event='PostToolUse'): check('the prompt-time report of a body edit is the same one line', 'body edit of OrderStore.findById' in out and 'reads / uses it' not in out, out) open(f, 'w').write(orig) +# ── what a Read of a script says: a call in the text read is not an edge the text does not show ───────────── +PY = { + 'tool.py': ('from lib import fetch\n\n\n' + 'def helper(x):\n return fetch(x)\n\n\n' + 'def main():\n return helper(1)\n\n\n' + 'def untyped(o):\n return o.go()\n\n\n' + 'if __name__ == "__main__":\n main()\n'), + 'lib.py': ('def fetch(x):\n return x\n\n\n' + 'def untyped2(o):\n return o.go()\n'), + 'app.py': ('from lib import untyped2\n\n\ndef run(o):\n return untyped2(o)\n'), + 'many.py': ''.join(f'def f{i}(o):\n return o.go()\n\n\n' for i in range(7)) + + 'g1 = lambda o: o.a()\ng2 = lambda o: o.b()\n\n\n' + # an owner-qualified one (`Walker.`) is as anonymous as a bare one + + 'class Walker:\n h = lambda self, o: o.c()\n', + 'use_many.py': 'import many\n\n\ndef use(o):\n' + ''.join(f' many.f{i}(o)\n' for i in range(7)) + + ' many.g1(o)\n many.g2(o)\n', +} +with tempfile.TemporaryDirectory() as repo: + for n, t in PY.items(): open(os.path.join(repo, n), 'w').write(t) + git(repo, 'init', '-q'); git(repo, 'add', '-A'); git(repo, 'commit', '-qm', 'init') + subprocess.run(['bash', AX, 'index', repo, '--lang', 'python'], capture_output=True, text=True, timeout=1800) + if not os.path.exists(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')): + check('the python project indexes', False) + else: + tool = os.path.join(repo, 'tool.py') + r = fire(repo, 'p1', 'Read', {'file_path': tool}) + check('a whole-script read does not list its own top level as a caller', '' not in r, r) + check('control: the cross-file callee it cannot show is still there', 'helper ←0 →1' in r, r) + r = fire(repo, 'p2', 'Read', {'file_path': tool, 'offset': 8, 'limit': 3}) + check('control: a range that leaves out the top-level call names it, at the line of the call', + 'main L8' in r and 'tool. L17' in r, r) + r = fire(repo, 'p3', 'Read', {'file_path': tool, 'offset': 12, 'limit': 3}) + check('a block of nothing but unresolved counts is not emitted', r == '', r) + r = fire(repo, 'p4', 'Read', {'file_path': os.path.join(repo, 'lib.py'), 'offset': 5, 'limit': 3}) + check('control: the same unresolved call on a declaration with a caller is kept', + 'untyped2 L5 ← run' in r and 'unresolved' in r, r) + r = fire(repo, 'p5', 'Read', {'file_path': os.path.join(repo, 'many.py')}) + more = line_of(r, '+') + check('anonymous functions, bare or owner-qualified, are counted in +N more but not named', + ('+5 more' in more or '+4 more' in more) and '' not in more, r) + check('control: the named ones left over are still named there', 'f' in more.split(':', 1)[-1], r) + print() print(f"{len(checked) - len(fails)} of {len(checked)} check(s) held" if not fails else f"{len(fails)} FAILED: " + '; '.join(fails)) sys.exit(1 if fails else 0) diff --git a/tests/freshness.py b/tests/freshness.py index 09ef0b699..c792a95ee 100644 --- a/tests/freshness.py +++ b/tests/freshness.py @@ -18,6 +18,9 @@ answered with no mark and no note named "nothing named X" for an X an edit newer than the graph wrote says so, names the file and whether a refresh runs, also with AXIOMCODE_NO_REFRESH; a name no edit writes, or one the answer found, is not blamed + engine a graph built by another engine, other rules or another IMPACT_VERSION is stale with no file changed: the + answer comes from it at once with a note, and `index` rebuilds it saying why; the same engine, even reinstalled + elsewhere, with no edit, is current mcp the MCP tools take fresh=true and pass --fresh, and the CLI's --fresh is written fresh=True in an answer No engine: the wait checks drive `ax_fresh.py query` with a stand-in verb, and a stand-in refresh that brings the file @@ -153,7 +156,7 @@ def fake_repo(work, name, build_seconds='3 0', log=''): write(repo, 'shop/report.py', 'def report(items):\n return total(items)\n') write(repo, '.axiomcode/out/graph.sqlite', '') table = dict(lang='python', lang_auto=False, src='', src_arg='', library='', built=time.time(), - files=ax_fresh.snapshot(repo, 'python', repo)) + files=ax_fresh.snapshot(repo, 'python', repo), built_by=ax_fresh.built_by(ax_fresh.current_engine(repo))) json.dump(table, open(os.path.join(repo, '.axiomcode/out/files.json'), 'w')) if build_seconds: write(repo, '.axiomcode/out/build-seconds', build_seconds + '\n') json.dump(dict(state='building', started=time.time()), open(os.path.join(repo, '.axiomcode/refresh.json'), 'w')) @@ -225,6 +228,78 @@ def wait_checks(): shutil.rmtree(work, ignore_errors=True) +# ── engine ──────────────────────────────────────────────────────────────────────────────────────────────────────── +def fake_engine(d, rules='rel(1).\n', version='1.0.0'): + for rel, text in (('bin/axiomcode', '#!/bin/sh\n'), ('graph/python/rules.dl', rules), ('package.json', json.dumps(dict(version=version))), + ('parser/dist/index.js', '// parser\n'), ('parser/dist/index.js.map', '{}'), ('graph/test/case.dl', 'x.\n')): + write(d, rel, text) + return d + + +def engine_checks(): + """a graph built by another engine, other rules or another IMPACT_VERSION is stale with no file changed, and says so; + the same engine, moved or not, with no edit, is current (the near-miss)""" + work = tempfile.mkdtemp(prefix='axiomcode-engine-'); saved = os.environ.get('AXIOMCODE_ENGINE') + try: + e1 = fake_engine(os.path.join(work, 'e1')) + os.environ['AXIOMCODE_ENGINE'] = e1 + repo = fake_repo(work, 'repo'); os.remove(os.path.join(repo, '.axiomcode/refresh.json')) + tp = os.path.join(repo, '.axiomcode/out/files.json'); table = json.load(open(tp)) + uptodate = lambda: subprocess.run([sys.executable, os.path.join(SCRIPTS, 'ax_fresh.py'), 'uptodate', repo, 'python', '', ''], + capture_output=True, text=True, env=dict(os.environ)) + by = table['built_by'] + check("engine: the file table records the engine (its version and content hash), the rules and IMPACT_VERSION", + by.get('engine_version') == '1.0.0' and len(by.get('engine_hash', '')) == 40 and len(by.get('rules', '')) == 40 + and by.get('impact') == ax_fresh.plugin_id()[1] and by['impact'] not in ('', '?'), by) + u = uptodate() + check("engine: control: the same engine and no edit is fresh, and `index` finds it up to date", + ax_fresh.status(repo).get('state') == 'fresh' and u.returncode == 0 and not u.stdout.strip(), (ax_fresh.status(repo), u.stdout)) + # the same bytes in another directory, every mtime new: a reinstall that changed nothing + e2 = os.path.join(work, 'e2'); time.sleep(0.01); fake_engine(e2); os.environ['AXIOMCODE_ENGINE'] = e2 + check("engine: control: the same engine reinstalled elsewhere (new paths and mtimes, same bytes) is fresh", + ax_fresh.engine_change(repo) == '' and ax_fresh.status(repo).get('state') == 'fresh', ax_fresh.engine_change(repo)) + check("engine: its test and source-map files are not part of the engine", + not any(p.endswith(('.map', os.path.join('test', 'case.dl'))) for p in ax_fresh._engine_files(e2)), list(ax_fresh._engine_files(e2))) + fake_engine(e2, rules='rel(2).\n', version='1.0.1') + s = ax_fresh.status(repo); n = ax_fresh.note(s); u = uptodate() + old, new = by['engine_hash'][:8], ax_fresh.engine_id(e2)[1]()[:8] + check("engine: another engine makes the graph stale with no file changed, and names both", + s.get('state') == 'stale' and not ax_fresh.edited(s) and s.get('engine') == f"graph built by an older axiomcode (engine 1.0.0 {old} -> 1.0.1 {new})", s) + check("engine: the answer's note says it comes from that graph while it is rebuilt", + n.startswith(f"graph refresh: graph built by an older axiomcode (engine 1.0.0 {old} -> 1.0.1 {new}); rebuilding in the background"), n) + check("engine: `index` rebuilds it and says why", u.returncode == 1 and u.stdout.strip() == f"graph built by an older axiomcode (engine 1.0.0 {old} -> 1.0.1 {new}); rebuilding", u.stdout) + # the query: an answer at once from the graph it has, unmarked, with the note; the refresh is kicked, not waited for + driver = os.path.join(work, 'driver.py'); open(driver, 'w').write(DRIVER) + t0 = time.time() + r = subprocess.run([sys.executable, driver, SCRIPTS, repo, 'impact', '--', sys.executable, '-c', f"print({ROWS!r})"], + capture_output=True, text=True, env={k: v for k, v in os.environ.items() if k not in ('AXIOMCODE_NO_REFRESH', 'AXIOMCODE_FRESH')}) + check(f"engine: a query answers from the old graph at once ({time.time() - t0:.1f}s), rows unmarked, and says it is rebuilding", + r.stdout.strip() == ROWS.strip() and 'graph built by an older axiomcode (engine' in r.stderr and 'rebuilding in the background' in r.stderr + and time.time() - t0 < 10, (r.stdout, r.stderr)) + os.environ['AXIOMCODE_ENGINE'] = e1 + t = dict(table, built_by=dict(by, impact='1')); json.dump(t, open(tp, 'w')) + check("engine: another IMPACT_VERSION makes it stale, named as such", + ax_fresh.engine_change(repo) == f"graph built by an older axiomcode (IMPACT_VERSION 1 -> {by['impact']})", ax_fresh.engine_change(repo)) + t = dict(table, built_by=dict(by, rules='f' * 40)); json.dump(t, open(tp, 'w')) + check("engine: other query rules make it stale", ax_fresh.engine_change(repo).startswith('graph built by an older axiomcode (rules ffffffff -> '), ax_fresh.engine_change(repo)) + t = dict(table); t.pop('built_by'); json.dump(t, open(tp, 'w')) + check("engine: a table that does not say what built it (every graph from before this) is stale", + ax_fresh.engine_change(repo).startswith('graph built by an older axiomcode (one that did not record its engine -> 1.0.0 '), ax_fresh.engine_change(repo)) + json.dump(table, open(tp, 'w')) + check("engine: control: put back, it is fresh again", ax_fresh.status(repo).get('state') == 'fresh', ax_fresh.status(repo)) + c = [[], [], []]; st = dict(failed_table=ax_fresh.change_key(c), failed_engine='graph built by an older axiomcode (engine a -> b)') + check("engine: a rebuild that failed on a difference is not retried for it, but is for another one, or for another edit", + ax_fresh.failed_on(st, c, st['failed_engine']) and not ax_fresh.failed_on(st, c, 'graph built by an older axiomcode (engine a -> c)') + and not ax_fresh.failed_on(st, [['x.py'], [], []], st['failed_engine'])) + e = dict(os.environ, AXIOMCODE_ENGINE=e2, AXIOMCODE_NO_ENGINE_CHECK='1'); os.environ.update(e) + check("engine: AXIOMCODE_NO_ENGINE_CHECK=1 turns the check off", ax_fresh.engine_change(repo) == '') + finally: + os.environ.pop('AXIOMCODE_NO_ENGINE_CHECK', None) + if saved is None: os.environ.pop('AXIOMCODE_ENGINE', None) + else: os.environ['AXIOMCODE_ENGINE'] = saved + shutil.rmtree(work, ignore_errors=True) + + # ── named ───────────────────────────────────────────────────────────────────────────────────────────────────────── NOT_FOUND = "nothing named 'audit' in the graph, and nothing close to it." @@ -296,7 +371,7 @@ def mcp_checks(): if __name__ == '__main__': - prune_checks(); marks_checks(); wait_checks(); named_checks(); mcp_checks() + prune_checks(); marks_checks(); wait_checks(); engine_checks(); named_checks(); mcp_checks() bad = [n for n, ok in RESULTS if not ok] print(f"\n{len(RESULTS) - len(bad)} of {len(RESULTS)} passed" + (f"; FAILED: {len(bad)}" if bad else '')) sys.exit(1 if bad or not RESULTS else 0) diff --git a/tests/graph_verb.py b/tests/graph_verb.py new file mode 100644 index 000000000..5c9fa025f --- /dev/null +++ b/tests/graph_verb.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""tests/graph_verb.py — `axiomcode graph` draws the graph the index built, and rebuilds it only as it was indexed. + +It used to run axiomcode-build with no flags. That detects every language in the tree, so on a repository indexed with +--lang python the build never matched the recorded graph: every call rebuilt it from scratch in every language +present, a JavaScript or TypeScript compile for a few stray files included, and left a graph for each of them that +later queries answered from. Its output was a raw Python dict and a relative ../../ page path. + +Two throwaway repositories: + + indexed Python under app/, with a stray JavaScript file beside it, indexed with --lang python --src app + current the page is drawn from the graph: no engine run (the graph file is untouched), no graph for another + language, prose counts and the page's absolute path + stale after an edit the graph is rebuilt with --lang python --src app, the page shows the edit, and still no + JavaScript graph appears + control Python and Java, indexed with no --lang (every language): the page draws both, and both graphs stay; + detected languages are not pinned, so a rebuild still detects them + rebuilds C# with a stray TypeScript front end, indexed with --lang csharp: an edit plus a commit (the background + refresh), a bare `index` and a query that repairs a broken graph each solve C# alone + surfaces `axiomcode help graph` and the MCP tool's description describe this, not `axiomcode-graph build` + + python3 tests/graph_verb.py [-v] +""" +import os, shutil, subprocess, sys, tempfile, time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +AX = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') +MCP = os.path.join(ROOT, 'plugins', 'axiomcode', 'mcp', 'server.py') +FRESH = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'ax_fresh.py') + +INDEXED = { + 'app/shop/__init__.py': '', + 'app/shop/orders.py': 'def price(n):\n return n * 3\n\n\ndef order_total(n):\n return price(n) + 1\n', + 'app/shop/cli.py': 'from shop.orders import order_total\n\n\ndef main():\n return order_total(2)\n', + 'app/static/widget.js': 'function strayWidget(a) {\n return a + 1\n}\n\nmodule.exports = { strayWidget }\n', + 'tools/other.py': 'def outside_src():\n return 1\n', +} +CSHARP = { + 'App/App.csproj': '\n net8.0\n\n', + 'App/Orders.cs': 'namespace App;\n\npublic class OrderService\n{\n public decimal Total(int widgets) => Price(widgets) * 2;\n\n private decimal Price(int widgets) => widgets * 3m;\n}\n', + 'App/ClientApp/tsconfig.json': '{ "compilerOptions": { "strict": true } }\n', + 'App/ClientApp/main.ts': 'export function strayFrontEnd(): number {\n return 1\n}\n', +} +MIXED = { + 'pom.xml': '\n 4.0.0\n example\n mixed\n 1.0\n\n', + 'src/main/java/app/Billing.java': 'package app;\n\npublic class Billing {\n public int chargeAccount(int n) {\n return fee(n) + n;\n }\n\n int fee(int n) {\n return 2;\n }\n}\n', + 'src/main/java/app/Invoice.java': 'package app;\n\npublic class Invoice {\n public int issueInvoice() {\n return new Billing().chargeAccount(3);\n }\n}\n', + 'scripts/report/__init__.py': '', + 'scripts/report/make.py': 'def render_report(x):\n return str(x)\n\n\ndef main():\n return render_report(1)\n', +} + + +def sh(cwd, *cmd, env=None): + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, env=env) + + +def make(root, files): + for rel, text in files.items(): + os.makedirs(os.path.dirname(os.path.join(root, rel)), exist_ok=True) + open(os.path.join(root, rel), 'w').write(text) + for cmd in (('git', 'init', '-q'), ('git', 'add', '-A'), ('git', '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'base')): + sh(root, *cmd) + + +def main(argv): + verbose = '-v' in argv + fails = [] + + def check(ok, why, detail=''): + print(('ok ' if ok else 'FAIL ') + why + ('' if ok and not verbose or not detail else '\n ' + detail.strip()[-1500:].replace('\n', '\n '))) + if not ok: fails.append(why) + + work = os.path.realpath(tempfile.mkdtemp(prefix='axiomcode-graphverb-')) # macOS: /var is /private/var, and the page is named by its real path + # the engine is this checkout's, unless the caller names a built one (a worktree whose parser is not built) + env = dict(os.environ, AXIOMCODE_ENGINE=os.environ.get('AXIOMCODE_ENGINE') or ROOT, AXIOMCODE_NO_REFRESH='1') + for k in ('AXIOMCODE_LANG', 'AXIOMCODE_SRC', 'AXIOMCODE_LIBRARY', 'AXIOMCODE_GRAPH'): env.pop(k, None) + try: + # ── indexed with --lang python --src app ────────────────────────────────────────────────────────────── + repo = os.path.join(work, 'indexed'); make(repo, INDEXED) + ax = os.path.join(repo, '.axiomcode'); db = os.path.join(ax, 'out', 'graph.sqlite') + b = sh(repo, 'bash', AX, 'index', repo, '--lang', 'python', '--src', 'app', env=env) + check(b.returncode == 0 and os.path.exists(db), 'setup: the repository indexes with --lang python --src app', b.stdout + b.stderr) + if b.returncode: return 1 + before = (os.path.realpath(db), os.stat(os.path.realpath(db)).st_mtime_ns) + + t0 = time.time(); g = sh(repo, 'bash', AX, 'graph', repo, env=env); took = time.time() - t0 + page = os.path.join(ax, 'graph', 'graph.html') + check(g.returncode == 0 and os.path.exists(page), 'current: the page is written', g.stdout + g.stderr) + check((os.path.realpath(db), os.stat(os.path.realpath(db)).st_mtime_ns) == before and 'no rebuild' in g.stdout and 'building' not in g.stdout, + f'current: the page is drawn from the graph there, the engine does not run ({took:.1f} s)', g.stdout + g.stderr) + check(not os.path.exists(os.path.join(ax, 'lang')) and not os.path.exists(os.path.join(ax, 'out', 'javascript')), + 'current: no graph appears for the stray JavaScript file the index left out', g.stdout) + check(f'page: {page}' in g.stdout and '../' not in g.stdout and "{'" not in g.stdout and ' functions and methods' in g.stdout, + 'current: the output is prose, and names the page by its absolute path', g.stdout) + check('axiomcode-graph build' not in g.stdout + g.stderr, 'current: it advises no command an agent does not have', g.stdout + g.stderr) + html = open(page).read() if os.path.exists(page) else '' + check('order_total' in html and 'strayWidget' not in html and 'outside_src' not in html, + 'current: the page holds the indexed Python and nothing outside --lang / --src', '') + + # an edit: the graph is stale, and is rebuilt as it was indexed + with open(os.path.join(repo, 'app', 'shop', 'orders.py'), 'a') as f: f.write('\n\ndef refund_order(n):\n return -order_total(n)\n') + g = sh(repo, 'bash', AX, 'graph', repo, env=env) + html = open(page).read() if os.path.exists(page) else '' + check(g.returncode == 0 and 'refund_order' in html and 'rebuilt (--lang python --src app)' in g.stdout, + 'stale: the graph is rebuilt with the --lang and --src it was indexed with, and the page shows the edit', g.stdout + g.stderr) + check(not os.path.exists(os.path.join(ax, 'lang')) and 'building python graph' in g.stdout and 'javascript graph' not in g.stdout and '+ javascript' not in g.stdout, + 'stale: the rebuild solves Python alone; no JavaScript graph appears', g.stdout) + check('outside_src' not in html, 'stale: the rebuild keeps --src (a file outside it is not drawn)', '') + + # ── control: indexed with no --lang, every language present ────────────────────────────────────────── + mixed = os.path.join(work, 'mixed'); make(mixed, MIXED) + b = sh(mixed, 'bash', AX, 'index', mixed, env=env) + mx = os.path.join(mixed, '.axiomcode') + both = [os.path.exists(os.path.join(mx, 'out', 'graph.sqlite')), os.path.exists(os.path.join(mx, 'lang', 'python', 'out', 'graph.sqlite'))] + check(b.returncode == 0 and all(both), 'control: a Java and Python repository indexed with no --lang has both graphs', b.stdout + b.stderr) + g = sh(mixed, 'bash', AX, 'graph', mixed, env=env) + mpage = os.path.join(mx, 'graph', 'graph.html') + html = open(mpage).read() if os.path.exists(mpage) else '' + check(g.returncode == 0 and 'chargeAccount' in html and 'render_report' in html and any(l.startswith('graph of ') and 'java' in l and 'python' in l for l in g.stdout.splitlines()), + 'control: the page draws every language the repository was indexed in', g.stdout + g.stderr) + check(os.path.exists(os.path.join(mx, 'lang', 'python', 'out', 'graph.sqlite')) and 'no rebuild' in g.stdout, + 'control: drawing it keeps both graphs and rebuilds neither', g.stdout) + c = sh(mixed, sys.executable, FRESH, 'chosen', mixed, env=env) + check(c.returncode == 0 and c.stdout.strip() == '', 'control: languages that were detected are not pinned; a rebuild detects them again', c.stdout) + + # ── every rebuild path: C# indexed with --lang csharp, a stray TypeScript front end beside it ───────────── + cs = os.path.join(work, 'csharp'); make(cs, CSHARP); cx = os.path.join(cs, '.axiomcode') + b = sh(cs, 'bash', AX, 'index', cs, '--lang', 'csharp', env=env) + check(b.returncode == 0 and os.path.exists(os.path.join(cx, 'out', 'graph.sqlite')) and not os.path.exists(os.path.join(cx, 'lang')), + 'rebuilds: a C# repository indexes with --lang csharp and no TypeScript graph', b.stdout + b.stderr) + c = sh(cs, sys.executable, FRESH, 'chosen', cs, env=env) + check(c.stdout.strip() == 'csharp', 'rebuilds: the file table records csharp as chosen', c.stdout) + # an edit, committed, then the background refresh: the path a hook or the MCP server's timer starts + with open(os.path.join(cs, 'App', 'Orders.cs'), 'a') as f: f.write('\n// committed edit\n') + sh(cs, 'git', '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qam', 'edit') + w = sh(cs, sys.executable, FRESH, 'worker', cs, env=dict(env, AXIOMCODE_REFRESH_DEBOUNCE='0.3')) + stamp = lambda: open(os.path.join(cx, 'out', 'stamp')).read().strip() if os.path.exists(os.path.join(cx, 'out', 'stamp')) else '' + no_ts = lambda: not os.path.exists(os.path.join(cx, 'lang')) and not os.path.exists(os.path.join(cx, 'out', 'typescript')) + check(w.returncode == 0 and 'rebuilding' in w.stdout and '-csharp-' in stamp() and no_ts(), + 'rebuilds: an edit plus a commit refreshes C# alone; no TypeScript graph, no TypeScript solve', w.stdout + stamp()) + # a bare index, as an agent re-runs one "to make sure" + b = sh(cs, 'bash', AX, 'index', cs, env=env) + check(b.returncode == 0 and 'keeping the languages this graph was indexed with (--lang csharp)' in b.stdout and '-csharp-' in stamp() and no_ts(), + 'rebuilds: a bare index keeps --lang csharp and says so', b.stdout + b.stderr) + # a query that finds the graph pointer broken repairs it, with the same languages + ptr = os.path.join(cx, 'out', 'graph.sqlite'); tgt = os.path.realpath(ptr) + os.rename(tgt, tgt + '.gone') + q = sh(cs, 'bash', AX, 'impact', 'OrderService.Total', cs, env=env) + check(q.returncode == 0 and os.path.exists(ptr) and '-csharp-' in stamp() and no_ts(), + 'rebuilds: a query repairing a broken graph builds C# alone', q.stdout[-600:] + q.stderr[-600:]) + + # ── surfaces ────────────────────────────────────────────────────────────────────────────────────────── + h = sh(ROOT, 'bash', AX, 'help', 'graph', env=env) + check(h.returncode == 0 and 'axiomcode graph []' in h.stdout and 'axiomcode-graph build' not in h.stdout and 'as it was indexed' in h.stdout, + '`axiomcode help graph` names the verb agents call and says a stale graph is rebuilt as it was indexed', h.stdout) + doc = open(MCP).read(); i = doc.index('def axiomcode_graph('); doc = doc[i:i + 1200] + check('it was indexed with' in doc and 'absolute path' in doc, 'the MCP axiomcode_graph description says the same', doc) + finally: + shutil.rmtree(work, ignore_errors=True) + print(f"\n{'FAIL' if fails else 'ok'}: {len(fails)} of the checks above failed" if fails else '\nok: every check passed') + return 1 if fails else 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv)) diff --git a/tests/hook_languages.py b/tests/hook_languages.py new file mode 100644 index 000000000..522d45234 --- /dev/null +++ b/tests/hook_languages.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""tests/hook_languages.py: the edit hooks speak for every language the index reads, from one table. + +Each hook kept its own list of source extensions, and the edit hooks' lists had no `.cs`: a C# edit got no graph line +while a C# Read and Grep did. The lists now come from one table (_where.BY_EXT), so this checks the hooks on a C# +project, where the old lists were silent: + + · a body edit to a C# method gets the one line of reaching tests and the command that runs them (enrich.py); + · a signature edit to a C# method gets its blast radius BEFORE it lands (changes.py, PreToolUse); + · a test declared in an abstract C# base runs as the class that extends it, so the command filters on that class, + not on the base, which `dotnet test --filter FullyQualifiedName~` would never match; + · control: an edit to a file no graph is built from (the .csproj) says nothing; + · control: the one table still carries every extension the old lists had. + +It indexes a small project, so it needs the engine, as run.py does. + + python3 tests/hook_languages.py +""" +import json, os, subprocess, sys, tempfile + +# the directive's once-per-session stamp lives in the temp directory, keyed on the session +os.environ['TMPDIR'] = tempfile.mkdtemp(prefix='ax-hooks-') +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +HOOKS = os.path.join(ROOT, 'plugins', 'axiomcode', 'hooks') +AX = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') + +SRC = { + 'src/App/App.csproj': '\n net8.0\n\n', + 'src/App/Repo.cs': ('namespace App;\n\npublic class Repo\n{\n public int Save(int x)\n {\n return x + 1;\n }\n\n' + ' public int Load(int x)\n {\n return x - 1;\n }\n}\n'), + 'src/App/Service.cs': ('namespace App;\n\npublic class Service\n{\n public int Run(Repo r)\n {\n return r.Load(2);\n }\n}\n'), + 'src/App.Tests/App.Tests.csproj': ('\n net8.0\n' + ' \n \n' + ' \n \n\n'), + 'src/App.Tests/RepoContractTests.cs': ('using App;\nusing Xunit;\n\nnamespace App.Tests;\n\npublic abstract class RepoContractTests\n{\n' + ' protected abstract Repo Make();\n\n [Fact]\n public void SavesOne()\n {\n' + ' Make().Save(1);\n }\n}\n'), + 'src/App.Tests/SqlRepoTests.cs': ('using App;\n\nnamespace App.Tests;\n\npublic class SqlRepoTests : RepoContractTests\n{\n' + ' protected override Repo Make() => new Repo();\n}\n'), +} + +fails, checked = [], [] +def check(why, cond, detail=''): + checked.append(why) + print(('ok ' if cond else 'FAIL ') + why + (f'\n {detail}' if not cond and detail else '')) + if not cond: + fails.append(why) + + +def fire(repo, session, tool, inp, hook, event): + ev = {'hook_event_name': event, 'tool_name': tool, 'tool_input': inp, 'cwd': repo, 'session_id': session} + r = subprocess.run([sys.executable, os.path.join(HOOKS, hook)], input=json.dumps(ev), capture_output=True, text=True, timeout=120) + out = r.stdout.strip() + if not out: + return '' + try: + return json.loads(out)['hookSpecificOutput']['additionalContext'] + except (ValueError, KeyError, TypeError): + return out + + +def git(repo, *a): + subprocess.run(['git', '-c', 'user.email=t@t', '-c', 'user.name=t', *a], cwd=repo, capture_output=True, check=True) + + +with tempfile.TemporaryDirectory() as repo: + for n, t in SRC.items(): + os.makedirs(os.path.dirname(os.path.join(repo, n)), exist_ok=True) + open(os.path.join(repo, n), 'w').write(t) + git(repo, 'init', '-q'); git(repo, 'add', '-A'); git(repo, 'commit', '-qm', 'init') + built = subprocess.run(['bash', AX, 'index', repo, '--lang', 'csharp'], capture_output=True, text=True, timeout=1800) + if not os.path.exists(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')): + print('FAIL could not index the project; the engine is needed\n ' + built.stderr.strip()[-300:]) + sys.exit(1) + + f = os.path.join(repo, 'src/App/Repo.cs') + orig = open(f).read() + + # PreToolUse: the edit is applied to a copy, and a signature change is reported before it lands + out = fire(repo, 'c1', 'Edit', {'file_path': f, 'old_string': 'public int Load(int x)', 'new_string': 'public int Load(long x)'}, + 'changes.py', 'PreToolUse') + check('a signature edit to a C# method gets its blast radius before it lands', + 'about to change' in out and 'Repo.Load' in out and 'Service.Run' in out, out) + + # PostToolUse: a body-only edit gets one line, the tests and the command + open(f, 'w').write(orig.replace('x + 1', 'x + 2')) + out = fire(repo, 'c2', 'Edit', {'file_path': f}, 'enrich.py', 'PostToolUse') + check('a body edit to a C# method gets the line of tests that reach it', 'body edit of Repo.Save' in out, out) + check('the command runs the class that extends the abstract base that declares the test', + 'FullyQualifiedName~SqlRepoTests' in out, out) + check('and never the abstract base, which no inherited test is named after', 'FullyQualifiedName~RepoContractTests' not in out, out) + open(f, 'w').write(orig) + + # control: a file no graph is built from + p = os.path.join(repo, 'src/App/App.csproj') + t = open(p).read() + open(p, 'w').write(t.replace('net8.0', 'net9.0')) + out = fire(repo, 'c3', 'Edit', {'file_path': p}, 'enrich.py', 'PostToolUse') + out2 = fire(repo, 'c3', 'Edit', {'file_path': p, 'old_string': 'net9.0', 'new_string': 'net8.0'}, 'changes.py', 'PreToolUse') + check('control: an edit to a .csproj says nothing', out == '' and out2 == '', out + out2) + open(p, 'w').write(t) + +sys.path.insert(0, HOOKS) +import _where +check('control: the one table still carries every extension the hooks listed before', + all(e in getattr(_where, 'SOURCE_EXT', ()) for e in ('.java', '.ts', '.tsx', '.py', '.js', '.jsx', '.mjs', '.cjs', '.cs')), getattr(_where, 'SOURCE_EXT', None)) +check('a C# test project and a *Tests.cs file are test code; a C# source file is not', + getattr(_where, 'is_test', lambda r: None)('src/App.Tests/Anything.cs') and getattr(_where, 'is_test', lambda r: None)('src/Foo/RepoTests.cs') and not getattr(_where, 'is_test', lambda r: None)('src/App/Repo.cs')) + +print() +print(f"{len(checked) - len(fails)} of {len(checked)} check(s) held" if not fails else f"{len(fails)} FAILED: " + '; '.join(fails)) +sys.exit(1 if fails else 0) diff --git a/tests/hooks_from_path.py b/tests/hooks_from_path.py index 6d961600f..fd80ef883 100644 --- a/tests/hooks_from_path.py +++ b/tests/hooks_from_path.py @@ -24,6 +24,9 @@ python3 tests/hooks_from_path.py """ import json, os, shutil, subprocess, sys, tempfile, time +# the directive's once-per-session stamp lives in the temp directory, keyed on the session: a run of its own, or a +# second run of this script reuses the first run's session ids and hears nothing +os.environ['TMPDIR'] = tempfile.mkdtemp(prefix='ax-hooks-') ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) HOOKS = os.path.join(ROOT, 'plugins', 'axiomcode', 'hooks') diff --git a/tests/hosts.py b/tests/hosts.py index 955cb874b..e1f235d84 100644 --- a/tests/hosts.py +++ b/tests/hosts.py @@ -17,6 +17,9 @@ python3 tests/hosts.py """ import json, os, shutil, subprocess, sys, tempfile +# the directive's once-per-session stamp lives in the temp directory, keyed on the session: a run of its own, or a +# second run of this script reuses the first run's session ids and hears nothing +os.environ['TMPDIR'] = tempfile.mkdtemp(prefix='ax-hooks-') ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) HOOKS = os.path.join(ROOT, 'plugins', 'axiomcode', 'hooks') diff --git a/tests/mcp.py b/tests/mcp.py index e16606841..d823c2a8e 100644 --- a/tests/mcp.py +++ b/tests/mcp.py @@ -162,11 +162,47 @@ def check_words(): ("print it with --json", "print it with --json"), (" 49 | args = ['--in', path, '--tests-only']", " 49 | args = ['--in', path, '--tests-only']"), (" | … +23 more line(s) --limit", " | … +23 more line(s) --limit"), - ("a pre-built --lang java graph", "a pre-built --lang java graph")] + ("a pre-built --lang java graph", "a pre-built --lang java graph"), + # a site of a grep-shaped answer is the file's own text: a flag written in that code stays as written + ("tests/freshness.py:294: fn(['--in', p, '--fresh']) [by name ×2 · mcp_checks]", + "tests/freshness.py:294: fn(['--in', p, '--fresh']) [by name ×2 · mcp_checks]"), + # and the footer under the sites is prose, rewritten as ever + ("… +3 more not listed: 3 [text] — pass --in to narrow", "… +3 more not listed: 3 [text] — pass in_path= to narrow")] return [f"mcp_words({src!r}) gave {server.mcp_words(src)!r}, want {want!r}" for src, want in cases if server.mcp_words(src) != want] +def check_grep_default(): + """A list of sites comes one per line (--grep) by default; full=True, and every parameter asking for what only the + prose carries (a flow's code, test routes, a delete verdict, a later page), gives the verb's own answer.""" + sys.path.insert(0, os.path.dirname(SERVER)) + import server + seen = [] + real, server.run = server.run, (lambda args, *a, **k: seen.append(args) or '') + try: + grep = [lambda: server.axiomcode_impact(['A.f']), lambda: server.axiomcode_path('a', 'b'), + lambda: server.axiomcode_context('how'), lambda: server.axiomcode_test_impact(), + lambda: server.axiomcode_impact(['A.f'], tests=True, limit=5)] + prose = [lambda: server.axiomcode_impact(['A.f'], full=True), lambda: server.axiomcode_impact(['A.f'], delete=True), + lambda: server.axiomcode_impact(['A.f'], why=True, tests=True), lambda: server.axiomcode_impact(['A.f'], page=2), + lambda: server.axiomcode_path('a', 'b', full=True), lambda: server.axiomcode_context('how', source=True), + lambda: server.axiomcode_context('how', from_='main'), lambda: server.axiomcode_test_impact(why=True), + lambda: server.axiomcode_changed()] + bad = [] + for f in grep: + seen.clear(); f() + if '--grep' not in seen[0]: bad.append(f"sites answer without --grep: {seen[0]}") + seen.clear(); grep[-1]() + if '--grep-limit' not in seen[0]: bad.append(f"limit=5 did not cap the sites: {seen[0]}") + if '--limit' in seen[0]: bad.append(f"limit=5 passed as the prose's --limit under --grep: {seen[0]}") + for f in prose: + seen.clear(); f() + if any(a.startswith('--grep') for a in seen[0]): bad.append(f"prose asked for, got --grep: {seen[0]}") + return bad + finally: + server.run = real + + def check(label, cmd, cwd, env=None, workdir=None, want_err=None): replies, err = exchange(cmd, cwd, env, workdir) if replies is None: @@ -203,6 +239,7 @@ def main(): # the SDK when the launcher finds one, which ignored an argument it did not know (#1567); else the fallback again bad += check_arguments('bin/axiomcode mcp', ['bash', CLI, 'mcp'], repo, lax=True) bad += check_words() + bad += check_grep_default() bad += check('symlinked axiomcode mcp', [link, 'mcp'], repo) env_note = 'python3 -S server.py (fallback, no SDK)' bad += check(env_note, [sys.executable, '-S', SERVER], repo) diff --git a/tests/mcp_first.py b/tests/mcp_first.py index 39499ab56..1c4b6068a 100644 --- a/tests/mcp_first.py +++ b/tests/mcp_first.py @@ -13,6 +13,9 @@ python3 tests/mcp_first.py indexes one case, so it needs the engine, as hosts.py does """ import json, os, re, shutil, subprocess, sys, tempfile +# the directive's once-per-session stamp lives in the temp directory, keyed on the session: a run of its own, or a +# second run of this script reuses the first run's session ids and hears nothing +os.environ['TMPDIR'] = tempfile.mkdtemp(prefix='ax-hooks-') ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PLUG = os.path.join(ROOT, 'plugins', 'axiomcode') diff --git a/tests/multi_language.py b/tests/multi_language.py index 7d4b743e5..773e85e20 100644 --- a/tests/multi_language.py +++ b/tests/multi_language.py @@ -20,14 +20,21 @@ control a repository in one language gets no .axiomcode/lang and no fan-out build output a Maven repository's target/ and a generated javadoc are not a JavaScript project (#1545); the same repository with JavaScript of its own, one file in a directory named target, still gets that graph + stopped a first query builds the graph, and the caller stops it mid-build (a timeout, Ctrl-C, a host that ends the + process group): the build is not the query's, it goes on and publishes every graph, and the next query + answers from them without building again. `axiomcode index` stopped after its main language is solved + publishes that graph before it stops, and the graph stays stale until the others are built. Controls: + one stopped before anything is solved stops at once and leaves no graph; a repository in one language + behaves the same python3 tests/multi_language.py [-v] """ -import json, os, shutil, subprocess, sys, tempfile, time +import fcntl, json, os, shutil, signal, subprocess, sys, tempfile, time ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) AX = os.path.join(ROOT, 'bin', 'axiomcode') FRESH = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'ax_fresh.py') +BUILD = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode-build') FILES = { 'tsconfig.json': '{ "compilerOptions": { "strict": true }, "include": ["src"] }\n', @@ -88,6 +95,37 @@ def settle(repo, env): time.sleep(0.2) +def building(repo): + """a build holds the repository's build lock""" + try: fd = os.open(os.path.join(repo, '.axiomcode', 'build.lock'), os.O_RDWR) + except OSError: return False + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB); return False + except OSError: return True + finally: os.close(fd) + + +def until(cond, seconds): + end = time.time() + seconds + while time.time() < end: + if cond(): return True + time.sleep(0.05) + return False + + +def stopped_query(repo, env, verb_args): + """a first query in its own process group, stopped by a signal to that group once its build holds the lock, as a + caller's timeout stops it; then the build (if it survived) is waited for. Returns whether the build was running when + the query was stopped""" + p = subprocess.Popen([AX] + verb_args, cwd=repo, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) + started = until(lambda: building(repo) or p.poll() is not None, 300) and p.poll() is None + try: os.killpg(p.pid, signal.SIGTERM) + except OSError: pass + p.wait(); time.sleep(0.5) + until(lambda: not building(repo), 900) + return started + + def main(argv): verbose = '-v' in argv fails = [] @@ -234,6 +272,48 @@ def check(ok, why, detail=''): st = json.loads(sh(one, sys.executable, FRESH, 'status', '.', '--json', env=quiet).stdout or '{}') check(st.get('state') == 'fresh', 'control: and it is fresh', json.dumps(st)) + # ── stopped ─────────────────────────────────────────────────────────────────────────────────────────── + kq = os.path.join(work, 'stopped'); make(kq, FILES) + ran = stopped_query(kq, quiet, ['impact', 'add', '.']) + main_db = os.path.join(kq, '.axiomcode', 'out', 'graph.sqlite') + langs = {l: os.path.exists(os.path.join(kq, '.axiomcode', 'lang', l, 'out', 'graph.sqlite')) for l in ('python', 'javascript')} + check(ran and os.path.exists(main_db) and all(langs.values()), + 'stopped: a first query stopped mid-build leaves the build running, and it publishes the main graph and every other one', + json.dumps(dict(build_was_running=ran, main=os.path.exists(main_db), **langs)) + '\n' + ' '.join(sorted(os.listdir(os.path.join(kq, '.axiomcode', 'out'))))) + q = sh(kq, AX, 'impact', 'add', '.', env=quiet) + check(q.returncode == 0 and '══ python graph' in q.stdout and 'building one' not in q.stderr and 'graph build is' not in q.stderr, + 'stopped: and the next query answers from those graphs, without building again', q.stdout[-600:] + q.stderr[-600:]) + # `axiomcode index` stopped once its main language is solved publishes that graph first + ki = os.path.join(work, 'stopped-index'); make(ki, FILES) + prog = os.path.join(ki, '.axiomcode', 'out', '.progress') + b = subprocess.Popen(['bash', BUILD, ki], cwd=ki, env=quiet, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, start_new_session=True) + solved = until(lambda: 'typescript ok' in (open(prog).read() if os.path.exists(prog) else ''), 600) + os.killpg(b.pid, signal.SIGTERM); out, _ = b.communicate() + ptr = os.path.join(ki, '.axiomcode', 'out', 'graph.sqlite') + check(solved and b.returncode != 0 and os.path.exists(ptr) and os.path.exists(os.path.join(ki, '.axiomcode', 'out', 'stamp')), + 'stopped: an index stopped after its main language is solved publishes that graph before it stops', f'rc={b.returncode} solved={solved}\n{out}') + q = sh(ki, AX, 'impact', 'square', '.', env=quiet) + check(q.returncode == 0 and 'area' in q.stdout and 'building one' not in q.stderr, + 'stopped: and a query answers from it without building', q.stdout[-600:] + q.stderr[-600:]) + st = json.loads(sh(ki, sys.executable, FRESH, 'status', '.', '--json', env=quiet).stdout or '{}') + r = sh(ki, AX, 'index', '.', env=quiet) + check(st.get('state') == 'stale' and 'up to date' not in r.stdout + and all(os.path.exists(os.path.join(ki, '.axiomcode', 'lang', l, 'out', 'graph.sqlite')) for l in ('python', 'javascript')), + 'stopped: the languages it did not build leave the graph stale, and the next build builds them', json.dumps(st) + '\n' + r.stdout[-600:]) + # control: stopped before anything is solved, it stops (no graph to keep) and the next query builds one + kc = os.path.join(work, 'stopped-early'); make(kc, FILES) + b = subprocess.Popen(['bash', BUILD, kc], cwd=kc, env=quiet, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, start_new_session=True) + until(lambda: building(kc), 60) + t0 = time.time(); os.killpg(b.pid, signal.SIGTERM); out, _ = b.communicate(); took = time.time() - t0 + check(b.returncode != 0 and took < 15 and not os.path.lexists(os.path.join(kc, '.axiomcode', 'out', 'graph.sqlite')), + f'control: an index stopped before its main language is solved stops at once ({took:.1f}s) and leaves no graph', f'rc={b.returncode}\n{out}') + # control: a repository in one language, its first query stopped mid-build, gets its graph the same way + k1 = os.path.join(work, 'stopped-one'); make(k1, {k: v for k, v in FILES.items() if k.startswith(('src/', 'tsconfig'))}) + ran = stopped_query(k1, quiet, ['impact', 'square', '.']) + q = sh(k1, AX, 'impact', 'square', '.', env=quiet) + check(ran and not os.path.exists(os.path.join(k1, '.axiomcode', 'lang')) and q.returncode == 0 and 'area' in q.stdout and 'building one' not in q.stderr, + 'stopped: in a repository of one language too, the stopped first query\'s build completes and the next query answers', q.stdout[-400:] + q.stderr[-400:]) + # ── build output ────────────────────────────────────────────────────────────────────────────────────── mvn = os.path.join(work, 'maven'); make(mvn, MAVEN) b = sh(mvn, AX, 'index', '.', env=quiet) diff --git a/tests/query_rules.py b/tests/query_rules.py new file mode 100644 index 000000000..e27aed46a --- /dev/null +++ b/tests/query_rules.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""tests/query_rules.py: the query rules are compiled once per MACHINE, never while a query waits (#1606). + +The query programs (dl/impact.dl, dl/path*.dl) are compiled to native binaries keyed by their rules. They were kept in +the plugin's own dl/.cache, so every plugin copy (an update, a second checkout, a worktree) recompiled identical rules, +and the first query after a build could pay the whole compile (44 s for impact.dl on a C# library). Checked here on a +tiny program in throwaway plugin copies, with the user cache pointed at a temporary XDG_CACHE_HOME: + + - a query with nothing compiled answers at once, from the interpreter, and starts ONE background compile + - the binary lands in the user cache, not in the plugin; a second plugin copy with the same rules reuses it + - control: a copy whose rules differ by one line does not + - a binary an older plugin left in dl/.cache is still used + - three queries at once all answer, and leave one binary and no lock, .cpp or .tmp behind + - the interpreter and the binary derive the same rows + - warm() (what `axiomcode index` calls) reports every program compiled + + python3 tests/query_rules.py [-v] + +Needs soufflé and a C++ compiler; without them it says so and passes nothing. +""" +import os, shutil, subprocess, sys, tempfile, time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SCRIPTS = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts') +RULES = '.decl e(a:number, b:number)\n.input e\n.decl r(a:number, b:number)\n.output r\nr(a, b) :- e(a, b).\nr(a, c) :- r(a, b), e(b, c).\n' +RULES_OTHER = RULES + 'r(a, a) :- e(a, _).\n' +fails = [] +passed = 0 +verbose = '-v' in sys.argv + + +def check(ok, what, detail=''): + global passed + if ok: passed += 1 + else: fails.append(what) + print((' ok ' if ok else ' FAIL ') + what + ('' if ok or not detail else '\n ' + str(detail)[:600].replace('\n', '\n '))) + + +def plugin(work, name, rules=RULES): + d = os.path.join(work, name, 'scripts'); os.makedirs(os.path.join(d, 'dl')) + shutil.copy(os.path.join(SCRIPTS, 'dl_program.py'), d) + with open(os.path.join(d, 'dl', 'q.dl'), 'w') as fh: fh.write(rules) + return d + + +def py(scripts, code, env): + t = time.time() + r = subprocess.run([sys.executable, '-c', 'import sys; sys.path.insert(0, sys.argv[1]); import dl_program as d\n' + code, scripts], + capture_output=True, text=True, env=env, timeout=300) + return r.stdout.strip(), r.stderr, time.time() - t + + +def wait_for(pred, secs=240): + end = time.time() + secs + while time.time() < end: + if pred(): return True + time.sleep(0.5) + return False + + +def main(): + if not (shutil.which('souffle') and shutil.which('c++')): + print('soufflé or c++ missing: nothing to check'); return 0 + work = tempfile.mkdtemp(prefix='ax-query-rules-') + try: + env = dict(os.environ, XDG_CACHE_HOME=os.path.join(work, 'xdg')); env.pop('AXIOMCODE_QUERY_CACHE', None); env.pop('AXIOMCODE_INTERPRET', None) + qdir = os.path.join(work, 'xdg', 'axiomcode', 'queries') + a = plugin(work, 'copy-a') + + # 1. nothing compiled: the interpreter at once, a compile in the background + out, err, took = py(a, "print(' '.join(d.program('q.dl')))", env) + check(out.startswith('souffle ') and took < 10, f'a first query answers from the interpreter at once ({took:.1f}s)', out + err) + check('compiling to a native binary in the background' in err, 'and says the rules are compiling in the background', err) + done = wait_for(lambda: any(n.startswith('q-') and not n.endswith(('.lock', '.nocompile', '.cpp', '.tmp')) for n in os.listdir(qdir)) if os.path.isdir(qdir) else False) + check(done, 'the background compile finishes into the user cache', os.listdir(qdir) if os.path.isdir(qdir) else 'no cache dir') + check(not os.path.exists(os.path.join(a, 'dl', '.cache')), 'nothing is written inside the plugin (dl/.cache)') + wait_for(lambda: not any(n.endswith('.lock') for n in os.listdir(qdir)), 30) + binp, _, _ = py(a, "print(d.program('q.dl')[0])", env) + check(binp.startswith(qdir) and os.access(binp, os.X_OK), 'the next query runs the compiled binary', binp) + + # 2. another plugin copy with the same rules: no compile + b = plugin(work, 'copy-b') + out, err, took = py(b, "print(d.program('q.dl')[0])", env) + check(out == binp and 'compiling' not in err, f'a second plugin copy with the same rules reuses that binary ({took:.1f}s)', out + err) + # control: rules one line apart are not the same program + c = plugin(work, 'copy-c', RULES_OTHER) + out, err, _ = py(c, "print(' '.join(d.program('q.dl')))", env) + check(out.startswith('souffle ') and binp not in out, 'control: a copy whose rules differ gets no binary of the other rules', out) + + # 3. a binary an older plugin compiled into its own dl/.cache is still used + e = plugin(work, 'copy-e'); key, _, _ = py(e, "print(d.rules_id(d.resolve('q.dl')))", env) + os.makedirs(os.path.join(e, 'dl', '.cache')); legacy = os.path.join(e, 'dl', '.cache', f'q-{key}'); shutil.copy(binp, legacy) + env2 = dict(env, XDG_CACHE_HOME=os.path.join(work, 'xdg-empty')) + out, err, _ = py(e, "print(d.program('q.dl')[0])", env2) + check(out == legacy, "a binary left in the plugin's dl/.cache by an older version is used", out + err) + # ... unless it was built for another machine: a plugin copy synced from a Mac to Linux (or back) carries the + # other machine's binary under the right name, and running it was an OSError (Exec format error) mid-query + g = plugin(work, 'copy-g'); os.makedirs(os.path.join(g, 'dl', '.cache')); foreign = os.path.join(g, 'dl', '.cache', f'q-{key}') + with open(foreign, 'wb') as fh: + fh.write(b'\xcf\xfa\xed\xfe\x0c\x00\x00\x01' + b'\0' * 24 if sys.platform.startswith('linux') + else b'\x7fELF\x02\x01\x01\x00' + b'\0' * 10 + b'\x3e\x00' + b'\0' * 12) + os.chmod(foreign, 0o755) + out, err, _ = py(g, "print(' '.join(d.program('q.dl')))", env2) + check(out.startswith('souffle ') and foreign not in out, "a dl/.cache binary built for another OS is not run: the interpreter answers", out + err) + out, _, _ = py(e, f"print(d.runs_here({legacy!r}))", env2) + check(out == 'True', 'control: the same check passes the binary this machine compiled', out) + + # 4. three queries at once on a cold cache: one compile, nothing left behind + env3 = dict(env, XDG_CACHE_HOME=os.path.join(work, 'xdg-race')); q3 = os.path.join(work, 'xdg-race', 'axiomcode', 'queries') + f = plugin(work, 'copy-f') + procs = [subprocess.Popen([sys.executable, '-c', "import sys; sys.path.insert(0, sys.argv[1]); import dl_program as d; print(d.program('q.dl')[0])", f], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env3) for _ in range(3)] + outs = [p.communicate()[0].strip() for p in procs] + check(all(o == 'souffle' for o in outs), 'three queries at once all answer from the interpreter, none waits', outs) + def settled(): + return os.path.isdir(q3) and any(n.startswith('q-') and '.' not in n for n in os.listdir(q3)) \ + and not any(n.endswith('.lock') for n in os.listdir(q3)) + wait_for(settled) + left = sorted(os.listdir(q3)) if os.path.isdir(q3) else [] + check(len([n for n in left if n.startswith('q-') and '.' not in n]) == 1 and not [n for n in left if n.endswith(('.lock', '.cpp', '.tmp', '.nocompile'))], + 'they leave one binary and no lock, .cpp, .tmp or failure marker', left) + + # 5. the interpreter and the binary derive the same rows + facts = os.path.join(work, 'facts'); os.makedirs(facts) + with open(os.path.join(facts, 'e.facts'), 'w') as fh: fh.write('1\t2\n2\t3\n3\t4\n7\t8\n') + rows = {} + for how, argv in (('interpreter', ['souffle', os.path.join(a, 'dl', 'q.dl')]), ('binary', [binp])): + o = os.path.join(work, 'out-' + how); os.makedirs(o) + subprocess.run(argv + ['-F', facts, '-D', o], check=True, capture_output=True) + rows[how] = sorted(l for l in open(os.path.join(o, 'r.csv')).read().split('\n') if l) + check(rows['interpreter'] == rows['binary'] and len(rows['binary']) >= 7, f"the interpreter and the binary derive the same {len(rows['binary'])} rows", rows) + + # 6. what `axiomcode index` prints + out, err, _ = py(b, "d.warm()", env) + check('datalog rules ready: 1/1 compiled (q.dl=cached)' in out, 'warm() reports every program compiled', out + err) + out, err, _ = py(c, "d.warm(wait=False)", env) + check('q.dl=compiling' in out or 'q.dl=cached' in out, 'warm(wait=False) on new rules starts their compile and does not wait', out + err) + wait_for(lambda: not any(n.endswith('.lock') for n in os.listdir(qdir)), 240) + finally: + if verbose: print('kept', work) + else: shutil.rmtree(work, ignore_errors=True) + print(f"{passed} passed, {len(fails)} FAILED" if fails else f'{passed} passed, all checks held') + return 1 if fails or passed < 1 else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/refresh.py b/tests/refresh.py index 787aebff7..39d7c3bfd 100644 --- a/tests/refresh.py +++ b/tests/refresh.py @@ -109,6 +109,34 @@ def check(ok, why, detail=''): t0 = time.time(); again = sh(repo, AX, 'index', '.', '--lang', lang, env=env); took = time.time() - t0 check('graph up to date' in again.stdout, f'{lang}: `index` with nothing changed does not rebuild ({took:.1f}s)', again.stdout + again.stderr) + # ── built by an older axiomcode ──────────────────────────────────────────────────────────────────── + # no file changed, but the table says another engine and another IMPACT_VERSION built the graph (what a plugin + # update leaves behind): not up to date. A query answers from it at once and says so, the refresher rebuilds + # it, and `index` rebuilds it saying why. The control is the check above: same engine, no edit, "graph up to + # date" and no rebuild + tp = os.path.join(out, 'files.json') + def older(): + t = json.load(open(tp)); by = t.get('built_by') or {} + check(by.get('engine_hash') and by.get('rules') and by.get('impact'), f'{lang}: the file table records the engine, rules and IMPACT_VERSION that built the graph', json.dumps(by)) + by.update(engine_hash='0' * 40, engine_stat='0' * 40, engine_version='0.0.1', impact='1'); t['built_by'] = by + json.dump(t, open(tp, 'w')) + older() + q = sh(repo, AX, 'impact', helper, '.', env=quiet) + check(q.returncode == 0 and helper in q.stdout and 'graph built by an older axiomcode (engine 0.0.1 00000000 ->' in q.stderr + and 'IMPACT_VERSION 1 ->' in q.stderr and 'nothing rebuilds it' in q.stderr, + f'{lang}: a query over a graph an older axiomcode built answers from it and says so', q.stdout[-300:] + q.stderr) + fr = sh(repo, AX, 'impact', helper, '.', '--fresh', env=env) + m = meta() + check(fr.returncode == 0 and 'graph built by an older axiomcode' in fr.stderr and 'graph refresh:' not in fr.stderr + and 'graph built by an older axiomcode' in m.get('refresh_reason', '') and 'found by a query' in m.get('refresh_reason', ''), + f'{lang}: the refresher rebuilds it with no file changed, and --fresh answers from the new graph', fr.stderr + json.dumps(m)) + older() + ix = sh(repo, AX, 'index', '.', '--lang', lang, env=env) + check(ix.returncode == 0 and 'graph built by an older axiomcode' in ix.stdout and '; rebuilding' in ix.stdout and 'graph up to date' not in ix.stdout, + f'{lang}: `index` over it rebuilds, and says why', ix.stdout + ix.stderr) + again = sh(repo, AX, 'index', '.', '--lang', lang, env=env) + check('graph up to date' in again.stdout and 'older axiomcode' not in again.stdout, f'{lang}: control: then `index` finds it up to date', again.stdout + again.stderr) + # ── an added function ───────────────────────────────────────────────────────────────────────────── f = os.path.join(repo, L['edit']); text = open(f).read() if isinstance(L['add'], tuple): text = text[:text.rindex(L['add'][0])] + L['add'][1] diff --git a/tests/refresh_races.py b/tests/refresh_races.py index ea013f44e..37879cff3 100644 --- a/tests/refresh_races.py +++ b/tests/refresh_races.py @@ -16,6 +16,11 @@ one) and impact answers. Control: a failed build with the same main language puts that language's directory back and leaves no stray link. failure reason a background refresh that fails says WHY in the note a query prints, not only "see build.log". + copied checkout graph.sqlite points at its graph by a relative name, so a copy reads its own graph and sees its + own edits (#1605). An absolute pointer an older build left, into another checkout that is still + there or gone, is re-pointed at the copy's graph and said as such, never "a build was + interrupted". Controls: the original does not see the copy's edit; an absolute pointer into + its own graph is kept silently. gitignore the index and the refresher skip what git ignores: a generated tree .gitignore names is neither parsed nor watched. Controls: a tracked file under an ignored name is read; AXIOMCODE_NO_GITIGNORE=1 reads everything. @@ -99,17 +104,21 @@ def check(ok, why, detail=''): # ── query mid-build ─────────────────────────────────────────────────────────────────────────────────── lock = os.path.join(repo, '.axiomcode', 'build.lock') + # the repair above answered as soon as its graph was published (#1555) and may still be solving: it ends first, + # or it re-points graph.sqlite under the lock this check holds + fd = os.open(lock, os.O_RDWR | os.O_CREAT, 0o644); fcntl.flock(fd, fcntl.LOCK_EX); fcntl.flock(fd, fcntl.LOCK_UN); os.close(fd) good = os.path.realpath(ptr); os.remove(ptr) held = threading.Event() def hold(): fd = os.open(lock, os.O_RDWR | os.O_CREAT, 0o644); fcntl.flock(fd, fcntl.LOCK_EX); held.set() - time.sleep(3); os.symlink(good, ptr); fcntl.flock(fd, fcntl.LOCK_UN); os.close(fd) + try: time.sleep(3); os.symlink(good, ptr) + finally: fcntl.flock(fd, fcntl.LOCK_UN); os.close(fd) # never left held: every later build would wait on it t = threading.Thread(target=hold); t.start(); held.wait() logm = os.path.getmtime(os.path.join(repo, '.axiomcode', 'build.log')) t0 = time.time(); r = sh(repo, AX, 'impact', 'helper', '.', env=dict(env, AXIOMCODE_NO_REFRESH='1')); took = time.time() - t0 t.join() - check(r.returncode == 0 and 'caller' in r.stdout and 'a graph build is running' in r.stderr, + check(r.returncode == 0 and 'caller' in r.stdout and ('a graph build is running' in r.stderr or 'a graph build is already running' in r.stderr), f'query mid-build: a query that finds no graph while a build runs waits for it, says so, and answers ({took:.1f}s)', r.stdout[-600:] + r.stderr[-600:]) check(os.path.getmtime(os.path.join(repo, '.axiomcode', 'build.log')) == logm, 'query mid-build: and starts no build of its own') @@ -153,6 +162,39 @@ def hold(): 'control: a failed build with the same main language puts its directory back, with no stray link', f"{os.path.realpath(mptr)} live={os.path.exists(os.path.join(mout, '.live.sqlite'))}") + # ── copied checkout ─────────────────────────────────────────────────────────────────────────────────── + orig = repo_with(work, 'orig', {'tsconfig.json': TSCONFIG, 'src/util.ts': UTIL}) + b = sh(orig, AX, 'index', '.', env=env) + optr = os.path.join(orig, '.axiomcode', 'out', 'graph.sqlite') + check(b.returncode == 0 and os.path.islink(optr) and not os.path.isabs(os.readlink(optr)), + 'copied checkout: graph.sqlite points at its graph by a name relative to its directory', f"{os.readlink(optr) if os.path.islink(optr) else '?'}\n{b.stdout[-400:]}") + cp = os.path.join(work, 'copy'); shutil.copytree(orig, cp, symlinks=True) + cptr = os.path.join(cp, '.axiomcode', 'out', 'graph.sqlite') + open(os.path.join(cp, 'src', 'util.ts'), 'a').write('\nexport function third(): number {\n return helper()\n}\n') + check(os.path.realpath(cptr).startswith(os.path.realpath(cp) + os.sep), 'copied checkout: the copy reads its own graph', os.path.realpath(cptr)) + b = sh(cp, AX, 'index', '.', env=env) + r = sh(cp, AX, 'impact', 'helper', '.', env=quiet) + check(b.returncode == 0 and r.returncode == 0 and 'third' in r.stdout and os.path.realpath(cptr).startswith(os.path.realpath(cp) + os.sep), + 'copied checkout: the copy rebuilds its own graph and sees its own edit', b.stdout[-400:] + r.stdout[-600:] + r.stderr[-600:]) + r = sh(orig, AX, 'impact', 'helper', '.', env=quiet) + check(r.returncode == 0 and 'third' not in r.stdout and 'caller' in r.stdout, 'control: the original does not see the copy\'s edit', r.stdout[-400:]) + # a pointer an older build left: absolute, into the original, whether the original is still there or gone + leg = os.path.join(work, 'legacy'); shutil.copytree(orig, leg, symlinks=True) + lptr = os.path.join(leg, '.axiomcode', 'out', 'graph.sqlite'); logp = os.path.join(leg, '.axiomcode', 'build.log') + for case, target in (('the original still there', os.path.realpath(optr)), + ('the original moved away', os.path.join(work, 'gone', '.axiomcode', 'out', 'typescript', 'graph.sqlite'))): + os.remove(lptr); os.symlink(target, lptr); logm = os.path.getmtime(logp) + r = sh(leg, AX, 'impact', 'helper', '.', env=quiet) + check(r.returncode == 0 and 'caller' in r.stdout and "another checkout's graph" in r.stderr and 're-pointed at its own graph' in r.stderr + and 'interrupted' not in r.stderr and os.readlink(lptr) == 'typescript/graph.sqlite' and os.path.getmtime(logp) == logm, + f'copied checkout: an absolute pointer into another checkout ({case}) is re-pointed at this one\'s graph, said as such, with no rebuild', + f"{os.readlink(lptr)}\n" + r.stdout[-400:] + r.stderr[-600:]) + # control: an absolute pointer into this checkout's own out/ is its graph, re-pointed without a word + os.remove(lptr); os.symlink(os.path.join(os.path.realpath(leg), '.axiomcode', 'out', 'typescript', 'graph.sqlite'), lptr) + r = sh(leg, AX, 'impact', 'helper', '.', env=quiet) + check(r.returncode == 0 and 'caller' in r.stdout and 'another checkout' not in r.stderr and os.readlink(lptr) == 'typescript/graph.sqlite', + 'control: an older absolute pointer into its own graph is kept, relative, silently', r.stderr[-400:]) + # ── gitignore ───────────────────────────────────────────────────────────────────────────────────────── gen = {f'gen/deep/g{i}.ts': f'export function gen{i}(): number {{\n return {i}\n}}\n' for i in range(200)} gi = repo_with(work, 'gitignore', dict({'tsconfig.json': '{ "compilerOptions": { "strict": true } }\n', 'src/util.ts': UTIL, diff --git a/tests/test_command.py b/tests/test_command.py new file mode 100644 index 000000000..b045d78eb --- /dev/null +++ b/tests/test_command.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +"""tests/test_command.py — the command test-impact prints for a TypeScript or JavaScript selection runs those files. + +`npx vitest run ` on a file vitest does not collect exits 1 with "No test files found" (#1570). Each file is +placed with the runner its own package would collect it with (that runner's include globs), else with the command +its package scripts, its own header or its package README give for it, else named as collected by nothing. Every +shape here has a control beside it: a file the runner does collect keeps the runner's command. No engine needed. +""" +import importlib.machinery, importlib.util, json, os, shutil, sys, tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +SCRIPTS = os.path.join(os.path.dirname(HERE), 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts') +sys.path.insert(0, SCRIPTS) +loader = importlib.machinery.SourceFileLoader('ti', os.path.join(SCRIPTS, 'axiomcode-test-impact')) +ti = importlib.util.module_from_spec(importlib.util.spec_from_loader('ti', loader)); loader.exec_module(ti) +import ax_pages + +fails = 0 + + +def check(why, got, want): + global fails + if got != want: + fails += 1; print(f"FAIL {why}\n got: {got!r}\n want: {want!r}") + else: + print(f"ok {why}") + + +def tree(files): + d = tempfile.mkdtemp(prefix='ax-cmd-') + for rel, text in files.items(): + p = os.path.join(d, rel); os.makedirs(os.path.dirname(p), exist_ok=True) + open(p, 'w').write(text if isinstance(text, str) else json.dumps(text)) + return d + + +VITEST_PKG = {'scripts': {'test': 'vitest'}, 'devDependencies': {'vitest': '1'}} + +# the defaults the runners document, read as globs +for g, path, want in [('**/*.{test,spec}.?(c|m)[jt]s?(x)', 'src/a.test.ts', True), + ('**/*.{test,spec}.?(c|m)[jt]s?(x)', 'a.spec.mjs', True), + ('**/*.{test,spec}.?(c|m)[jt]s?(x)', 'src/test/a-tests.ts', False), + ('**/?(*.)+(spec|test).[jt]s?(x)', 'src/a.test.tsx', True), + ('**/?(*.)+(spec|test).[jt]s?(x)', 'src/test.ts', True), + ('**/?(*.)+(spec|test).[jt]s?(x)', 'src/tests.ts', False), + ('**/__tests__/**/*.[jt]s?(x)', 'src/__tests__/a.ts', True), + ('test/*.{js,cjs,mjs}', 'test/a.js', True), + ('test/*.{js,cjs,mjs}', 'test/sub/a.js', False)]: + check(f"glob {g} {'matches' if want else 'does not match'} {path}", ti._globs_match((g,), path), want) + +# #1570: a script suite in a vitest package is run as its header says, from its package +d = tree({'parser/package.json': VITEST_PKG, + 'parser/src/test/a-tests.ts': '/**\n * npx tsx src/test/a-tests.ts # everything\n */\n', + 'parser/src/test/b-tests.ts': 'import x from "y";\n', + 'parser/README.md': '```\nnpx tsx src/test/b-tests.ts\n```\n', + 'parser/src/test/c-tests.ts': 'export {};\n', + 'parser/src/cart.test.ts': ''}) +cmds, unrun = ti.js_plan(d, ['parser/src/test/a-tests.ts', 'parser/src/test/b-tests.ts', + 'parser/src/test/c-tests.ts', 'parser/src/cart.test.ts']) +check("a vitest-collected file keeps vitest, run from its package (control)", + cmds[0], '(cd parser && npx vitest run src/cart.test.ts)') +check("a script suite gets its header's command", cmds[1], '(cd parser && npx tsx src/test/a-tests.ts)') +check("a script suite with no header gets its package README's command", cmds[2], + '(cd parser && npx tsx src/test/b-tests.ts)') +check("a file nothing runs is named, not handed to vitest", unrun, ['parser/src/test/c-tests.ts']) +check("no command names a file vitest does not collect", [c for c in cmds if 'vitest' in c and 'tests.ts' in c], []) +shutil.rmtree(d) + +# a package script that names the file +d = tree({'package.json': {'scripts': {'test:e2e': 'tsx e2e/run-e2e.ts'}, 'devDependencies': {'vitest': '1'}}, + 'e2e/run-e2e.ts': ''}) +check("a package script naming the file is the command", ti.js_plan(d, ['e2e/run-e2e.ts']), (['npm run test:e2e'], [])) +shutil.rmtree(d) + +# vitest with its own include: a file outside it is not vitest's, a file inside it is (control) +d = tree({'package.json': VITEST_PKG, + 'vitest.config.ts': "export default { test: { include: ['tests/**/*.ts'] } }\n", + 'tests/unit/a.ts': '', 'src/b.test.ts': ''}) +check("a configured vitest include collects what it names", + ti.js_plan(d, ['tests/unit/a.ts']), (['npx vitest run tests/unit/a.ts'], [])) +check("a configured vitest include leaves out a default-named file outside it", + ti.js_plan(d, ['src/b.test.ts']), ([], ['src/b.test.ts'])) +shutil.rmtree(d) + +# jest: its own runner, its own defaults +d = tree({'package.json': {'devDependencies': {'jest': '29'}}, 'src/__tests__/a.ts': '', 'src/b.spec.js': ''}) +check("jest collects __tests__ and *.spec with its own command", + ti.js_plan(d, ['src/__tests__/a.ts', 'src/b.spec.js']), (['npx jest src/__tests__/a.ts src/b.spec.js'], [])) +shutil.rmtree(d) + +# mocha: test/*.js by default +d = tree({'package.json': {'devDependencies': {'mocha': '10'}}, 'test/a.js': ''}) +check("mocha collects test/*.js with its own command", ti.js_plan(d, ['test/a.js']), (['npx mocha test/a.js'], [])) +shutil.rmtree(d) + +# a workspace member with no runner of its own is run by the root's +d = tree({'package.json': {'workspaces': ['pkgs/*'], 'devDependencies': {'vitest': '1'}}, + 'pkgs/core/package.json': {'name': 'core'}, 'pkgs/core/src/a.test.ts': ''}) +check("a workspace member without a runner is run by the root's, with the root path", + ti.js_plan(d, ['pkgs/core/src/a.test.ts']), (['npx vitest run pkgs/core/src/a.test.ts'], [])) +shutil.rmtree(d) + +# control: nothing configured anywhere, a *.test.ts file still gets the command it always got +d = tree({'src/a.test.ts': ''}) +check("nothing configured: a *.test.ts file keeps `npx vitest run` (control)", + ti.command_for('typescript', ['src/a.test.ts'], [], repo=d), 'npx vitest run src/a.test.ts') +shutil.rmtree(d) + +# a runner's collection is read the way the runner reads it. Each shape: (files, the file, expected plan) +V = {'devDependencies': {'vitest': '1'}} +J = {'scripts': {'test': 'jest'}, 'devDependencies': {'jest': '29'}} +for why, files, f, want in [ + ("vitest: coverage.include is not the test include", + {'package.json': V, 'vitest.config.mjs': "export default {test:{coverage:{include:['src/**']}}}"}, + 'test/a.test.ts', (['npx vitest run test/a.test.ts'], [])), + ("vitest: coverage.include beside a real include still leaves a file outside the include out (control)", + {'package.json': V, 'vitest.config.mjs': "export default {test:{include:['src/**/*.test.ts'],coverage:{include:['test/**']}}}"}, + 'test/a.test.ts', ([], ['test/a.test.ts'])), + ("vitest: a test block in vite.config.* is vitest's config", + {'package.json': V, 'vite.config.mjs': "export default {test:{include:['tests/**/*.ts']}}"}, + 'tests/unit/a.ts', (['npx vitest run tests/unit/a.ts'], [])), + ("vitest: vite.config.* of a package that does not use vitest is not read (control)", + {'package.json': J, 'vite.config.mjs': "export default {test:{include:['tests/**/*.ts']}}"}, + 'tests/unit/a.ts', ([], ['tests/unit/a.ts'])), + ("vitest: each inline project collects on its own", + {'package.json': V, 'vitest.config.mjs': + "export default {test:{projects:[{test:{include:['src/**/*.test.ts']}},{test:{include:['it/**/*.it.ts']}}]}}"}, + 'it/a.it.ts', (['npx vitest run it/a.it.ts'], [])), + ("vitest: a file no project includes is still left out (control)", + {'package.json': V, 'vitest.config.mjs': + "export default {test:{projects:[{test:{include:['src/**/*.test.ts']}},{test:{include:['it/**/*.it.ts']}}]}}"}, + 'e2e/a.it.ts', ([], ['e2e/a.it.ts'])), + ("vitest: a workspace member without its own config is collected with vitest's defaults", + {'package.json': V, 'vitest.workspace.ts': "export default ['packages/*']", + 'packages/a/src/x.test.ts': ''}, + 'packages/a/src/x.test.ts', (['npx vitest run packages/a/src/x.test.ts'], [])), + ("vitest: a workspace member's own include is read from the member", + {'package.json': V, 'vitest.workspace.ts': "export default ['packages/*']", + 'packages/a/vitest.config.ts': "export default {test:{include:['spec/**/*.ts']}}"}, + 'packages/a/spec/x.ts', (['(cd packages/a && npx vitest run spec/x.ts)'], [])), + ("vitest: exclude takes a file back out", + {'package.json': V, 'vitest.config.mjs': + "import {configDefaults} from 'vitest/config'\nexport default {test:{exclude:[...configDefaults.exclude,'e2e/**']}}"}, + 'e2e/a.spec.ts', ([], ['e2e/a.spec.ts'])), + ("vitest: a file outside the exclude is kept (control)", + {'package.json': V, 'vitest.config.mjs': + "import {configDefaults} from 'vitest/config'\nexport default {test:{exclude:[...configDefaults.exclude,'e2e/**']}}"}, + 'src/a.spec.ts', (['npx vitest run src/a.spec.ts'], [])), + ("vitest: an include this cannot read is not evidence the file is out", + {'package.json': V, 'vitest.config.ts': "import {inc} from './x'\nexport default {test:{include: inc}}"}, + 'weird/a.ts', (['npx vitest run weird/a.ts'], [])), + ("node:test through `tsx --test` in a glob script", + {'package.json': {'scripts': {'test:run': 'glob -c "tsx --test" "./test/**/*.ts"'}}}, + 'test/routes/api/users/users.test.ts', (['npx tsx --test test/routes/api/users/users.test.ts'], [])), + ("node:test through `node --test`", + {'package.json': {'scripts': {'test': 'node --test'}}}, 'test/a.test.js', (['node --test test/a.test.js'], [])), + ("playwright-only package: playwright runs its spec", + {'package.json': {'devDependencies': {'@playwright/test': '1'}}}, 'e2e/a.spec.ts', + (['npx playwright test e2e/a.spec.ts'], [])), + ("playwright testDir: a spec outside it is not playwright's", + {'package.json': {'devDependencies': {'@playwright/test': '1'}}, + 'playwright.config.ts': "export default defineConfig({ testDir: './e2e' })"}, 'src/a.spec.ts', + ([], ['src/a.spec.ts'])), + ("vitest excludes e2e and playwright takes it", + {'package.json': {'devDependencies': {'vitest': '1', '@playwright/test': '1'}}, + 'vitest.config.ts': "export default {test:{exclude:['e2e/**']}}", + 'playwright.config.ts': "export default { testDir: 'e2e' }"}, 'e2e/a.spec.ts', + (['npx playwright test e2e/a.spec.ts'], [])), + ("react-scripts runs its own jest", + {'package.json': {'scripts': {'test': 'react-scripts test'}, 'dependencies': {'react-scripts': '5'}}}, + 'src/App.test.js', (['npx react-scripts test --watchAll=false src/App.test.js'], [])), + ("a package with a package.json and no runner hands nothing to vitest", + {'package.json': {'name': 'x'}}, 'src/a.test.ts', ([], ['src/a.test.ts'])), + ("a jest config outranks vitest listed only as a dependency", + {'package.json': {'scripts': {'test': 'jest'}, 'devDependencies': {'jest': '29', 'vitest': '1'}}, + 'jest.config.js': "module.exports = {}"}, 'src/a.test.js', (['npx jest src/a.test.js'], [])), + ("a vitest config outranks jest listed only as a dependency (control)", + {'package.json': {'devDependencies': {'jest': '29', 'vitest': '1'}}, + 'vitest.config.ts': "export default {}"}, 'src/a.test.js', (['npx vitest run src/a.test.js'], [])), + ("jest testMatch with ", + {'package.json': J, 'jest.config.js': "module.exports = {testMatch:['/src/**/*.check.js']}"}, + 'src/a.check.js', (['npx jest src/a.check.js'], [])), + ("jest testRegex replaces testMatch", + {'package.json': J, 'jest.config.js': "module.exports = {testRegex:'\\\\.e2e-spec\\\\.js$'}"}, + 'test/app.e2e-spec.js', (['npx jest test/app.e2e-spec.js'], [])), + ("jest testRegex leaves a default-named file out (control)", + {'package.json': J, 'jest.config.js': "module.exports = {testRegex:'\\\\.e2e-spec\\\\.js$'}"}, + 'src/a.test.js', ([], ['src/a.test.js'])), + ("jest: package.json key with rootDir + a `jest --config` e2e config in a script", + {'package.json': {'scripts': {'test': 'jest', 'test:e2e': 'jest --config ./test/jest-e2e.json'}, + 'devDependencies': {'jest': '29'}, + 'jest': {'rootDir': 'src', 'testRegex': '.*\\.spec\\.ts$'}}, + 'test/jest-e2e.json': {'rootDir': '.', 'testRegex': '.e2e-spec.ts$'}}, + 'test/app.e2e-spec.ts', (['npx jest --config ./test/jest-e2e.json test/app.e2e-spec.ts'], [])), + ("jest: package.json rootDir src collects its spec (control)", + {'package.json': {'scripts': {'test': 'jest', 'test:e2e': 'jest --config ./test/jest-e2e.json'}, + 'devDependencies': {'jest': '29'}, + 'jest': {'rootDir': 'src', 'testRegex': '.*\\.spec\\.ts$'}}, + 'test/jest-e2e.json': {'rootDir': '.', 'testRegex': '.e2e-spec.ts$'}}, + 'src/app.service.spec.ts', (['npx jest src/app.service.spec.ts'], [])), + ("jest: a [jt] glob is read whole, and a nested e2e/jest.config.ts runs its own files", + {'package.json': J, 'jest.config.ts': "export default {testMatch:['/src/**/*(*.)@(spec|test).[jt]s?(x)']}", + 'e2e/jest.config.ts': "export default {testMatch:['/src/**/*.spec.ts']}"}, + 'e2e/src/server/server.spec.ts', (['(cd e2e && npx jest src/server/server.spec.ts)'], [])), + ("jest: the root [jt] glob still collects its own src spec (control)", + {'package.json': J, 'jest.config.ts': "export default {testMatch:['/src/**/*(*.)@(spec|test).[jt]s?(x)']}", + 'e2e/jest.config.ts': "export default {testMatch:['/src/**/*.spec.ts']}"}, + 'src/app/a.spec.tsx', (['npx jest src/app/a.spec.tsx'], [])), + ("a script that only lints the file is not its test command", + {'package.json': {'scripts': {'lint': 'eslint e2e/run-e2e.ts'}, 'devDependencies': {'vitest': '1'}}}, + 'e2e/run-e2e.ts', ([], ['e2e/run-e2e.ts'])), + ("a test script whose own glob takes the file runs it", + {'package.json': {'scripts': {'test': 'cross-env NODE_ENV=test babel-tape-runner test/test-*.js'}}}, + 'test/test-users.js', (['npm run test'], [])), + ("a lint script's glob is not a test command (control)", + {'package.json': {'scripts': {'lint': 'eslint "test/**/*.js"'}}}, 'test/test-users.js', ([], ['test/test-users.js'])), + ("a package directory with a space is quoted", + {'my pkg/package.json': V}, 'my pkg/src/a.test.ts', (["(cd 'my pkg' && npx vitest run src/a.test.ts)"], [])), + # the config is read as the value the file exports, not as the literal keys it spells; what that value does not + # say is never taken as the runner's defaults + ("vitest: vitest.config.* outranks vite.config.* (control)", + {'package.json': V, 'vite.config.mjs': "export default {test:{include:['tests/**/*.ts']}}", + 'vitest.config.mjs': "export default {test:{include:['spec/**/*.ts']}}"}, + 'tests/unit/a.ts', ([], ['tests/unit/a.ts'])), + ("vitest: an include spread from configDefaults keeps the defaults", + {'package.json': V, 'vitest.config.ts': "import { defineConfig, configDefaults } from 'vitest/config'\n" + "export default defineConfig({ test: { include: [...configDefaults.include, 'checks/**/*.ts'] } })"}, + 'src/a.test.ts', (['npx vitest run src/a.test.ts'], [])), + ("vitest: test options aliased through a const `{ test }`", + {'package.json': V, 'vitest.config.ts': "import { defineConfig } from 'vitest/config'\n" + "const test = { include: ['spec/**/*.ts'] }\nexport default defineConfig({ test })"}, + 'spec/a.ts', (['npx vitest run spec/a.ts'], [])), + ("vitest: the aliased include leaves a default-named file out (control)", + {'package.json': V, 'vitest.config.ts': "import { defineConfig } from 'vitest/config'\n" + "const test = { include: ['spec/**/*.ts'] }\nexport default defineConfig({ test })"}, + 'src/b.test.ts', ([], ['src/b.test.ts'])), + ("vitest: include as a shorthand property `{ include }`", + {'package.json': V, 'vitest.config.ts': "const include: string[] = ['spec/**/*.ts']\n" + "export default defineConfig({ test: { include } })"}, + 'src/b.test.ts', ([], ['src/b.test.ts'])), + ("vitest: a config re-exported from another file", + {'package.json': V, 'vitest.config.ts': "export { default } from './config/vitest.shared'\n", + 'config/vitest.shared.ts': "import { defineConfig } from 'vitest/config'\n" + "export default defineConfig({ test: { include: ['spec/**/*.ts'] } })"}, + 'spec/a.ts', (['npx vitest run spec/a.ts'], [])), + ("vitest: mergeConfig with its base include in another file", + {'package.json': V, 'vitest.base.ts': "export default defineConfig({ test: { include: ['spec/**/*.ts'] } })", + 'vitest.config.ts': "import { mergeConfig, defineConfig } from 'vitest/config'\nimport base from './vitest.base'\n" + "export default mergeConfig(base, defineConfig({ test: { exclude: ['spec/skip/**'] } }))"}, + 'spec/a.ts', (['npx vitest run spec/a.ts'], [])), + ("vitest: the merged exclude still takes its file out (control)", + {'package.json': V, 'vitest.base.ts': "export default defineConfig({ test: { include: ['spec/**/*.ts'] } })", + 'vitest.config.ts': "import { mergeConfig, defineConfig } from 'vitest/config'\nimport base from './vitest.base'\n" + "export default mergeConfig(base, defineConfig({ test: { exclude: ['spec/skip/**'] } }))"}, + 'spec/skip/b.ts', ([], ['spec/skip/b.ts'])), + ("vitest: an include computed at load time is not read as the defaults", + {'package.json': V, 'vitest.config.ts': "const include = ['spec'].map(d => d + '/**/*.ts')\n" + "export default { test: { include } }"}, + 'spec/a.ts', (['npx vitest run spec/a.ts'], [])), + ("vitest: a workspace member configured by its vite.config.ts test block", + {'package.json': V, 'vitest.workspace.ts': "export default ['packages/*']", + 'packages/a/package.json': {'name': 'a'}, + 'packages/a/vite.config.ts': "export default defineConfig({ test: { include: ['tests/**/*.ts'] } })"}, + 'packages/a/tests/x.ts', (['npx vitest run packages/a/tests/x.ts'], [])), + ("vitest: a second config a script names with --config", + {'package.json': {**V, 'scripts': {'test': 'vitest run', 'test:e2e': 'vitest run --config vitest.e2e.config.ts'}}, + 'vitest.config.ts': "export default defineConfig({ test: { include: ['src/**/*.test.ts'] } })", + 'vitest.e2e.config.ts': "export default defineConfig({ test: { include: ['e2e/**/*.e2e.ts'] } })"}, + 'e2e/login.e2e.ts', (['npx vitest run --config vitest.e2e.config.ts e2e/login.e2e.ts'], [])), + ("vitest: test.dir narrows where it looks", + {'package.json': V, 'vitest.config.ts': "export default defineConfig({ test: { dir: 'src' } })"}, + 'test/b.test.ts', ([], ['test/b.test.ts'])), + ("vitest: test.dir keeps its own files (control)", + {'package.json': V, 'vitest.config.ts': "export default defineConfig({ test: { dir: 'src' } })"}, + 'src/a.test.ts', (['npx vitest run src/a.test.ts'], [])), + ("jest: a testMatch spread from jest-config's defaults keeps the defaults", + {'package.json': J, 'jest.config.js': "const { defaults } = require('jest-config')\n" + "module.exports = { testMatch: [...defaults.testMatch, '**/*.int.js'] }"}, + 'src/a.test.js', (['npx jest src/a.test.js'], [])), + ("jest: a `!glob` in testMatch takes a file back out", + {'package.json': J, 'jest.config.js': "module.exports = { testMatch: ['**/*.test.js', '!**/fixtures/**'] }"}, + 'src/fixtures/a.test.js', ([], ['src/fixtures/a.test.js'])), + ("jest: the negation keeps every other file (control)", + {'package.json': J, 'jest.config.js': "module.exports = { testMatch: ['**/*.test.js', '!**/fixtures/**'] }"}, + 'src/b.test.js', (['npx jest src/b.test.js'], [])), + ("jest: collectCoverageFrom is not the test match", + {'package.json': J, 'jest.config.js': "module.exports = { collectCoverageFrom: ['lib/**/*.js'] }"}, + 'test/a.test.js', (['npx jest test/a.test.js'], [])), + ("jest: nor in the package.json key", + {'package.json': {**J, 'jest': {'collectCoverageFrom': ['lib/**/*.js'], 'coveragePathIgnorePatterns': ['/test/']}}}, + 'test/a.test.js', (['npx jest test/a.test.js'], [])), + ("jest: a config through a const and an async function", + {'package.json': J, 'jest.config.ts': "import type { Config } from 'jest'\n" + "const config: Config = { testMatch: ['**/*.it.ts'] }\nexport default async () => config"}, + 'src/a.it.ts', (['npx jest src/a.it.ts'], [])), + ("mocha: --extension ts --recursive in the script, run with the script's flags", + {'package.json': {'scripts': {'test': 'mocha --require tsx/cjs --extension ts --recursive test'}, + 'devDependencies': {'mocha': '10'}}}, + 'test/unit/a.spec.ts', (['npx mocha --require tsx/cjs --extension ts --recursive test/unit/a.spec.ts'], [])), + ("mocha: .mocharc.json extension with the default ./test spec", + {'package.json': {'scripts': {'test': 'mocha'}, 'devDependencies': {'mocha': '10'}}, + '.mocharc.json': {'extension': ['ts'], 'require': 'tsx/cjs'}}, + 'test/a.ts', (['npx mocha test/a.ts'], [])), + ("mocha: without --recursive a nested file is not collected (control)", + {'package.json': {'scripts': {'test': 'mocha --extension ts test'}, 'devDependencies': {'mocha': '10'}}}, + 'test/unit/a.ts', ([], ['test/unit/a.ts'])), +]: + d = tree(files) + check(why, ti.js_plan(d, [f]), want) + shutil.rmtree(d) + +# control: other languages are untouched +check("python unchanged (control)", ti.command_for('python', ['tests/test_a.py'], []), 'pytest tests/test_a.py') +check("java unchanged (control)", ti.command_for('java', [], ['app.ATest']), 'mvn test -Dtest=ATest') + +# the next: line names every command, and still names a single one as it did +check("next: one command is named as before (control)", + ax_pages.next_test_impact("\n npx vitest run src/a.test.ts\n").split(' — ')[0], 'next: run npx vitest run src/a.test.ts') +check("next: a (cd pkg && …) command is recognised", + ax_pages.next_test_impact("\n (cd parser && npx tsx src/test/a-tests.ts)\n").split(' — ')[0], + 'next: run (cd parser && npx tsx src/test/a-tests.ts)') +check("next: several commands are counted", + ax_pages.next_test_impact("\n (cd p && npx tsx a.ts)\n (cd p && npx tsx b.ts)\n").startswith('next: run the 2 commands above'), + True) + +print('\n' + ('all passed' if not fails else f"{fails} FAILED")) +sys.exit(1 if fails else 0)