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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 0 additions & 41 deletions CHANGELOG.md

This file was deleted.

14 changes: 2 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,18 +125,8 @@ AxiomCode Graph is two parts. The **engine** (`@axiomcode/code-graph` on npm) pa
graph; it also provides the `axiomcode` command and an MCP server. The **plugin** (`plugins/axiomcode/`) is the
agent-facing frontend: a skill, seven MCP tools, and hooks. Install the engine first.

Requirements: **Node ≥ 22.5** and **Python 3** (`python3`, or `python` / `py` on Windows). On Windows, also
[Git for Windows](https://git-scm.com/download/win): the CLI runs under its bash. The engine ships as a prebuilt
binary for macOS (Apple Silicon and Intel), Linux (x64 and arm64) and Windows x64, and `npm install` takes the one for
your platform. No Soufflé and no compiler are needed, with one exception:

> **Linux arm64** (Graviton or Ampere servers, Raspberry Pi, `node:*-slim` or Alpine containers on an Apple Silicon
> Mac): the parser's native modules compile during `npm install`, so install build tools first, e.g.
> `sudo apt install build-essential python3`. The full `node:*` Docker images already include them.

Before every release, the exact packages that ship are installed without Soufflé and run end to end (every CLI verb,
five languages) on macOS arm64 and x64, Linux x64 and arm64, and Windows x64. Check an install with
`axiomcode --version`.
Requirements: **Node ≥ 22.5** and **`python3`**. The engine ships as a prebuilt binary and `npm install`
takes the one for your platform; there is nothing else to install.

### Installation

Expand Down
2 changes: 1 addition & 1 deletion gemini-extension.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "axiomcode",
"version": "0.1.3",
"version": "0.1.2",
"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": {
Expand Down
12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@axiomcode/code-graph",
"version": "0.1.3",
"version": "0.1.2",
"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",
Expand Down Expand Up @@ -30,11 +30,11 @@
],
"license": "FSL-1.1-Apache-2.0",
"optionalDependencies": {
"@axiomcode/engine-darwin-arm64": "0.1.3",
"@axiomcode/engine-darwin-x64": "0.1.3",
"@axiomcode/engine-linux-x64": "0.1.3",
"@axiomcode/engine-linux-arm64": "0.1.3",
"@axiomcode/engine-win32-x64": "0.1.3"
"@axiomcode/engine-darwin-arm64": "0.1.2",
"@axiomcode/engine-darwin-x64": "0.1.2",
"@axiomcode/engine-linux-x64": "0.1.2",
"@axiomcode/engine-linux-arm64": "0.1.2",
"@axiomcode/engine-win32-x64": "0.1.2"
},
"bin": {
"axiomcode": "bin/axiomcode.js"
Expand Down
2 changes: 1 addition & 1 deletion parser/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@axiomcode/parser",
"version": "0.1.0",
"version": "0.1.1",
"description": "AxiomCode Parser — compiles source code and build configuration into a relational intermediate representation.",
"main": "dist/extract.js",
"types": "dist/extract.d.ts",
Expand Down
26 changes: 24 additions & 2 deletions parser/src/workflows/python/python-project-analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,28 @@ const CHUNK_SIZE = 50_000;
* deferred set for the second freeze, so the rejection detail lives in the
* skipped-files CSV until it is unfrozen.
*/

/**
* A `.py`/`.pyi` file, or an executable script with no extension whose first
* line is a python shebang (`#!/usr/bin/env python3`): the interpreter runs it
* as a module, so it is analysed as one (#1376).
*/
const PYTHON_SHEBANG = /^#![^\n]*\bpython[0-9.]*\b/;
export function isPythonSourceFile(full: string, name: string): boolean {
if (name.endsWith('.py') || name.endsWith('.pyi')) return true;
if (name.includes('.')) return false;
let fd: number | undefined;
try {
fd = fs.openSync(full, 'r');
const head = Buffer.alloc(128);
const n = fs.readSync(fd, head, 0, head.length, 0);
return PYTHON_SHEBANG.test(head.subarray(0, n).toString('utf-8'));
} catch {
return false;
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
}
export class PythonProjectAnalyzer {
private extractor: PythonFactExtractor;
private resolutionLinker: PythonResolutionLinker;
Expand Down Expand Up @@ -568,7 +590,7 @@ export class PythonProjectAnalyzer {
found.push(...(await this.collectPythonFiles(full, excludes, false)));
continue;
}
if (entry.name.endsWith('.py') || entry.name.endsWith('.pyi')) {
if (entry.isFile() && isPythonSourceFile(full, entry.name)) {
found.push(full);
}
}
Expand Down Expand Up @@ -603,7 +625,7 @@ export class PythonProjectAnalyzer {
found.push(...(await this.collectPythonFiles(full, excludes, false)));
continue;
}
if (entry.name.endsWith('.py') || entry.name.endsWith('.pyi')) {
if (entry.isFile() && isPythonSourceFile(full, entry.name)) {
found.push(full);
}
}
Expand Down
2 changes: 1 addition & 1 deletion plugins/axiomcode/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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.3",
"version": "0.1.2",
"author": {
"name": "AxiomCode"
}
Expand Down
2 changes: 1 addition & 1 deletion plugins/axiomcode/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "axiomcode",
"version": "0.1.3",
"version": "0.1.2",
"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",
Expand Down
2 changes: 1 addition & 1 deletion plugins/axiomcode/.cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "axiomcode",
"version": "0.1.3",
"version": "0.1.2",
"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",
Expand Down
23 changes: 21 additions & 2 deletions plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

Environment: AXIOMCODE_NO_REFRESH=1 turns every trigger off; AXIOMCODE_REFRESH_DEBOUNCE (seconds, default 2)
is the quiet window; AXIOMCODE_FRESH_WAIT (seconds, default 10) is how long a query verb waits."""
import errno, hashlib, json, os, subprocess, sys, time
import re, errno, hashlib, json, os, subprocess, sys, time

H = os.path.dirname(os.path.abspath(__file__))

Expand Down Expand Up @@ -60,12 +60,24 @@ def out_dir(repo): return os.path.join(repo, '.axiomcode', 'out')
def table_path(repo): return os.path.join(out_dir(repo), 'files.json')
def state_path(repo): return os.path.join(repo, '.axiomcode', 'refresh.json')

PY_SHEBANG = re.compile(r'#![^\n]*\bpython[0-9.]*\b')

def python_script(p, name=None):
"""an executable Python script with no extension: its first line is a python shebang. The parser analyses it as a
module (#1376), so it is watched and counted like a .py file"""
if '.' in (name or os.path.basename(p)): return False
try:
with open(p, 'rb') as fh: head = fh.read(128)
except OSError: return False
return bool(PY_SHEBANG.match(head.decode('utf-8', 'replace')))

def watched(root, lang):
exts, names, prune = EXT.get(lang, ()), NAMES.get(lang, ()), PRUNE.get(lang, PRUNE_ALL)
for d, subdirs, files in os.walk(root):
subdirs[:] = [s for s in subdirs if s not in prune]
for f in files:
if f.endswith(exts) or f in names or (lang == 'java' and os.path.basename(d) == 'services' and 'META-INF' in d):
if f.endswith(exts) or f in names or (lang == 'java' and os.path.basename(d) == 'services' and 'META-INF' in d) \
or (lang == 'python' and python_script(os.path.join(d, f), f)):
yield os.path.join(d, f)

def digest(p):
Expand Down Expand Up @@ -337,6 +349,13 @@ def main(argv):
lib = os.environ.get('AXIOMCODE_LIBRARY', '')
json.dump(dict(lang=lang, 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
if cmd == 'pyscripts':
# how many extensionless python scripts are under repo: added to the build's .py count when it picks a language
n = 0
for d, subdirs, files in os.walk(repo):
subdirs[:] = [x for x in subdirs if x not in PRUNE_ALL]
n += sum(1 for f in files if python_script(os.path.join(d, f), f))
print(n); 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 ''
Expand Down
2 changes: 1 addition & 1 deletion plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ ENGINE="$AXIOMCODE_ENGINE"
exit 1; }
# no `head` in this pipeline: under pipefail a closed pipe kills find and, with set -e, the script — silently
count(){ find "$SRC" \( -path '*/node_modules' -o -path '*/.axiomcode' -o -path '*/.git' -o -path '*/.venv' -o -path '*/venv' -o -path '*/site-packages' -o -path '*/dist' -o -path '*/build' \) -prune -o -type f \( "$@" \) -print 2>/dev/null | wc -l | tr -d ' '; }
py=$(count -name '*.py'); ja=$(count -name '*.java'); ts=$(count -name '*.ts' -o -name '*.tsx'); js=$(count -name '*.js' -o -name '*.mjs' -o -name '*.cjs' -o -name '*.jsx'); cs=$(count -name '*.cs')
py=$(( $(count -name '*.py') + $(python3 "$H/ax_fresh.py" pyscripts "$SRC" 2>/dev/null || echo 0) )); ja=$(count -name '*.java'); ts=$(count -name '*.ts' -o -name '*.tsx'); js=$(count -name '*.js' -o -name '*.mjs' -o -name '*.cjs' -o -name '*.jsx'); cs=$(count -name '*.cs')
LANG_ARG="${AXIOMCODE_LANG:-}"
if [ -z "$LANG_ARG" ]; then
LANG_ARG=java; best=$ja
Expand Down
5 changes: 3 additions & 2 deletions plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ class Changed:
# ── one file: the declarations touched and how ────────────────────────────────────────────────────────────
def file_changes(self, rel, old, new):
OL, NL = old.split('\n'), new.split('\n')
is_py = rel.endswith(('.py', '.pyi')) or bool(re.match(r'#![^\n]*\bpython[0-9.]*\b', new or old)) # a shebang script too (#1376)
sm = difflib.SequenceMatcher(None, OL, NL, autojunk=False)
old_changed = set(); new_of = {}; added = []; removed_lines = set(); replaced = []
# a blank line, a comment line, a brace on its own: not a change to a declaration
Expand Down Expand Up @@ -275,7 +276,7 @@ class Changed:
if kind == 'signature':
# compared with comments removed: `f( /* note */ a)` is `f(a)`. Strings stay, so an edited route in a
# decoration is still a header change
uncomment = lambda lines: re.sub(r'\s+', ' ', strip_code('\n'.join(lines), strings=False, hash_comments=rel.endswith('.py'))).strip()
uncomment = lambda lines: re.sub(r'\s+', ' ', strip_code('\n'.join(lines), strings=False, hash_comments=is_py)).strip()
oh_raw = ' '.join(x.strip() for x in OL[a - 1:self.header_end(OL, a)]); oh = uncomment(OL[a - 1:self.header_end(OL, a)])
na = new_of.get(a) or (new_of.get(max((x for x in new_of if x < a), default=0), 0) + 1)
nh = nh_raw = ''
Expand Down Expand Up @@ -352,7 +353,7 @@ class Changed:
names = []; hdrs = {}
# the type's body level in the NEW text: brace depth just after its header (indentation for Python); a declaration
# the insertion introduces sits exactly there — a local inside a new method's body sits deeper
py = rel.endswith('.py'); tline = new_of.get(t[0]) if t else None
py = is_py; tline = new_of.get(t[0]) if t else None
if t and not tline: # the type's header line moved: find it in the new text by name
tline = next((j for j in range(1, len(NL) + 1) if re.search(rf'\b(class|interface|enum|record|trait|struct)\s+{re.escape(t[5])}\b', NL[j - 1])), None)
if tline: body_depth = ndepth[self.header_end(NL, tline)] if not py else len(NL[tline - 1]) - len(NL[tline - 1].lstrip()) + 4
Expand Down
7 changes: 6 additions & 1 deletion plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ class Impact:
"""the source with comments and string literals blanked (same length, same line breaks): a name in a javadoc, a
docstring or a string is not a use. Block /* */, line // and # (Python), '…' "…" `…` and triple-quoted strings."""
if ('code', file) in self.src: return self.src[('code', file)]
t = '\n'.join(self.lines(file)); out = []; i = 0; n = len(t); py = file.endswith('.py')
t = '\n'.join(self.lines(file)); out = []; i = 0; n = len(t); py = file.endswith(('.py', '.pyi')) or bool(re.match(r'#![^\n]*\bpython[0-9.]*\b', t))
triple = py or file.endswith(('.java', '.kt', '.scala', '.groovy')) # a Java text block is """ too
js = file.endswith(('.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx'))
prev = '' # the last code character seen
Expand Down Expand Up @@ -593,6 +593,9 @@ class Impact:
out = set()
for f in files:
ext = os.path.splitext(f)[1].lower()
if not ext: # a python shebang script (#1376)
try: ext = '.py' if re.match(r'#![^\n]*\bpython[0-9.]*\b', (self.lines(f) or [''])[0]) else ''
except Exception: ext = ''
rx = self.IMPORT_RE.get(ext)
if not rx: continue
try: text = '\n'.join(self.lines(f))
Expand Down Expand Up @@ -625,12 +628,14 @@ class Impact:
a services file, a properties file, a build script, a spec) breaks at run time and the compiler is silent."""
if hasattr(self, '_nsf'): return self._nsf
out = []
indexed = {sy['file'] for sy in self.g.sym.values() if sy.get('file')} # a shebang script is source too (#1376)
for root, dirs, files in os.walk(self.g.repo):
dirs[:] = [d for d in dirs if d not in self.SKIP_DIR and not d.startswith('.axiomcode')]
for fn in files:
ext = os.path.splitext(fn)[1].lower()
if ext in self.SOURCE_EXT or ext in self.PROSE_EXT: continue
fp = os.path.join(root, fn)
if os.path.relpath(fp, self.g.repo).replace(os.sep, '/') in indexed: continue
try:
if os.path.getsize(fp) > 512 * 1024: continue
with open(fp, 'rb') as fh: head = fh.read(2048)
Expand Down
14 changes: 14 additions & 0 deletions tests/cases/python/shebang-script/case.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{"lang": "python", "src": ".",
"checks": [
{"why": "a script with a python shebang and no .py extension is a module: the function it calls has it as a caller, and it is not listed among the files the index does not read as source",
"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",
"run": ["impact", "price"],
"want": ["[text] deploy:2"],
"avoid": ["deploy.<module>"]},
{"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"],
"avoid": ["no declarations known here", "no changed declaration"]}]}
2 changes: 2 additions & 0 deletions tests/cases/python/shebang-script/deploy
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/bin/sh
python3 -c 'from pricing import price; print(price(1, 1))'
10 changes: 10 additions & 0 deletions tests/cases/python/shebang-script/make-report
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env python3
from pricing import price


def report():
return price(3, 5)


if __name__ == '__main__':
print(report())
10 changes: 10 additions & 0 deletions tests/cases/python/shebang-script/new.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env python3
from pricing import price


def report():
return price(4, 5)


if __name__ == '__main__':
print(report())
10 changes: 10 additions & 0 deletions tests/cases/python/shebang-script/old.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env python3
from pricing import price


def report():
return price(3, 5)


if __name__ == '__main__':
print(report())
2 changes: 2 additions & 0 deletions tests/cases/python/shebang-script/pricing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
def price(qty, unit):
return qty * unit
Loading