diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 76c2acc00..000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,41 +0,0 @@ -# Changelog - -Every release of `@axiomcode/code-graph` and its `@axiomcode/engine-` packages. The same notes are on each -[GitHub release](https://github.com/AxiomCodeAI/axiomcodegraph/releases). - -## 0.1.3 — 2026-09-26 - -### Documentation -- #1372 README: what installing needs on each platform. Python 3 can be `python` or `py` on Windows, which also needs Git for Windows. On Linux arm64 the parser's native modules compile during `npm install`, so install build tools first (`sudo apt install build-essential python3`); the full `node:*` Docker images already include them `docs` - -## 0.1.2 — 2026-09-26 - -### Improvements -- #1360 targets: one spelling works in every language. `util.square`, `src.util.square` and `src/util#square` all name the same declaration in `impact`, `path` and `context`; spellings that worked before still work, and a name that fits two declarations lists both instead of guessing `cli` -- #1352 cli: `axiomcode --version` prints the installed version `cli` - -### Bugs -- #1363 windows: `impact`, `test-impact` and `changed --impact` crashed for every user without admin rights (WinError 1314) `windows` -- #1332 windows: a `git.exe`, `python.exe` or `py.exe` in the working directory was run instead of the real one, which could hang the CLI `windows` -- #1369 windows: `axiomcode path '*' …` received the directory's file names instead of `*` `windows` -- #1364 windows: `impact` could not find its query binaries in plugin installs, a console window opened on every background refresh, hooks crashed on files on another drive, WSL's bash could be picked instead of Git Bash, and library paths with spaces were split `windows` -- #1359 macOS: on Apple Silicon Macs whose `python3` is an Intel build (such as Anaconda), `impact` failed unless Soufflé was installed `macos` -- #1361 javascript: `impact` on a class imported with `require` refused it as "declared as more than one kind" `javascript` - -### Build and release -- #1366 every release is tested on all five platforms (Linux x64/arm64, macOS arm64/x64, Windows x64) with every CLI verb before it is tagged `ci` -- #1350 engines are built once per release, and the exact build that passed the tests is the one published `ci` -- #1365 releases and tags are published by axiomcode-bot `ci` - -## 0.1.1 — 2026-09-25 - -### Improvements -- #1336 graph: labels scale with zoom and never overlap, so a small repository opens as cleanly as a large one `graph` - -### Bugs -- #1329 index: exits with code 127 on a clean machine because it needs the sqlite3 command-line program `index` -- #1330 impact and path need Soufflé on the user's machine: the query programs are not shipped compiled `engine` -- #1331 windows: every CLI verb and hook calls python3, which a python.org install does not provide `windows` -- #1340 windows: the index stores paths with backslashes, so context finds no scope, and every path lookup depends on the separator `windows` -- #1341 path --every: the route enumeration grows without bound on a cyclic subgraph and exhausts memory `path` -- #1345 context: a repository with its files at the root has no scope, and the refusal offers an empty list `context` diff --git a/README.md b/README.md index 8522bc8c8..51cb2e405 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/gemini-extension.json b/gemini-extension.json index 5d36ac9bd..defcedc65 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -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": { diff --git a/package.json b/package.json index 9d61673fd..b38548c41 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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" diff --git a/parser/package.json b/parser/package.json index fe7527c54..6c9395caa 100644 --- a/parser/package.json +++ b/parser/package.json @@ -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", diff --git a/parser/src/workflows/python/python-project-analyzer.ts b/parser/src/workflows/python/python-project-analyzer.ts index 1669761b5..2925a86b9 100644 --- a/parser/src/workflows/python/python-project-analyzer.ts +++ b/parser/src/workflows/python/python-project-analyzer.ts @@ -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; @@ -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); } } @@ -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); } } diff --git a/plugins/axiomcode/.claude-plugin/plugin.json b/plugins/axiomcode/.claude-plugin/plugin.json index 38e818dc4..28f13b63e 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.3", + "version": "0.1.2", "author": { "name": "AxiomCode" } diff --git a/plugins/axiomcode/.codex-plugin/plugin.json b/plugins/axiomcode/.codex-plugin/plugin.json index c16a6f50b..f4d18b779 100644 --- a/plugins/axiomcode/.codex-plugin/plugin.json +++ b/plugins/axiomcode/.codex-plugin/plugin.json @@ -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", diff --git a/plugins/axiomcode/.cursor-plugin/plugin.json b/plugins/axiomcode/.cursor-plugin/plugin.json index 743e1acf0..a8f886ec1 100644 --- a/plugins/axiomcode/.cursor-plugin/plugin.json +++ b/plugins/axiomcode/.cursor-plugin/plugin.json @@ -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", diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index 57636c7c5..294bf75fc 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -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__)) @@ -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): @@ -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 '' diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build index 4724ce140..bb7fe597a 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-build @@ -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 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index 0055d86c0..423e16150 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -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 @@ -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 = '' @@ -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 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 8f5c9f22e..4c6def0a0 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -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 @@ -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)) @@ -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) diff --git a/tests/cases/python/shebang-script/case.json b/tests/cases/python/shebang-script/case.json new file mode 100644 index 000000000..885b16155 --- /dev/null +++ b/tests/cases/python/shebang-script/case.json @@ -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."]}, + {"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"]}]} diff --git a/tests/cases/python/shebang-script/deploy b/tests/cases/python/shebang-script/deploy new file mode 100755 index 000000000..da5df3553 --- /dev/null +++ b/tests/cases/python/shebang-script/deploy @@ -0,0 +1,2 @@ +#!/bin/sh +python3 -c 'from pricing import price; print(price(1, 1))' diff --git a/tests/cases/python/shebang-script/make-report b/tests/cases/python/shebang-script/make-report new file mode 100755 index 000000000..c95b99a70 --- /dev/null +++ b/tests/cases/python/shebang-script/make-report @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +from pricing import price + + +def report(): + return price(3, 5) + + +if __name__ == '__main__': + print(report()) diff --git a/tests/cases/python/shebang-script/new.txt b/tests/cases/python/shebang-script/new.txt new file mode 100644 index 000000000..7a4550003 --- /dev/null +++ b/tests/cases/python/shebang-script/new.txt @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +from pricing import price + + +def report(): + return price(4, 5) + + +if __name__ == '__main__': + print(report()) diff --git a/tests/cases/python/shebang-script/old.txt b/tests/cases/python/shebang-script/old.txt new file mode 100755 index 000000000..c95b99a70 --- /dev/null +++ b/tests/cases/python/shebang-script/old.txt @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +from pricing import price + + +def report(): + return price(3, 5) + + +if __name__ == '__main__': + print(report()) diff --git a/tests/cases/python/shebang-script/pricing.py b/tests/cases/python/shebang-script/pricing.py new file mode 100644 index 000000000..53f2f8440 --- /dev/null +++ b/tests/cases/python/shebang-script/pricing.py @@ -0,0 +1,2 @@ +def price(qty, unit): + return qty * unit