The commands here take a unified diff (with --git, the git diff of the working tree) and examine what a change affects. context returns the impact, impact stops with exit 1 while impacts are unresolved, and review returns the impact, co-changes, public API changes, and dead symbols in one run. dead-code and cochange also work without a diff.
context takes a unified diff and analyzes the scope of the change, to support AI code review. Function signature changes are matched on identifier boundaries, so two different functions whose names only share a prefix, such as foo and foo_bar, are not confused.
# Get the git diff automatically and analyze the impact (recommended)
astro-sight context --dir . --git
# Analyze staged changes
astro-sight context --dir . --git --staged
# Use a custom base
astro-sight context --dir . --git --base HEAD~3
# Pipe from stdin
git diff HEAD~1 | astro-sight context --dir .
# Pass the diff as an inline string
astro-sight context --dir . --diff "$(git diff HEAD~1)"
# Read the diff from a file
git diff HEAD~1 > /tmp/changes.diff
astro-sight context --dir . --diff-file /tmp/changes.diff--base of context / impact / review is passed straight to git diff / git show / git blame, so values that start with -, contain NUL, or are empty are rejected with INVALID_REQUEST (to keep something like --output=/path from being taken as an option).
Output:
{
"changes": [
{
"path": "src/engine/symbols/mod.rs",
"hunks": [{ "old_start": 10, "old_count": 5, "new_start": 10, "new_count": 8 }],
"affected_symbols": [
{ "name": "extract_symbols", "kind": "function", "change_type": "modified" }
],
"signature_changes": [
{ "name": "extract_symbols", "old_signature": "fn extract_symbols(...)", "new_signature": "fn extract_symbols(..., include_refs: bool)" }
],
"impacted_callers": [
{ "path": "src/commands.rs", "name": "cmd_symbols", "line": 166 }
]
}
]
}For Rust, affected_symbols[].kind is impl when an impl header changes, an impl without methods is added, or deleted lines may include a method declaration. Adding a method without deleting lines reports the method without reporting its owning type as changed. Edits with deleted lines keep the impl record because a removed method cannot be ruled out from the new tree alone. An attribute-only change also keeps the impl record because the method symbol does not include attributes. The symbols command still includes the impl owner for method ownership.
Callers fall into 3 groups by confidence and by how breaking they are. "Blocking" here means a finding that makes impact or review --hook return exit 1.
impacted_callers: actual call sites. Those left outside the diff are blocking forimpact. References to symbols whose number of arguments changed, and references that cannot be decided, also stay herelow_confidence_callers: generic names whose owner (the class or type a method belongs to) cannot be determined, and same-name references in TS/Rust without evidence of a direct importinformational_callers: function-value references to symbols whose name and number of arguments did not change, and import lines of modified symbols whose name did not change. Not blocking
For top-level JS / TS / TSX functions, modified_closed_in_diff (api.mod_closed) excludes same-name occurrences in another file when their use does not depend on the function signature. Both revisions must contain a unique direct top-level function declaration without visible rebinding. Changes from values or other symbol kinds do not use this proof. The supported sites are noncomputed property_signature names with directly written noncallable predefined types such as boolean, string, and number, literal types, or unions of those types; and parameters used only for truthiness. Type aliases, callable properties, method signatures, and mock object keys remain unproven.
Parameter proofs additionally require file-wide strict mode, established by a static import/export statement or an exact "use strict" directive in the file directive prologue. Dynamic import, function-local directives, and class-only strictness are not used as evidence. Sloppy or unknown strictness remains unproven because reflective argument access can occur outside the function. Property-declaration proofs do not require strict mode.
Parameters must be plain identifiers or direct object-pattern shorthands. Every same-name use in the function, including nested closures, is checked. Only ternary / if / while / do / for conditions and prefix ! operands are accepted, through bounded parentheses. A call, value escape, return, assignment, same-name declaration, or type use keeps the entire binding unproven. Defaults, rest parameters, renamed destructuring, and constructor parameter properties are excluded. Functions referring to arguments remain unproven because parameters can be accessed indirectly.
Parse errors, clues for with or eval, ambient declarations, and declaration files prevent this proof. Unicode escapes in any identifier node kind also prevent it; escaped names are not guessed. API closure still requires at least one actual target call verified against the changed argument count and changed lines. Existing alias-import guards remain in place. Single and batch refs occurrences and dead-code counts are unchanged.
context and impact also route these proven sites in other files to low_confidence_callers. Both revisions must contain a unique direct function declaration, and the modified declaration itself must be a top-level function with a signature change. Mixed same-name affected methods or other declarations prevent this proof. Removed or added declarations, symbol-kind changes, and import sites do not qualify. Disabling the confidence filter disables this route. Direct impact --staged and review fallback without a staged snapshot remain conservative. When review --staged uses a complete index snapshot, only references inside that snapshot provide evidence.
An unaliased, unconditional module-level from .cli import main can bind main() to a different function than an unrelated changed legacy.main. Impact analysis excludes that use only when the relative module resolves uniquely inside the workspace and contains a single, undecorated, direct definition of the imported function. References to the actual defining module, .pyi changes, and import-name occurrences keep their existing handling. refs and dead-code reference counts are unchanged.
The proof requires package initializers containing only comments, plain string literals, or pass. Rebinding, duplicate imports, star imports, dynamic namespace operations (including __dict__ and frame namespace access), parse failures, and module __getattr__ prevent it. Absolute imports, aliases, namespace packages, class bodies, type positions, lambdas, and comprehensions keep the conservative result. Arbitrary runtime replacement from another file, custom import hooks, and string-based monkey patching are outside this static proof. Package and target evidence is reused within one analysis and discarded before the next query; imported names unrelated to the changed symbols are skipped.
From the result of context, impact flags impacts on files outside the diff as "unresolved". It is meant for the Stop hook of an AI agent: when affected code is left untouched, it blocks and prompts the agent to continue. impact only follows symbols that still exist in the changed tree, so deleted symbols are not detected (exit 0 even if calls remain). To stop on deletions too, use review --dir . --git --hook, which detects removed public APIs as api.rm.
Signature changes are matched on identifier boundaries as in context, so a change to a test helper or a derived name does not spread as if the base function had changed. Local symbols declared inside a function are not used as starting points of cross-file impacts. TypeScript/JavaScript, Rust, Python, Go, Java, and Kotlin are supported, and nested Kotlin functions are told apart from top-level functions with the same name.
For a unique root TypeScript/TSX function with one inline object parameter, type changes confined to properties that remain optional on both sides can make individual JSX callers informational. The caller must use a direct identifier tag and omit every changed prop. Supplying a prop, even as undefined, JSX spreads, type arguments, mismatched tags, and implicit children for a changed children prop do not qualify. Any bytes between paired tags, including spaces, tabs and line breaks, count as children. Existing low-confidence results retain priority.
The source proof requires unchanged headers, patterns and explicit return types, with no whole-parameter or pattern defaults. Prop types and explicit return types are limited to primitives, literals and static inline objects; named types and other unknown forms remain unproven. Without a return annotation, every outer return must directly contain JSX or a string, number, boolean or null literal after removing parentheses. A final top-level return must prevent fallthrough, and the old/new sets of return categories must match. Nested functions and classes are skipped. File-level JSX pragma comments prevent this inferred-return proof. Required changes, overloads and top-level prop additions/removals do not qualify. Evidence is specific to each source and function name. Other body behavior is not verified, and the global api.mod is retained; refs and dead-code occurrences are unchanged.
For Rust, a private declaration directly in a source file can be used by child modules in other files. Impact analysis includes those callers. For a leaf module with no external children, it excludes same-name references in other files when it can rule out source inclusion. For modules with children, it excludes references outside the declaration's subtree only when the old and new visibility, ordinary module paths, and source layout can be verified. Unclear module layouts or source-generating macros keep the candidate. Private methods inside impl blocks use a separate rule. When a unique private inherent method has the same owner before and after the change, method references in descendant modules are included and proven sibling modules are excluded. Bare function calls and imports are excluded. A same-name definition in the reference file, a different written owner, a receiver with no owner evidence in the file, or an unproven module subtree lowers the reference to low confidence. Unproven references with --staged are also low confidence.
When the only edit in a Rust source file adds pub(crate) or pub to a direct private item, unchanged references inside its former private subtree are informational. References outside that subtree are excluded because they could not have named the old item. This requires an unchanged ordinary Cargo module layout and no glob imports anywhere in the analyzed project: widening visibility can otherwise introduce an ambiguous name through a glob. Other edits in the same diff, uncertain ownership, and narrower or more complex visibility changes retain the normal blocking classification.
For Rust type and free-function references, impact analysis can follow named imports, pub use chains, and glob imports through ordinary Cargo modules. It excludes a same-name reference only when the resolved declaration belongs to a different file and the bindings used for that proof are unchanged across the diff. An alias import remains blocking under the original name when it is used, publicly re-exported, or may be used by a child module. An alias proven to come from a standard crate or registry dependency remains informational. Other external imports, conditional bindings, ambiguous globs, changed bindings, unusual source layouts, and staged analysis retain the conservative result. This ownership proof does not change refs or dead-code output.
# Get the git diff automatically and detect unresolved impacts (recommended)
astro-sight impact --dir . --git
# Check staged changes
astro-sight impact --dir . --git --staged
# Use a custom base
astro-sight impact --dir . --git --base HEAD~3
# Pipe from stdin
git diff HEAD~1 | astro-sight impact --dir .- Nothing unresolved → exit 0 (no output)
- Something unresolved → text on stderr + exit 1
--diris not in a git repository → exit 0 (no output, with or without--hook; see "Skipping Directories Outside Git" below)--baseis omitted and there is no commit yet (HEAD is unborn) → exit 0 (no output; see the same section)
Output (on exit 1):
Unresolved impacts found:
src/engine/symbols/mod.rs changed [extract_symbols]:
→ src/service.rs:284 [extract_symbols]
→ src/commands/api_changes/exported.rs:45 [extract_symbols]
Line numbers in the text output are 1-based so that an editor can open them as they are (line / ln in JSON output are 0-based).
An example with claw-hooks (write it in the global configuration ~/.config/claw-hooks/config.toml; claw-hooks ignores stop_hooks written in a project's .claw-hooks.toml):
[[stop_hooks]]
commands = ["astro-sight impact --git --dir ."]
condition = { command_exists = "astro-sight" }If you register the command directly in the Claude Code hooks settings, exit 1 on a finding is treated as a non-blocking error and Claude does not stop. With stop_hooks that have a condition, as in the example above, claw-hooks returns the command failure to Claude as a block, and Claude stops.
When a command that accepts --git (context / impact / review / dead-code / cochange) runs in a directory outside git, the failure of the internal git diff is not treated as an error. The run is skipped as "nothing to analyze" and ends normally with exit 0. This keeps the Claude Code Stop hook from blocking while you edit in a directory outside git, such as ~/.config.
--hook(review/impact) → no output on stdout or stderr, exit 0- Normal CLI use → an empty normal result with a machine-readable
skippedfield, exit 0. You can tell "no changes" from "not in git" (impact, which has no structured output, prints nothing)
{ "...": "...", "skipped": { "reason": "not_git_repository", "source": "git", "message": "--git was requested but --dir is not inside a git worktree" } }The check uses git rev-parse --is-inside-work-tree (with LC_ALL=C), so worktrees, submodules, and bare repositories are detected correctly. Real errors (an invalid --base, a base that cannot resolve to a commit, git not runnable, a broken repository, missing permissions) return exit 1.
A repository without any commit is skipped in the same way when --base is omitted. Right after git init or git checkout --orphan, HEAD does not point to a commit yet, so there is no base to compare against. skipped.reason is unborn_head. An explicit --base is never skipped: the diff against that base is analyzed, and a base that does not resolve to a commit, even --base HEAD, still returns exit 1. When cochange gets --paths / --paths-file, it continues with those files only, as it does outside git. Whether HEAD is unborn is checked with git show-ref --exists (git 2.43 or later); older git does not skip and keeps exit 1. Passing a diff through --diff / --diff-file / stdin does not go through this check.
review --git makes at most three attempts when the index or working tree changes during analysis, or when git diff reports a short read while indexing a file. Each attempt starts by acquiring the diff again and resolving the base to a commit. That commit stays fixed for the attempt's diff, cochange, and API analysis; moving HEAD alone does not invalidate it. Failed results and temporary snapshots are discarded.
If all three attempts fail for these reasons, both normal review and --hook return REVIEW_INPUT_CHANGED with exit 2. A single JSON error is printed to stdout; --hook also prints a short instruction to rerun review on stderr. Other errors and blocking hook findings keep exit 1. Explicit --diff, --diff-file, and stdin inputs are not retried.
With --staged, the index contents are written to a temporary directory, and impact, API, and dead-code analysis all read those contents. Unstaged edits in the working tree do not enter the result (for large repositories, see "Size limit of the staged snapshot").
--git (without --staged) includes untracked source files in the analysis as new files (so that references to untracked files created in the same piece of work are not reported as unresolved impacts outside the diff). Untracked files larger than 256KB or 5,000 lines are left out, though. Pulling in generated output such as code generator results or huge fixtures would make every exported symbol in them a candidate for the API diff, review would take tens of minutes, and the Stop hook would time out. In a measurement, a review that finished in 1.75 seconds without untracked files did not finish within 10 minutes after adding an untracked file with 22,000 pub fn in total.
Tracked files are not limited. A file that has been committed or added can be seen as intentionally put up for review. An untracked file, on the other hand, has not been added yet; whether it will be committed is unknown, and a huge one is likely generated.
Excluded files are not dropped silently; they are listed in truncations (so that they are not mistaken for reviewed files):
{ "...": "...", "truncations": [{ "path": "generated.rs", "reason": "untracked_file_too_large", "message": "untracked file excluded from --git analysis: lines 80000 exceeds limit 5000" }] }With --hook, this is printed as trunc: [{"f": "generated.rs", "r": "untracked_file_too_large"}] (it reports the analyzed scope rather than a finding, so it does not cause exit 1). impact, which has no structured JSON, prints it as a note: line on stderr. --staged / --diff / --diff-file respect the scope given explicitly and do not pull in untracked files at all.
review --git --staged writes every regular file in the index to a temporary directory before analyzing it. When the repository has more than 50,000 files or 512 MiB in total, it does not write them and reads the working tree instead (only the changed files are read from the index). This is not an error; the switch is reported in truncations:
{ "...": "...", "truncations": [{ "reason": "staged_snapshot_unavailable", "message": "staged snapshot was not created: 61234 files / 734003200 bytes exceed the limit (50000 files / 536870912 bytes); the working tree matched the index, so files were read from the working tree" }] }On this path, references found in the working tree do not match the content being committed when it has unstaged edits. So when the working tree differs from the index, the checks that move a change to a non-blocking category because no references remain (mod_closed / mod_compat / rm_dead) are not used, and the change stays blocking. With --hook, this is printed as trunc: [{"r": "staged_snapshot_unavailable"}] (it reports the analyzed scope, so it does not cause exit 1 by itself).
dead-code / review also list in truncations the source files that exist in the directory but that no backend could analyze. Declaring a symbol dead without counting the references inside unreadable files would report live symbols as dead (for example a TypeScript function used only from the <script> of a .vue file). The report lets you tell "there are no references" from "references could not be observed".
{ "...": "...", "truncations": [{ "reason": "unanalyzable_source", "message": "1 \".vue\" file(s) were not analyzed (no parser for this language); references inside them are not counted (e.g. src/App.vue)" }] }Only extensions that are certainly program or template languages are reported (.vue / .svelte / .astro / .erb / .razor / .scala / .dart / .lua, and so on). Files outside the scan also include images, archives, and data, and reporting all of them would bury the sources that are really missed in noise. The report is folded into one entry per extension, with at most 10 extensions and 3 example paths. When there are no such files, truncations is not printed at all.
dead-code reports the unanalyzable sources in the scope where references were counted (the whole directory). Narrowing the dead candidates with --glob or --git still counts references in the whole directory, so the scope of the report is not narrowed either.
The impact analysis of context / impact / review leaves third-party dependencies and build artifacts out of the cross-file reference search by default. This keeps generic method names such as new / save / find / update, pouring in from third-party and generated code, from burying the impacts in tens of thousands of false positives.
- Vendored code and package managers:
vendor,node_modules,bower_components,.venv,venv,.tox,Pods,Carthage - Build artifacts:
target,build,dist,out,.build,DerivedData,bin,obj,coverage,.next,.nuxt,.svelte-kit,.turbo,CMakeFiles- Among the
bindirectories,src/bin/of a Cargo package (directly undersrc, whose parent has aCargo.toml) holds the sources of binary targets and is not excluded. An explicit--exclude-dir binexcludes everybin
- Among the
To turn off the default exclusions:
ASTRO_SIGHT_INCLUDE_VENDOR_FOR_IMPACT=1 astro-sight impact --dir . --gitThe exclusion of .gitignored and hidden files and the detection of generated files (through refs::collect_files) are separate mechanisms from these default exclusions. Of these, only the exclusion of generated files can be turned off, with --include-generated or skip_generated = false.
To exclude directories whose names are not in the fixed list (pjproject-2.15, openssl_64_1.1.1c, third_party, and so on), or to narrow the impact analysis with finer glob patterns, use --exclude-dir / --exclude-glob. The same options work in context / impact / review, and they are added to the fixed list (they do not replace the default exclusions).
# Exclude a vendored C library
astro-sight impact --dir . --git \
--exclude-dir pjproject-2.15 \
--exclude-dir openssl_64_1.1.1c
# Cover several versions with a glob
astro-sight impact --dir . --git \
--exclude-glob '**/openssl_*1.1.1*/**'
# In review, the same options apply to both impact and dead_symbols
astro-sight review --dir . --git \
--exclude-dir pjproject-2.15 \
--exclude-glob '**/openssl_*/**'--exclude-glob is treated as a negative pattern of ignore::overrides (no leading ! needed; relative to the workspace). Invalid glob syntax is rejected with INVALID_REQUEST before the run.
On top of the impact analysis of context, review returns candidates for missed changes from cochange, the public API diff, and dead symbols in a single run. It is meant for PR reviews and pre-merge checks.
api_changes.removed_unresolved (api.rm_unresolved in hook output) reports confirmed removals whose remaining member reads have unresolved receivers and a restricted type demand. These entries have confidence: "low" and do not block the hook. They do not establish that the removed member has no callers; removed_dead keeps its existing proof requirements. Shell parse recovery continues to use the separate uncertain_removals / rm_unverified category.
This classification requires an undecorated ordinary method without old member rebinding or attribute-write clues, its class still present at the old path, complete static reference input, and at least one surviving field declaration owned by another class. Every occurrence must have an existing unrelated-field proof or an unresolved receiver at a supported use site. Field declarations explain the name collision; they do not prove the receiver's type. Unrelated field reads retain the existing non-escape checks for the removed owner class, including uses through inheritance or aliases. Ambiguous moves are excluded.
Supported demands are unary +, -, ~; arithmetic or bit operations with an integer or float literal on the other side (excluding %); ordering against such a literal; and one-argument, unshadowed int, float, abs, round, range, or len. sum accepts a single generator or list comprehension, with no start or a numeric literal start. Value flow is limited to parentheses, conditional-expression branches, and comprehension bodies with one for clause. Every enclosing generator must pass directly to an unshadowed sum, allowing only bounded parentheses in between; escaped generators remain blocking. List comprehensions are evaluated eagerly.
Receivers are limited to unannotated plain parameters and those comprehension targets. After bounded parenthesis normalization, an unresolved comprehension iterable must be an unannotated, unreassigned parameter or an attribute chain without calls rooted in an unreassigned parameter, without a default value. A typed root must resolve to an ordinary class whose first field is assigned once from an unannotated, unreassigned __init__ parameter. Explicit field types, typed constructor inputs, decorated members, and unresolved root types remain blocking. Typed iterables, local aliases, and explicit constructor/factory results remain blocking unless the existing field proof already resolves the read.
Calls, known class/instance receivers, self / cls, escaped bound methods, properties and static/class methods remain blocking. So do truthiness, equality, string formatting, unknown consumers, any, sorted, max, and collections returned without a supported consumer. An enclosing try or with, name shadowing, evidence of access to builtins, parse failures, and incomplete input also prevent this classification. Single and batch refs output is unchanged.
The inference assumes that evaluating these operations did not intentionally rely on a method value raising TypeError in the old program. sum additionally assumes ordinary numeric accumulation: clues for __add__ or __radd__ in the old candidate file or current analysis scope prevent the sum_element classification. External custom numeric types remain unknown. Runtime validity is not checked. Exception handling across function boundaries, external callers, and runtime changes to builtins or classes outside the observed static input remain unknown. The result therefore reports the demand and its location for review, rather than claiming safety:
{"name":"Board.slot","kind":"method","file":"shop/board.py","refs_internal":0,"reason":"unresolved_receiver_type_demand","confidence":"low","references":[{"file":"shop/report.py","line":3,"column":20,"demand":"sum_element"}],"field_owners":[{"file":"shop/board.py","class":"Item","line":4,"column":4}]}Reference and field-declaration positions are zero-based. Hook entries use n, f, r, refs: [{f,l,c,d}], and field_owners: [{f,n,l,c}], while retaining confidence: "low". If a staged snapshot cannot be created and the working tree differs from the index, these entries return to blocking removed along with other reference-based downgrades.
With --git --base <rev>, the blame analysis of missing_cochanges uses the same base. When a PR with several commits is reviewed as a whole, the diff and the co-change candidates cover the same range.
missing_cochanges only proposes pairs that changed together 3 or more times (--cochange-min-samples, default 3). With blame on the changed lines, starting points with only 2 evidence commits are common, and pairs that "changed together only once" would line up at the top with confidence 1.0. To look at small samples exploratively, pass --cochange-min-samples 2 (the standalone cochange command keeps the default of 2). Deduplicating the candidates and picking the top 10 use the same smoothed score as the standalone command. This keeps a small sample such as 3/3 from mechanically ranking above a sufficient sample such as 30/40, and the raw confidence is used for showing the evidence and for the threshold.
Lock files and the dependency declaration files that correspond to a source (Cargo.toml / package.json / pyproject.toml, and so on) are not proposed in missing_cochanges. In a commit that adds a dependency, these files always change together with the source, so the historical correlation is 100%. That correlation holds only "when a dependency is added", though, and has no causal link to changes of the code that neither add nor remove an import. A dependency declaration file is left out only for the pairing with the nearest one in the same ecosystem as the source (a pair from different ecosystems, such as Cargo.toml and a Python script, stays a candidate). The standalone cochange command keeps showing dependency declaration files as the fact that they "changed together in the past" (lock files are generated, so both leave them out).
The relation between an external snapshot and the test that generates it is treated as directional. A diff that only updates a snapshot does not get "the generating test may be missing a change". A snapshot is also updated when the output of the code under test changes, so "if the test changes, the snapshot changes too" is a fair expectation, but the converse is not. The other direction stays: when a test changes and its snapshot is missing from the diff, the snapshot is proposed. Only pairs that meet all of the following are suppressed; if any of them cannot be confirmed, the pair stays a candidate:
- The snapshot's parent directory is exactly
__snapshots__, and its path with the trailing.snapremoved once matches the missing candidate exactly (the standard convention shared by Jest / Vitest / Bun:tests/__snapshots__/widget.test.tsx.snap→tests/widget.test.tsx) - The generating test exists as a regular file
- The first line of the snapshot exactly matches a known runner header (
// Vitest Snapshot v1, …, and so on). The path convention alone would also catch handwritten fixtures and.snapfiles for other uses, so the file itself has to show that it is generated output
This is independent of linguist-generated in .gitattributes (that declares generated files in general, and removes the pair from the candidates in both directions). Custom snapshot resolvers, inline snapshots, and formats other than .snap are not covered, and for all of them the historical correlation is shown as information. The global --include-generated also turns this direction off. The standalone cochange command is for exploration and is not directional.
This does not prove that no test change is needed (you can update only the expected values and forget a needed change in the test logic). It is a recommendation policy: for pairs in the standard generation relation, a change in the reverse direction is not requested on the basis of historical correlation alone.
api_changes.compatible_modified lists changes where the signature string changes but existing calls stay compatible. The following changes are informational and not blocking for --hook:
- Wrapping a React component in a HOC
- Removing an object member that nothing references
- Adding optional / default parameters at the end of a top-level TS/TSX function (
trailing_optional_params) - Adding optional properties to an inline TypeScript object parameter type, including the unique object branch of an otherwise unchanged union (
optional_object_props) - Adding keyword-only parameters with defaults or positional parameters with defaults at the end of a top-level Python function or of a method of a class directly under the module (
trailing_optional_params). When decorators differ or there are several definitions with the same name, the change stays blocking to be safe
The impacts tied to the same symbol are not reported as breaking either; they only appear as mod_compat information. To detect unreferenced object members, the removed keys are pre-extracted all at once with Aho-Corasick instead of searching the whole repository once per key, and each JS/TS file is parsed at most once. When collecting, reading, or parsing a file fails, the change is not downgraded to compatible and stays blocking.
For optional_object_props, object destructuring can also append bindings for optional properties added to the type in the same change. Existing elements must remain an exact token prefix, including aliases and defaults. New elements may be shorthand bindings or shorthand bindings with literal defaults: strings, numbers, booleans, null, templates without substitutions, or negative numbers. Parameter kind, the whole-parameter default, and the rest of the signature must stay unchanged.
When bindings are added, other union branches must be undefined. Defaults must match a property type made only of string, number, boolean, null, or a union of these. Aliases, other literal types, unknown type forms, BigInt defaults, legacy octal numbers, and numeric string/template escapes remain unproven.
This destructuring proof rejects rest, computed or nested patterns, duplicate keys or bindings, new aliases, and keys already present in the old type. It also rejects names referenced elsewhere in the signature, including another parameter's default and typeof inside a property or return type. Direct eval, escaped identifiers, and unsupported member forms remain unproven. The existing type-only classification is unchanged.
New bindings must also avoid direct-body lexical and function declarations, and any var binding in the same function scope. Unknown declaration forms remain blocking. Ordinary body references are outside this compatibility proof.
Value bindings such as export const are compared as whole declarations (including the initializer), but when the value itself is a function, its body is left out of the comparison (consistent with a body change of export function not being an api.mod). Only the bodies of the following functions are left out:
- An arrow function or function expression that is the value itself (including ones in parentheses or with
as/satisfies) - A function wrapped in React's
memo/forwardRef - A function that is a member of an object literal (methods and
key: () => ...)
Parameters, type annotations, and added or removed keys are still compared. Callbacks passed to other calls (such as create((set) => ({ ... }))) have bodies that decide the shape of a store or the value itself, so their bodies are compared too. Destructured bindings (export const { a, b } = obj) are compared by "the path to that name + the initializer" (arrays keep positions), so that adding or removing other bindings of the same destructuring does not make the remaining bindings an api.mod. Patterns with default values, computed keys, or rest elements are compared as whole declarations.
Changes to Python's public type contracts that can be classified by direction get {kind, breaks} as api_changes.modified[].contract_change (api.mod[].contract in hook output). This covers changes to whether TypedDict keys are required, and changes to the value set of a direct Literal type alias directly under the module. For Literal, a narrower value set is reported as literal_values_narrowed (breaks the producer side) and a wider one as literal_values_widened (breaks the consumer side). This applies only when the Literal can be proven to come from typing / typing_extensions and its values are only strings without escapes or prefixes, decimal integers, booleans, or None. When the meaning cannot be determined statically, as with replaced values, a dynamic __all__, a shadowed name, or star imports, the direction is not guessed and the change stays a normal blocking api.mod. Test files are not covered, following the existing public API surface conventions. A type alias entry is a pseudo-symbol with kind = "type", and it is not added to what symbols / refs / dead-code analyze.
Symbols that are called implicitly at run time are excluded differently in the API diff and in dead-code. PHPUnit conventions, TS/JS constructors, and Flyway migrations are excluded from both public surfaces. Laravel relations and Angular lifecycle hooks, on the other hand, are excluded from dead-code but stay in the API diff, so that changes to externally visible signatures are not missed. --framework selects dead-code conventions and does not change this API diff boundary across the board.
# Get the git diff automatically and review it (recommended)
astro-sight review --dir . --git
# Review staged changes
astro-sight review --dir . --git --staged
# Use a custom base
astro-sight review --dir . --git --base HEAD~3
# Use a patch or PR diff you already have
astro-sight review --dir . --diff-file /tmp/pr.patchOutput:
{
"impact": { "changes": [...] },
"missing_cochanges": [
{ "file": "src/service.rs", "expected_with": "src/commands.rs", "confidence": 0.75 }
],
"api_changes": {
"added": [],
"removed": [],
"modified": [
{
"name": "greet",
"kind": "function",
"file": "src/new.rs",
"old_signature": "pub fn greet() -> i32 {",
"new_signature": "pub fn greet(name: &str) -> i32 {"
}
]
},
"dead_symbols": []
}dead-code detects symbols that are exported but never referenced. With a diff, only the files related to the change are scanned; without one, the whole project is scanned.
# Scan the whole project
astro-sight dead-code --dir .
# Scan only Rust files
astro-sight dead-code --dir . --glob "**/*.rs"
# Scan only the files related to the git diff
astro-sight dead-code --dir . --git
# Only the files related to staged changes
astro-sight dead-code --dir . --git --stagedOutput:
{
"dir": "/path/to/project",
"scanned_files": 48,
"dead_symbols": [
{ "name": "unused_helper", "kind": "function", "file": "src/utils.rs", "line": 12 },
{ "name": "OldConfig", "kind": "struct", "file": "src/config.rs", "line": 40 }
]
}line is the line of the declaration (0-based). Symbols referenced only from tests are not included in dead_symbols; they are listed separately in test_only_symbols.
For JavaScript, TypeScript, and TSX, named default-exported functions and classes also receive references from module dependencies. This covers static default imports, namespace imports, default and namespace re-exports, require(), and dynamic import() with a string or a template literal without substitutions. Type-only forms such as import type and typeof import() also count as dependencies. A namespace can be passed to a loader such as React.lazy, so these dependencies conservatively keep the default declaration alive even without a local .default access. Other named exports and class members keep their existing reference rules; side-effect-only static imports and export * do not consume the default export. Dependencies found only in test files produce test_only_symbols.
This module lookup supports relative paths, extensionless and index paths, and source extension substitution such as ./panel.js referring to panel.tsx. It checks candidate paths within the workspace and retains all plausible matches. It does not resolve package names, configured path aliases, escaped specifiers, or bundler query strings. Identifier occurrences returned by refs are unchanged.
When symbols with the same name exist in several files, they are skipped to avoid false findings. Class members in TS/JS and PHP are an exception, judged only when their owner can be inferred safely and uniquely. In PHP, Owner::method() and self::method() inside the same class count as definite references. When there are references whose owner cannot be determined, such as $obj->method() or callable strings, the symbol is skipped. static:: can reach a subclass override through late static binding, so it is not resolved as a definite reference. Static calls through a class / trait / enum that uses a trait count as references only for a trait method reached uniquely. If the composing class has a concrete method with the same name, the resolution does not go to the trait, following PHP's resolution order.
Symbols that frameworks and test runners call dynamically, by naming convention or reflection, cannot be traced to a caller through identifier-level cross-file references and would be false findings. The following conventions are therefore excluded from dead-code automatically:
- PHPUnit:
*Test/*TestCase/*IntegrationTest/*FeatureTestclasses andtestXxx/setUp/tearDown/setUpBeforeClass/tearDownAfterClassmethods - Python unittest: classes that inherit
unittest.TestCase(andunittest.IsolatedAsyncioTestCase), with indirect inheritance within the same file resolved to a fixed point, and theirtest_*/setUp/tearDown/setUpClass/tearDownClass/addCleanup/addClassCleanupmethods - Python pytest: top-level
test_*functions intest_*.py/*_test.pyfiles, and every function inconftest.py - Python framework registration decorators: functions, methods, and classes with registration decorators of Typer / Click / FastAPI / Flask / Django / Celery / pytest, and so on. A decorator imported under another name (
from celery import shared_task as st) is matched by its original name. Methods defined in a class body that Pydantic calls during validation and serialization (@model_validator/@field_validator/@field_serializer/@model_serializer/@computed_field/@root_validator) are excluded too. The v1@validatoris excluded only when it is imported frompydantic - Python dynamic protocol methods:
*_open/*_request/*_response/http_error_*of theurllib.request.BaseHandlerfamily, and theon_*callbacks of watchdog'sFileSystemEventHandlerfamily. Only methods of classes that directly inherit the known base classes are excluded - Angular: lifecycle hooks of classes decorated with
@Component/@Directive(ngOnInit/ngOnDestroy/ngOnChanges/ngDoCheck/ngAfterContentInit/ngAfterContentChecked/ngAfterViewInit/ngAfterViewChecked) are excluded because the Angular runtime calls them in the change detection cycle.transformof@Pipeclasses (called from| namein templates) andngOnDestroyof@Injectable/@Pipeclasses (called when a service / pipe is destroyed) are excluded too. Other hooks of services and pipes (ngOnInit, and so on) are not called by Angular and are not excluded - PHP annotations and attributes: methods and classes whose docblock has an annotation starting with an uppercase letter (
@Route,@Entity, ...) or a PHPUnit annotation such as@test/@dataProvider, and those with a PHP 8 attribute of the same name (#[Route],#[Test], ...) - Methods called through syntax: Kotlin
operator funand SwiftcallAsFunction. They are called through operators or call syntax, not by name. They stay in the API diff - Program entry points: the following are excluded (they stay in the API diff)
- C / C++:
mainat global scope - Kotlin: top-level
fun main, and@JvmStatic fun maindirectly inside anobject/companion object - Java: non-private
void main(with no parameters or a singleString[], including Java 25 instance main) - C#:
static Main(with no parameters or a singlestring[]) - In Java / C# / Kotlin, the type that declares the entry point (including the outer types of a nested type) is excluded too
- C / C++:
Files detected as generated (a generated-file declaration comment at the top, linguist-generated in .gitattributes, and so on) are left out of the candidates for dead by default. References inside generated files are always counted, though. Generated code calls hand-written code at run time, as when generated gRPC handlers (*_grpc.pb.go) call a hand-written server implementation, and dropping those references would report live symbols as dead.
Files left out of the candidates are listed in generated_candidates_skipped, in the same shape as skipped of refs (not printed when there are none). --include-generated (or skip_generated = false) makes the symbols of generated files candidates too.
{ "...": "...", "generated_candidates_skipped": { "generated": 1, "paths": ["api/greeter_grpc.pb.go"] } }When a file whose references are counted cannot be read (over the 100MB limit, no permission, ...) or fails to parse, candidates whose names appear in that file are not reported as dead. They are listed in unverified_symbols, and dead_symbols keeps only the symbols confirmed to have no references. The file is reported as source_analysis_failed in truncations, whether or not any candidate was withheld.
{ "...": "...", "unverified_symbols": [{ "name": "load_fixture", "kind": "function", "file": "src/fixtures.rs", "line": 3 }], "truncations": [{ "path": "data/huge.rs", "reason": "source_analysis_failed", "message": "references in this file were not counted (the file exceeds the read limit (115343360 bytes > 104857600 bytes)); 1 candidate name(s) appear in it, so they are not reported as dead (1 candidate(s) in total were withheld from dead/test-only)" }] }A file over the limit is not parsed. Its content is read in fixed-size chunks only to check which candidate names appear in it. When a file cannot be opened or is larger than 1GiB, it is unknown which names appear, so every candidate is withheld. When only a test file could not be counted, test_only_symbols is not withheld: the absence of references from production files does not depend on test files. With review --hook, this appears in trunc only and does not cause exit 1 by itself.
Even without --framework, the nextjs preset is applied automatically when a package.json directly under <dir> or in a monorepo package has a next key in dependencies / devDependencies. The convention globs detected this way are limited to paths relative to each Next.js workspace, so app/**/page.tsx in a sibling workspace that is not Next.js is not excluded. node_modules, generated output, and symlinks are not searched, and peerDependencies / optionalDependencies are ignored because they tend to trigger the preset by mistake. An explicit choice (--framework laravel, and so on) always wins over the detection.
# Applied automatically when a package.json at the root or in a workspace has `next`
astro-sight dead-code --dir .
astro-sight review --dir . --gitapi_changes of review (added / removed / modified) automatically leaves out pub fn changes in bin-only Rust crates (no src/lib.rs and no [lib] section in Cargo.toml). A pub fn of a bin-only crate cannot be reached from outside the crate, so adding, removing, or changing its signature is never a compatibility problem of a public API. Even when the same diff deletes src/lib.rs, if the crate was a library crate at the base revision, the removal of the old public API correctly stays in removed.
cochange finds files that tend to change together with the given files, from git blame and diff-tree. missing_cochanges of review --git --base <rev> uses the same analysis.
# Take the starting files from the git diff
astro-sight cochange --dir . --git --base HEAD~5
# Name the starting files explicitly
astro-sight cochange --dir . --paths src/service.rs
# Follow renames / copies
astro-sight cochange --dir . --git --base HEAD~10 --rename --copy--paths-file is read with a 100MB limit, and an empty list returns INVALID_REQUEST. --min-confidence accepts only finite values in 0.0..=1.0, and --smoothing-alpha / --smoothing-beta only finite non-negative values. Source files given with --paths / --paths-file must be relative paths under --dir; paths containing .., absolute paths, and Windows paths with a drive prefix are rejected with PATH_OUT_OF_BOUNDS.
Generated output is excluded both as a starting point and as a candidate. Files written together by batch jobs, code generation, or builds almost always land in the same commit, so otherwise "mechanical simultaneous updates" would be presented as co-changes with high confidence. The decision gives linguist-generated in .gitattributes the highest priority (set / true excludes the file, and unset / false keeps it without looking at header markers). Without that attribute, generated-file markers at the top of the file (@generated / DO NOT EDIT, and so on) decide. If neither decides, the file stays a candidate. Extensions and update frequency are not used, so declare generated JSON / CSV files, which cannot hold comments, in .gitattributes.
data/*.json linguist-generated=true
fixtures/hand-maintained.yaml -linguist-generatedThe denominators of the remaining starting points do not change with the exclusion (removing commits in which generated files were present from the denominator would turn 1/2 into 1/1, a selection bias). The numbers of exclusions are in excluded_generated_sources / filtered_generated_candidates of diagnostics. When git check-attr cannot be run, nothing is excluded at all (reported as GeneratedAttrLookupFailed), and candidates are not removed just because the decision is impossible. The global --include-generated (or the equivalent skip_generated = false in config.toml) includes generated output too. The option affects not only the standalone cochange but also missing_cochanges of review.
This classification applies only when both versions use the Bash grammar. Replacing an API from another language with a shell function of the same name retains the existing deletion checks. Extensionless files are checked against their original shebang as well.
If a Bash/zsh function disappears from the parsed symbols but its declaration header remains inside an ERROR region, review reports it in api_changes.uncertain_removals instead of confirming a deletion. Each item includes the name, file, zero-based line, old signature, and reason: "parse_error_region". The hook emits api.rm_unverified with {n,f,l,r}. These entries are informational; a confirmed deletion in the same file still blocks. Duplicate declarations and ambiguous string/heredoc contents keep the existing conservative deletion checks.
truncations (hook: trunc) also reports parse_error_region for changed Bash/zsh files with parser errors. This means declarations, references, and signature changes in those regions could not be verified; exit 0 does not establish compatibility. The parser does not invent replacement symbols, and the analysis of healthy regions continues.
When an explicit diff deletes a supported source file but its old content is absent from, differs from, or cannot be fully checked against the requested base, review reports diff_base_mismatch in truncations (hook: trunc). The hook blocks with api.diff_base_mismatch. Any extracted deleted symbols stay in blocking api.rm; if no old source is available, the truncation is the only finding. Deleting documentation or a Git submodule does not trigger this check. --git fixes submodule output to the short gitlink form regardless of diff.submodule. Use the revision before deletion as --base when available.