Scry.Analysis (scry v0.1.22)

Copy Markdown View Source

Incremental argus analysis as a roux query graph.

Query DAG

module_beam(module)                                 [frontend]
     
module_extraction(module)      Argus.Pipeline.extract, per module
               
module_semantic_facts   module_line_table
 (line_info dropped     (anchor resolution,
  THE cutoff seam)        consumed late by LSP)
     
module_relation_facts({module, relation})    per-module projection
     
relation_facts(relation)       one relation across the project
     
stage0_facts(:all)
 (shared call graph 
  THE second cutoff seam)
     
analysis_facts_dir(analysis)   content-addressed, projected to the
                               relations THIS analysis reads
souffle_solve(analysis)
     
findings(analysis)

Planchette's LSP-only surface (Planchette.SupTree's supervision tree, Planchette.Focus's flowistry slices) hangs off module_extraction and relation_facts by query name from its own modules; nothing here depends on it.

There is no whole-program fact node. Everything downstream of extraction is projected — per module, then per relation, then per analysis — so an edit propagates only along the relations it actually moved.

The line-shift immunity story: a whitespace/comment edit changes the beam (Line/Dbgi chunks) → module_extraction recomputes and differs (its line_info rows changed) → module_semantic_facts recomputes, produces an EQUAL value → roux backdates it → every projection below it validates green without executing. Zero Souffle runs for a comment edit.

The body-edit story: an edit that changes what a function COMPUTES but not what it CALLS moves instruction and friends, so the analyses that anchor at instruction sites re-solve — but supervisor, sync_call and the rest of the structural relations backdate at module_relation_facts, so relation_facts never executes for them and the supervision analyses stop before Souffle.

Rule fidelity: every analysis stays in Souffle — the .dl files are the single source of truth, and incremental findings must equal batch Argus.Findings.run/2 exactly (the honesty principle). Fact rows are sorted per relation so equal extractions produce equal values (roux compares with ==); Souffle has set semantics, so ordering cannot change results.

Purity deviation: analysis_facts_dir, stage0_facts and souffle_solve touch the filesystem and shell out — content-addressed and idempotent, the same pragmatic loophole as the frontend's code loading.

Shared-layer contract

This module is consumed by BOTH planchette (LSP, in-memory compile frontend) and scry's Mix compiler (disk-beam frontend). Roux dispatches queries by NAME and memo keys are {query_name, key}, so query names, key shapes, and value shapes are the ABI — rename nothing without revisiting every consumer and its persisted manifests. The frontend contract this module demands, by name: the queries :module_beam, :module_map, and :file_of, and the input :env_fingerprint (inputs are the frontend's to declare — this module defines queries only).

Summary

Functions

all_extractors()

@spec all_extractors() :: [module()]

The union of every built-in analysis's extractors (coverage excluded, mirroring Argus.Findings.run/2) — one extraction serves all solves.

analysis_diagnostics(db, analysis)

@spec analysis_diagnostics(Roux.Database.t(), term()) ::
  {:ok, %{optional(String.t()) => [map()]}} | {:error, term()}

analysis_facts_dir(db, analysis)

@spec analysis_facts_dir(Roux.Database.t(), term()) :: %{
  dir: String.t(),
  key: String.t()
}

analysis_input_relations(db, analysis)

@spec analysis_input_relations(Roux.Database.t(), term()) :: [atom()]

default_analyses()

@spec default_analyses() :: [atom()]

The always-on analyses (keynote-narrative, low-noise). The rest run on demand.

findings(db, analysis)

@spec findings(Roux.Database.t(), term()) :: {:ok, [map()]} | {:error, term()}

module_extraction(db, module)

@spec module_extraction(Roux.Database.t(), term()) :: {:ok, map()} | {:error, term()}

module_line_table(db, module)

@spec module_line_table(Roux.Database.t(), term()) :: {:ok, map()} | {:error, term()}

module_semantic_facts(db, module)

@spec module_semantic_facts(Roux.Database.t(), term()) ::
  {:ok, map()} | {:error, term()}

prewarm_extractions(paths, db)

@spec prewarm_extractions(%{optional(module()) => String.t()}, Roux.Database.t()) ::
  :ok

Extracts modules in parallel ahead of the query graph.

Queries execute one at a time on the demanding process, so a cold run extracted every module serially. This runs the extraction for the given module => beam_path map across the schedulers and parks each result for module_extraction to pick up — the query still executes, records its dependencies and memoizes as before, it just finds its answer waiting. A result is keyed by the canonical beam's digest, so a beam that changed between the pre-pass and the query is extracted again.

program_relation_facts(db, atom)

@spec program_relation_facts(Roux.Database.t(), term()) :: Argus.Facts.interned()

relation_digest(db, relation)

@spec relation_digest(Roux.Database.t(), term()) :: String.t()

relation_facts(db, relation)

@spec relation_facts(Roux.Database.t(), term()) :: [[String.t()]]

relation_rows(db, relation)

@spec relation_rows(Roux.Database.t(), term()) :: [tuple()]

souffle_solve(db, analysis)

@spec souffle_solve(Roux.Database.t(), term()) :: {:ok, map()} | {:error, term()}

stage0_facts(db, atom)

@spec stage0_facts(Roux.Database.t(), term()) :: %{
  call_edge: [tuple()],
  call_site: [tuple()],
  call_tag: [tuple()],
  unconditional_call_edge: [tuple()]
}