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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ target/
!graph/test/java/cases/62-build-output-is-not-source/src/**/target/**
!graph/test/java/cases/62-build-output-is-not-source/src/**/build/
!graph/test/java/cases/62-build-output-is-not-source/src/**/build/**
# ...and the Gradle case: build/ beside a build script is the construct (the parser must skip it by that
# anchor, not because git ignores it), and src/main/java/app/build/ is the control.
!graph/test/java/cases/64-gradle-build-output-is-not-source/src/**/build/
!graph/test/java/cases/64-gradle-build-output-is-not-source/src/**/build/**

# ── Secrets & local configuration ────────────────────────────────────────────
.env
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
plugins {
id 'java'
}

dependencies {
implementation project(':lib')
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package app.gen;

// Written by an annotation processor into Gradle's output directory: not the project's source.
// Its only call is to the JDK, so the bytecode oracle scores nothing here; if the parser read
// this file, the .edges golden would gain its boundary_lib row.
public class GeneratedStage {
public String label(String name) {
return name.trim();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
plugins {
`java-library`
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package app.gen;

// A Gradle MODULE's output (beside lib/build.gradle.kts): skipped like the root project's.
public class GeneratedLibStep {
public String label(String name) {
return name.strip();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package app.lib.build;

// The same inside a Gradle module: lib/src/main/java/app/lib/build is a package, not lib/build.
public class LibStep {
public String apply(String name) {
return name + "-applied";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
rootProject.name = 'app'
include 'lib'
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package app;

import app.build.StageBuilder;
import app.lib.build.LibStep;

public class Pipeline {
public String run(String name) {
String staged = new StageBuilder().stage(name);
return new LibStep().apply(staged);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package app.build;

// A source package named `build`, in a Gradle project: it has no build script beside it, so it is source.
public class StageBuilder {
public String stage(String name) {
return name + "-staged";
}
}
4 changes: 2 additions & 2 deletions graph/test/java/expected/62-build-output-is-not-source.edges
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
ambiguous_unknown method app.WidgetValidator#check(String) -> -
ambiguous_unknown new app.WidgetValidator#check(String) -> -
boundary_lib method app.WidgetValidator#check(String) -> external:String.isEmpty
known_edge method app.WidgetValidator#check(String) -> app.build.StepBuilder#next(String)
known_edge method app.WidgetValidator#check(String) -> app.site.PageRenderer#render(String)
known_edge new app.WidgetValidator#check(String) -> app.build.StepBuilder#<init>()
known_edge new app.WidgetValidator#check(String) -> app.site.PageRenderer#<init>()
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
oracle=4 engine=4 agree=4 missing=0 (known 0, NEW 0) extra=0
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
ambiguous_unknown METHOD_PARAM 0 app.WidgetValidator#check(String) [METHOD_PARAM] -> -
ambiguous_unknown METHOD_PARAM 0 app.build.StepBuilder#next(String) [METHOD_PARAM] -> -
ambiguous_unknown METHOD_PARAM 0 app.site.PageRenderer#render(String) [METHOD_PARAM] -> -
ambiguous_unknown METHOD_RETURN 0 app.build.StepBuilder#next(String) [METHOD] -> -
ambiguous_unknown METHOD_RETURN 0 app.site.PageRenderer#render(String) [METHOD] -> -
ambiguous_unknown OBJECT_CREATION_TYPE 0 app.WidgetValidator#check(String) [EXPRESSION] -> -
known_edge OBJECT_CREATION_TYPE 0 app.WidgetValidator#check(String) [EXPRESSION] -> app.build.StepBuilder
known_edge OBJECT_CREATION_TYPE 0 app.WidgetValidator#check(String) [EXPRESSION] -> app.site.PageRenderer
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
oracle=1 engine=1 agree=1 missing=0 (known 0, NEW 0) extra=0
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
known_edge method app.Pipeline#run(String) -> app.build.StageBuilder#stage(String)
known_edge method app.Pipeline#run(String) -> app.lib.build.LibStep#apply(String)
known_edge new app.Pipeline#run(String) -> app.build.StageBuilder#<init>()
known_edge new app.Pipeline#run(String) -> app.lib.build.LibStep#<init>()
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
oracle=4 engine=4 agree=4 missing=0 (known 0, NEW 0) extra=0
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
ambiguous_unknown LOCAL_VARIABLE 0 app.Pipeline#run(String) [LOCAL_VARIABLE] -> -
ambiguous_unknown METHOD_PARAM 0 app.Pipeline#run(String) [METHOD_PARAM] -> -
ambiguous_unknown METHOD_PARAM 0 app.build.StageBuilder#stage(String) [METHOD_PARAM] -> -
ambiguous_unknown METHOD_PARAM 0 app.lib.build.LibStep#apply(String) [METHOD_PARAM] -> -
ambiguous_unknown METHOD_RETURN 0 app.Pipeline#run(String) [METHOD] -> -
ambiguous_unknown METHOD_RETURN 0 app.build.StageBuilder#stage(String) [METHOD] -> -
ambiguous_unknown METHOD_RETURN 0 app.lib.build.LibStep#apply(String) [METHOD] -> -
known_edge OBJECT_CREATION_TYPE 0 app.Pipeline#run(String) [EXPRESSION] -> app.build.StageBuilder
known_edge OBJECT_CREATION_TYPE 0 app.Pipeline#run(String) [EXPRESSION] -> app.lib.build.LibStep
2 changes: 1 addition & 1 deletion graph/test/java/expected/oracle-agreement.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
cases compared 48 agreeing 48
cases compared 49 agreeing 49
CONSTRUCTOR-RULE disagreements: 0 cases, 0 rows
OTHER disagreements: 0
rows naming no method: 0
Expand Down
4 changes: 3 additions & 1 deletion parser/src/constants/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import * as path from 'path';
export const HASH_ALGO = 'md5';

/**
* Directories to exclude when scanning projects or source files
* Directories to exclude when scanning projects or source files.
* The Java source walk does not skip `build` by this name: only a Gradle project's output
* (utils/generated-output.ts isGradleBuildOutput), since `build` is also a Java package.
*/
export const EXCLUDED_DIRS = new Set([
'node_modules',
Expand Down
22 changes: 22 additions & 0 deletions parser/src/utils/generated-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,25 @@ export function isGeneratedOutputDirectory(parent: string, name: string): boolea
|| (fs.existsSync(path.join(directory, 'navigation.html'))
&& fs.existsSync(path.join(directory, 'scripts', 'sourceset_dependencies.js')));
}

/**
* The files that make a directory the root of a Gradle project or module. Gradle writes that
* project's output (compiled classes, processed resources, and generated sources such as
* `build/generated/sources/annotationProcessor/java/main`) into a `build` directory beside them.
*/
export const GRADLE_BUILD_FILES = ['build.gradle', 'build.gradle.kts', 'settings.gradle', 'settings.gradle.kts'] as const;

/**
* Is `parent/name` a Gradle project's output directory, whose `.java` files a build wrote?
*
* Anchored on its owner, the way `target` is anchored on a `pom.xml` above: a `build` beside a
* Gradle build or settings script. `build` is also an ordinary Java package name (builders, build
* steps: `src/main/java/app/build/StepBuilder.java`), and pruning every directory of that name
* dropped such a package from the graph with no skipped-file row to say so. A `build` inside a
* source tree or a package path has no build script beside it, so it is walked.
* Used by the Java source walk (java-project-analyzer.ts); the refresher's file table
* (ax_fresh.py `gradle_output`) applies the same rule, so freshness and the language count agree.
*/
export function isGradleBuildOutput(parent: string, name: string): boolean {
return name === 'build' && GRADLE_BUILD_FILES.some((file) => fs.existsSync(path.join(parent, file)));
}
8 changes: 7 additions & 1 deletion parser/src/workflows/java/java-project-analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { TypeAnnotation } from '@/analysis-types/java/TypeAnnotation';
import { TypeParameter } from '@/analysis-types/java/TypeParameter';
import { TypeReference } from '@/analysis-types/java/TypeReference';
import { TypeRegistry } from '@/analysis-types/java/TypeRegistry';
import { isGradleBuildOutput } from '@/utils/generated-output';
import { EXCLUDED_DIRS, isJavaTestDir, ANALYSIS_OUTPUT_DIR, OUTPUT_TYPE_REGISTRY_CSV_FILENAME, OUTPUT_TYPE_PARAMETER_CSV_FILENAME, OUTPUT_TYPE_REFERENCE_CSV_FILENAME, OUTPUT_TYPE_ANNOTATION_CSV_FILENAME, OUTPUT_ANNOTATION_ARGUMENT_CSV_FILENAME, OUTPUT_METHOD_REGISTRY_CSV_FILENAME, OUTPUT_METHOD_PARAMETER_CSV_FILENAME, OUTPUT_METHOD_TYPE_PARAMETER_CSV_FILENAME, OUTPUT_ENUM_CONSTANT_CSV_FILENAME, OUTPUT_FIELD_REGISTRY_CSV_FILENAME, OUTPUT_FIELD_POSITION_CSV_FILENAME, OUTPUT_IMPORT_REGISTRY_CSV_FILENAME, OUTPUT_MODULE_REGISTRY_CSV_FILENAME, OUTPUT_MODULE_DIRECTIVE_CSV_FILENAME, OUTPUT_EXPRESSION_REFERENCE_CSV_FILENAME, OUTPUT_LOCAL_VARIABLE_REGISTRY_CSV_FILENAME, OUTPUT_BLOCK_REGISTRY_CSV_FILENAME, OUTPUT_COMMENT_REGISTRY_CSV_FILENAME, OUTPUT_SKIPPED_JAVA_FILES_CSV_FILENAME, JAVA_ENTITY_TYPES, FILE_EXTENSIONS, LARGE_FILE_LINE_THRESHOLD } from '@/constants/consts';
import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants';
import { SkippedFileReason } from '@/enums/SkippedFileReason';
Expand Down Expand Up @@ -321,7 +322,12 @@ export class JavaProjectAnalyzer {
for (const entry of entries) {
if (entry.isDirectory()) {
const isTestDir = isJavaTestDir(entry.name);
if (!EXCLUDED_DIRS.has(entry.name) && !entry.name.startsWith('.') && !(excludeTests && isTestDir)
// `build` is skipped only as a Gradle project's output, never by name: it is also a
// Java package name (see isGradleBuildOutput).
const excluded = entry.name === 'build'
? isGradleBuildOutput(dirPath, entry.name)
: EXCLUDED_DIRS.has(entry.name);
if (!excluded && !entry.name.startsWith('.') && !(excludeTests && isTestDir)
&& !isGitIgnoredDir(path.join(dirPath, entry.name))) {
const subPath = path.join(dirPath, entry.name);
await this.scanForJavaFiles(subPath, files, excludeTests);
Expand Down
199 changes: 199 additions & 0 deletions plugins/axiomcode/hooks/_where.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
"""_where.py: which repository's graph a hook event is about: the one above the file or directory the tool touched.

The hooks used to look for the graph in the session's working directory only. Agents rarely work there: in 309 measured
sessions, 308 had a working directory with no graph and read, grepped and edited files in indexed worktrees, fixtures
and project copies by absolute path. 190 agents made 2,115 graph calls between them and the hooks spoke 3 times, and
never once on a Read or a Grep. So each hook asks here instead:

touched(tool, inp, cwd) the paths the tool acts on: Read / Edit / Write file_path, Grep / Glob path, the graph's
own tools' repo, and for Bash the last `cd` and the path arguments that exist
root_of(path) the nearest directory at or above `path` holding .axiomcode/out/graph.sqlite, or a
per-language graph under .axiomcode/lang/<lang>/out/; None when there is none. It walks
up from the REAL path: a symlink into another tree finds that tree's graph (whose
relative paths are the ones the file has), never the graph of the directory the link
sits in
locate(tool, inp, cwd) the repository root for this event. A tool that names a path is answered from that path
alone: no graph above it is silence, even when the working directory has one (a search
of another tree says nothing about this graph). A tool that names none falls back to
the working directory, walked up the same way
graph_db(root, file) the graph a file's language is answered from: .axiomcode/lang/<lang>/out/graph.sqlite
when the repository has one for that extension, else the main graph
remember / recent the repositories touched this session, most recent first, for the events that name no
path (a prompt, the end of a turn)

The walk is cached per directory for the session, in the temp directory (a hook is a new process each time). A found
root is kept while its graph exists. "No graph here" is NOT kept: an agent that runs `axiomcode index` and then reads
must be answered on that read, and the uncached walk that says so is a stat per directory level (under 1 ms). Every function here returns a safe empty answer instead of raising: a hook never fails a tool call."""
import json, os, re, shlex, time

BY_EXT = {'.py': ('python',), '.pyi': ('python',), '.java': ('java',), '.cs': ('csharp',),
'.ts': ('typescript',), '.tsx': ('typescript',), '.mts': ('typescript',), '.cts': ('typescript',),
'.js': ('javascript', 'typescript'), '.jsx': ('javascript', 'typescript'),
'.mjs': ('javascript', 'typescript'), '.cjs': ('javascript', 'typescript')}

_session = 'x'
_cache = None


def _cache_path():
sid = re.sub(r'[^\w.-]', '_', str(_session))[:80] or 'x'
# not tempfile.gettempdir(): importing tempfile costs ~15 ms, on a hook that runs before and after every tool call
tmp = os.environ.get('TMPDIR') or os.environ.get('TEMP') or os.environ.get('TMP') or ('/tmp' if os.name != 'nt' else os.path.expanduser('~'))
return os.path.join(tmp, 'axiomcode-hooks', f'where-{sid}.json')


def _load():
global _cache
if _cache is None:
try: _cache = json.load(open(_cache_path()))
except Exception: _cache = {}
_cache.setdefault('dirs', {}); _cache.setdefault('repos', [])
return _cache


def _save():
if _cache is None: return
try:
p = _cache_path(); os.makedirs(os.path.dirname(p), exist_ok=True)
if len(_cache['dirs']) > 400: _cache['dirs'] = dict(list(_cache['dirs'].items())[-200:])
tmp = f'{p}.{os.getpid()}'
with open(tmp, 'w') as f: json.dump(_cache, f)
os.replace(tmp, p) # hooks run in parallel: a whole file or the old one
except Exception: pass


def session(sid):
"""the session whose cache this process reads (call once, with the event's session_id)"""
global _session, _cache
if sid and sid != _session: _session, _cache = sid, None


def has_graph(d):
"""d holds a graph: the main one, or any language's under .axiomcode/lang/"""
ax = os.path.join(d, '.axiomcode')
if not os.path.isdir(ax): return False
if os.path.isfile(os.path.join(ax, 'out', 'graph.sqlite')): return True
try: return any(os.path.isfile(os.path.join(ax, 'lang', l, 'out', 'graph.sqlite')) for l in os.listdir(os.path.join(ax, 'lang')))
except OSError: return False


def root_of(path):
"""the nearest directory at or above the REAL `path` holding a graph, or None"""
try:
cur = os.path.realpath(os.path.expanduser(str(path)))
if not os.path.isdir(cur): cur = os.path.dirname(cur)
except Exception: return None
c = _load(); now = time.time(); walked = []; found = None
while True:
hit = c['dirs'].get(cur)
if hit and hit[0] and has_graph(hit[0]): found = hit[0]; break
walked.append(cur)
if has_graph(cur): found = cur; break
up = os.path.dirname(cur)
if up == cur: break
cur = up
if found and walked:
for d in walked: c['dirs'][d] = [found, now]
_save()
return found


def _abs(p, base):
p = os.path.expanduser(str(p))
return p if os.path.isabs(p) else os.path.join(base, p)


def bash_where(cmd, cwd):
"""(base, paths) for a shell command: `base` is where it runs (the last `cd`, else cwd); `paths` are its arguments
that name an existing file or directory, resolved against `base`"""
cmd = str(cmd or '')
base = cwd
for d in re.findall(r'(?:^|[;&|(]\s*)cd\s+([^\s;&|)]+)', cmd):
d = d.strip('\'"')
if d and d != '-': base = _abs(d, base)
try: toks = shlex.split(cmd, comments=False)
except ValueError: toks = cmd.split()
paths = []
for i, t in enumerate(toks):
if not t or t.startswith('-') or (i and toks[i - 1] == 'cd') or t in ('cd', '|', '&&', ';', '||'): continue
if not ('/' in t or t.startswith(('~', '.')) or re.search(r'\w\.\w+$', t)): continue # a path, or a file name
t = t.rstrip(';&|')
try:
p = _abs(t, base)
if os.path.exists(p): paths.append(p)
except Exception: continue
if len(paths) >= 8: break
return base, paths


def touched(tool, inp, cwd):
"""the paths this tool call acts on; [] when it names none"""
inp = inp or {}
if tool in ('Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit'):
p = inp.get('file_path') or inp.get('notebook_path')
return [_abs(p, cwd)] if p else []
if tool in ('Grep', 'Glob'):
return [_abs(inp['path'], cwd)] if inp.get('path') else []
if tool == 'Bash':
base, paths = bash_where(inp.get('command'), cwd)
return paths or ([base] if base != cwd else [])
if str(tool).startswith('mcp__plugin_axiomcode_'):
r = inp.get('repo')
return [_abs(r, cwd)] if r and r != '.' else []
return []


def locate(tool, inp, cwd, sid=None):
"""the repository root this event is about, or None: the graph above the first touched path that has one; with no
touched path, the graph at or above the working directory"""
session(sid)
ps = touched(tool, inp, cwd)
if ps:
for p in ps:
r = root_of(p)
if r: remember(r); return r
return None
r = root_of(cwd)
if r: remember(r)
return r


def remember(root):
c = _load()
if c['repos'][:1] != [root]:
c['repos'] = [root] + [x for x in c['repos'] if x != root][:7]; _save()


def recent(sid=None):
"""the repositories this session touched, most recent first, that still have a graph"""
session(sid)
return [r for r in _load()['repos'] if has_graph(r)]


def graph_dir(root, file=None):
"""the directory holding <dir>/out/graph.sqlite for `file`'s language: .axiomcode/lang/<lang> when the repository
has that language's graph and it is not the main one, else .axiomcode; the first language graph when there is no
main graph"""
ax = os.path.join(root, '.axiomcode')
main = os.path.isfile(os.path.join(ax, 'out', 'graph.sqlite'))
for l in BY_EXT.get(os.path.splitext(str(file or ''))[1].lower(), ()):
d = os.path.join(ax, 'lang', l)
if os.path.isfile(os.path.join(d, 'out', 'graph.sqlite')): return d
if main: return ax
try:
for l in sorted(os.listdir(os.path.join(ax, 'lang'))):
if os.path.isfile(os.path.join(ax, 'lang', l, 'out', 'graph.sqlite')): return os.path.join(ax, 'lang', l)
except OSError: pass
return ax


def graph_db(root, file=None):
return os.path.join(graph_dir(root, file), 'out', 'graph.sqlite')


def lang_env(root, file=None):
"""the environment the verbs read a language's graph from (as ax_langs sets it); {} for the main graph"""
d = graph_dir(root, file)
if os.path.dirname(d).endswith(os.path.join('.axiomcode', 'lang')):
return {'AXIOMCODE_GRAPH': d, 'AXIOMCODE_GRAPH_LANG': os.path.basename(d)}
return {}
Loading
Loading