diff --git a/.gitignore b/.gitignore index 58205348b..8568550de 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/graph/test/java/cases/64-gradle-build-output-is-not-source/src/build.gradle b/graph/test/java/cases/64-gradle-build-output-is-not-source/src/build.gradle new file mode 100644 index 000000000..3a0ee1153 --- /dev/null +++ b/graph/test/java/cases/64-gradle-build-output-is-not-source/src/build.gradle @@ -0,0 +1,7 @@ +plugins { + id 'java' +} + +dependencies { + implementation project(':lib') +} diff --git a/graph/test/java/cases/64-gradle-build-output-is-not-source/src/build/generated/sources/annotationProcessor/java/main/app/gen/GeneratedStage.java b/graph/test/java/cases/64-gradle-build-output-is-not-source/src/build/generated/sources/annotationProcessor/java/main/app/gen/GeneratedStage.java new file mode 100644 index 000000000..f58ff966b --- /dev/null +++ b/graph/test/java/cases/64-gradle-build-output-is-not-source/src/build/generated/sources/annotationProcessor/java/main/app/gen/GeneratedStage.java @@ -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(); + } +} diff --git a/graph/test/java/cases/64-gradle-build-output-is-not-source/src/lib/build.gradle.kts b/graph/test/java/cases/64-gradle-build-output-is-not-source/src/lib/build.gradle.kts new file mode 100644 index 000000000..f6727627a --- /dev/null +++ b/graph/test/java/cases/64-gradle-build-output-is-not-source/src/lib/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + `java-library` +} diff --git a/graph/test/java/cases/64-gradle-build-output-is-not-source/src/lib/build/generated/sources/annotationProcessor/java/main/app/gen/GeneratedLibStep.java b/graph/test/java/cases/64-gradle-build-output-is-not-source/src/lib/build/generated/sources/annotationProcessor/java/main/app/gen/GeneratedLibStep.java new file mode 100644 index 000000000..280d007ef --- /dev/null +++ b/graph/test/java/cases/64-gradle-build-output-is-not-source/src/lib/build/generated/sources/annotationProcessor/java/main/app/gen/GeneratedLibStep.java @@ -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(); + } +} diff --git a/graph/test/java/cases/64-gradle-build-output-is-not-source/src/lib/src/main/java/app/lib/build/LibStep.java b/graph/test/java/cases/64-gradle-build-output-is-not-source/src/lib/src/main/java/app/lib/build/LibStep.java new file mode 100644 index 000000000..352c08b93 --- /dev/null +++ b/graph/test/java/cases/64-gradle-build-output-is-not-source/src/lib/src/main/java/app/lib/build/LibStep.java @@ -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"; + } +} diff --git a/graph/test/java/cases/64-gradle-build-output-is-not-source/src/settings.gradle b/graph/test/java/cases/64-gradle-build-output-is-not-source/src/settings.gradle new file mode 100644 index 000000000..e325b7ebc --- /dev/null +++ b/graph/test/java/cases/64-gradle-build-output-is-not-source/src/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'app' +include 'lib' diff --git a/graph/test/java/cases/64-gradle-build-output-is-not-source/src/src/main/java/app/Pipeline.java b/graph/test/java/cases/64-gradle-build-output-is-not-source/src/src/main/java/app/Pipeline.java new file mode 100644 index 000000000..9b2364cfd --- /dev/null +++ b/graph/test/java/cases/64-gradle-build-output-is-not-source/src/src/main/java/app/Pipeline.java @@ -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); + } +} diff --git a/graph/test/java/cases/64-gradle-build-output-is-not-source/src/src/main/java/app/build/StageBuilder.java b/graph/test/java/cases/64-gradle-build-output-is-not-source/src/src/main/java/app/build/StageBuilder.java new file mode 100644 index 000000000..7ac14a55e --- /dev/null +++ b/graph/test/java/cases/64-gradle-build-output-is-not-source/src/src/main/java/app/build/StageBuilder.java @@ -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"; + } +} diff --git a/graph/test/java/expected/62-build-output-is-not-source.edges b/graph/test/java/expected/62-build-output-is-not-source.edges index 9768acfde..1779c8a8c 100644 --- a/graph/test/java/expected/62-build-output-is-not-source.edges +++ b/graph/test/java/expected/62-build-output-is-not-source.edges @@ -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#() known_edge new app.WidgetValidator#check(String) -> app.site.PageRenderer#() diff --git a/graph/test/java/expected/62-build-output-is-not-source.oracle b/graph/test/java/expected/62-build-output-is-not-source.oracle new file mode 100644 index 000000000..518b0e5c3 --- /dev/null +++ b/graph/test/java/expected/62-build-output-is-not-source.oracle @@ -0,0 +1 @@ +oracle=4 engine=4 agree=4 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/java/expected/62-build-output-is-not-source.type-use b/graph/test/java/expected/62-build-output-is-not-source.type-use index 6661dc348..237028423 100644 --- a/graph/test/java/expected/62-build-output-is-not-source.type-use +++ b/graph/test/java/expected/62-build-output-is-not-source.type-use @@ -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 diff --git a/graph/test/java/expected/63-javadoc-root-is-not-a-project.oracle b/graph/test/java/expected/63-javadoc-root-is-not-a-project.oracle new file mode 100644 index 000000000..97a1dbf27 --- /dev/null +++ b/graph/test/java/expected/63-javadoc-root-is-not-a-project.oracle @@ -0,0 +1 @@ +oracle=1 engine=1 agree=1 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/java/expected/64-gradle-build-output-is-not-source.edges b/graph/test/java/expected/64-gradle-build-output-is-not-source.edges new file mode 100644 index 000000000..7065eed6f --- /dev/null +++ b/graph/test/java/expected/64-gradle-build-output-is-not-source.edges @@ -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#() +known_edge new app.Pipeline#run(String) -> app.lib.build.LibStep#() diff --git a/graph/test/java/expected/64-gradle-build-output-is-not-source.oracle b/graph/test/java/expected/64-gradle-build-output-is-not-source.oracle new file mode 100644 index 000000000..518b0e5c3 --- /dev/null +++ b/graph/test/java/expected/64-gradle-build-output-is-not-source.oracle @@ -0,0 +1 @@ +oracle=4 engine=4 agree=4 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/java/expected/64-gradle-build-output-is-not-source.type-use b/graph/test/java/expected/64-gradle-build-output-is-not-source.type-use new file mode 100644 index 000000000..f7c8f9094 --- /dev/null +++ b/graph/test/java/expected/64-gradle-build-output-is-not-source.type-use @@ -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 diff --git a/graph/test/java/expected/oracle-agreement.txt b/graph/test/java/expected/oracle-agreement.txt index bad554eae..4bb656bac 100644 --- a/graph/test/java/expected/oracle-agreement.txt +++ b/graph/test/java/expected/oracle-agreement.txt @@ -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 diff --git a/parser/src/constants/consts.ts b/parser/src/constants/consts.ts index 5142f59a0..000999d09 100644 --- a/parser/src/constants/consts.ts +++ b/parser/src/constants/consts.ts @@ -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', diff --git a/parser/src/utils/generated-output.ts b/parser/src/utils/generated-output.ts index 3f2176c2d..02a169304 100644 --- a/parser/src/utils/generated-output.ts +++ b/parser/src/utils/generated-output.ts @@ -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))); +} diff --git a/parser/src/workflows/java/java-project-analyzer.ts b/parser/src/workflows/java/java-project-analyzer.ts index 118e4c82e..625e8880a 100644 --- a/parser/src/workflows/java/java-project-analyzer.ts +++ b/parser/src/workflows/java/java-project-analyzer.ts @@ -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'; @@ -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); diff --git a/plugins/axiomcode/hooks/_where.py b/plugins/axiomcode/hooks/_where.py new file mode 100644 index 000000000..dc71e291c --- /dev/null +++ b/plugins/axiomcode/hooks/_where.py @@ -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//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//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 /out/graph.sqlite for `file`'s language: .axiomcode/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 {} diff --git a/plugins/axiomcode/hooks/changes.py b/plugins/axiomcode/hooks/changes.py index e84b06a97..3bfa63e8a 100644 --- a/plugins/axiomcode/hooks/changes.py +++ b/plugins/axiomcode/hooks/changes.py @@ -17,10 +17,16 @@ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts')) import graph_sql sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import _host, _graphline +import _host, _graphline, _where -ev = _host.read(); event = ev.get('hook_event_name', ''); tool = ev.get('tool_name', ''); inp = ev.get('tool_input', {}) or {}; cwd = ev.get('cwd') or os.getcwd() -if not os.path.exists(os.path.join(cwd, '.axiomcode', 'out', 'graph.sqlite')): sys.exit(0) +ev = _host.read(); event = ev.get('hook_event_name', ''); tool = ev.get('tool_name', ''); inp = ev.get('tool_input', {}) or {}; scwd = ev.get('cwd') or os.getcwd() +# the repository the edit or command is in, found from the file or directory it touched (_where.py); a prompt names +# no path, so it is the working directory's graph, else the one this session last worked in +cwd = _where.locate(tool if event != 'UserPromptSubmit' else '', inp, scwd, ev.get('session_id')) \ + or (next(iter(_where.recent()), None) if event == 'UserPromptSubmit' else None) +if not cwd or not os.path.exists(os.path.join(cwd, '.axiomcode', 'out', 'graph.sqlite')): sys.exit(0) +if tool in ('Edit', 'Write', 'MultiEdit') and inp.get('file_path'): + os.environ.update(_where.lang_env(cwd, inp['file_path'])) SCR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts') SRC = re.compile(r'\.(java|ts|tsx|js|mjs|cjs|py)$'); TEST = re.compile(r'(^|/)(tests?|__tests__)/|/src/test/|Tests?\.java$|\.(spec|test)\.[jt]sx?$|(^|/)test_') STATE = os.path.join(cwd, '.axiomcode', f"hooks-state-{ev.get('session_id', 'x')}.json") @@ -119,7 +125,7 @@ def key(d): return f"{d['file']}:{d['symbol']}:{d['kind']}:{d.get('detail', '')} lines = [] if event == 'PreToolUse' and tool in ('Edit', 'Write', 'MultiEdit'): - fp = str(inp.get('file_path', '')); rel = rel_of(fp) + fp = _where._abs(inp.get('file_path', ''), scwd); rel = rel_of(fp) if not SRC.search(rel) or TEST.search(rel) or not os.path.exists(fp): sys.exit(0) cur = open(fp, errors='replace').read(); new = cur if tool == 'Write': new = str(inp.get('content', '')) diff --git a/plugins/axiomcode/hooks/direct.py b/plugins/axiomcode/hooks/direct.py index 9d8b1dc65..c9aea74a3 100755 --- a/plugins/axiomcode/hooks/direct.py +++ b/plugins/axiomcode/hooks/direct.py @@ -24,7 +24,7 @@ """ import json, os, re, sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import _host +import _host, _where MARK = '.axiomcode/.directed-{}' # once per session: keyed by session id, not once per repo forever SRC = ('.java', '.ts', '.tsx', '.py', '.js', '.jsx', '.mjs', '.cjs', '.cs') @@ -50,9 +50,10 @@ def main(): ev = _host.read() tool = ev.get('tool_name') or '' inp = ev.get('tool_input') or {} - cwd = ev.get('cwd') or os.getcwd() - - if not os.path.exists(os.path.join(cwd, '.axiomcode', 'out', 'graph.sqlite')): + # the repository whose graph would answer: the one above the file or directory this tool is about to touch, not + # the session's working directory, which in 308 of 309 measured sessions held no graph (_where.py) + cwd = _where.locate(tool, inp, ev.get('cwd') or os.getcwd(), ev.get('session_id')) + if not cwd: sys.exit(0) stamp = os.path.join(cwd, MARK.format(ev.get('session_id') or 'x')) @@ -75,6 +76,14 @@ def main(): if tool == 'Bash' and not SEARCH.search(inp.get('command') or ''): sys.exit(0) + # the same rule as a Read: a shell read or search whose every file is not source (`cat NOTES.md`, `tail run.log`) + # is not the decision this is about. Of the 3 times this hook spoke in 190 measured agents, one was a `cat` of a + # Markdown file. A search with no file argument (a directory, or none) still searches source. + if tool == 'Bash': + _, paths = _where.bash_where(inp.get('command'), ev.get('cwd') or os.getcwd()) + files = [p for p in paths if os.path.isfile(p)] + if files and not any(p.endswith(SRC) for p in files): + sys.exit(0) if os.path.exists(stamp): sys.exit(0) diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index 5f8597af8..75edab875 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -13,9 +13,17 @@ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts')) import graph_sql, ax_contract as _ax sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import _host, _graphline +import _host, _graphline, _where -ev = _host.read(); tool = ev.get('tool_name', ''); inp = ev.get('tool_input', {}) or {}; cwd = ev.get('cwd') or os.getcwd() +ev = _host.read(); tool = ev.get('tool_name', ''); inp = ev.get('tool_input', {}) or {}; scwd = ev.get('cwd') or os.getcwd() +# THE GRAPH IS FOUND FROM WHAT THE TOOL TOUCHED, not from the session's working directory: an agent reads and greps +# indexed trees by absolute path from a directory that has no graph (308 of 309 measured sessions), and a hook that +# looked only in the working directory never spoke. `cwd` below is the repository ROOT whose graph answers; `scwd` is +# where the session is, which a relative path in the tool's input is relative to. +cwd = _where.locate(tool, inp, scwd, ev.get('session_id')) +if not cwd: sys.exit(0) +_file = next((p for p in _where.touched(tool, inp, scwd) if os.path.isfile(p)), None) +os.environ.update(_where.lang_env(cwd, _file)) # a file in a language with its own graph is answered from it def rel_of(fp): """the graph stores repo-relative paths; the tool's file_path may reach the tree through a symlink while cwd is resolved (or the reverse) — compare real paths, and if the file still is not under the tree, fall back to the graph's own suffix match""" @@ -25,7 +33,7 @@ def rel_of(fp): except ValueError: continue # Windows: a file on another drive is not under the tree if not r.startswith('..'): return r.replace(os.sep, '/') # the index stores '/' on every platform return fp -db = os.path.join(cwd, '.axiomcode', 'out', 'graph.sqlite') +db = _where.graph_db(cwd, _file) if not os.path.exists(db): sys.exit(0) con = sqlite3.connect(db); con.row_factory = sqlite3.Row q = lambda s, *p: con.execute(s, p).fetchall() @@ -88,15 +96,14 @@ def save_state(st): # The tree searched is where the shell is (a `cd` before the grep) and the paths given after the pattern. if m: import shlex + base, _ = _where.bash_where(c[:m.start()], scwd) # where the shell is when the grep runs (a `cd` first) def outside(p): - p = os.path.expanduser(p) - r = os.path.relpath(os.path.realpath(p if os.path.isabs(p) else os.path.join(cwd, p)), os.path.realpath(cwd)) + r = os.path.relpath(os.path.realpath(_where._abs(p, base)), os.path.realpath(cwd)) return r == '..' or r.startswith('..' + os.sep) - cds = re.findall(r'(?:^|[;&|]\s*)cd\s+([^\s;&|]+)', c[:m.start()]) try: rest = shlex.split(c[m.end():].split('|')[0].split('&&')[0].split(';')[0]) except ValueError: rest = [] where = [a for a in rest if not a.startswith('-') and ('/' in a or a.startswith(('~', '.')))] - if (cds and outside(cds[-1])) or any(outside(a) for a in where): sys.exit(0) + if outside(base) or any(outside(a) for a in where): sys.exit(0) # A GREP THAT IS NOT A CODE SEARCH SAYS NOTHING ABOUT THIS GRAPH (#1604). After a `|` it filters another command's # output (`npm test | grep -E 'FAIL|ok'` named `Result.fail` and a test helper `fail`); over files no graph # indexes (a log, a shell script, YAML) it names whatever shares a word with the pattern. @@ -106,10 +113,13 @@ def outside(p): if files and all(os.path.splitext(a)[1] and os.path.splitext(a)[1].lower() not in _graphline.LANG for a in files): sys.exit(0) if m: tool = 'Grep'; inp = {'pattern': m.group(3)}; _from_shell = True else: - m = re.search(r"sed -n '?(\d+),(\d+)p'? (\S+)", c) or re.search(r'\bcat\s+(\S+\.(?:java|ts|tsx|js|py))', c) - if m and m.re.groups == 3: tool = 'Read'; inp = {'file_path': os.path.join(cwd, m.group(3)) if not m.group(3).startswith('/') else m.group(3), 'offset': int(m.group(1)), 'limit': int(m.group(2)) - int(m.group(1)) + 1} - elif m: tool = 'Read'; inp = {'file_path': os.path.join(cwd, m.group(1)) if not m.group(1).startswith('/') else m.group(1)} + m = re.search(r"sed -n '?(\d+),(\d+)p'? (\S+)", c) or re.search(r'\bcat\s+(\S+\.(?:java|ts|tsx|js|py|cs))\b', c) + # a relative file is relative to where the shell is when it runs: the session's directory, or a `cd` before it + if m: base, _ = _where.bash_where(c[:m.start()], scwd) + if m and m.re.groups == 3: tool = 'Read'; inp = {'file_path': _where._abs(m.group(3), base), 'offset': int(m.group(1)), 'limit': int(m.group(2)) - int(m.group(1)) + 1} + elif m: tool = 'Read'; inp = {'file_path': _where._abs(m.group(1), base)} else: sys.exit(0) + if _where.root_of(inp['file_path']) != cwd: sys.exit(0) # the file read is another tree's, or none's # names are looked up by prefix on every grep: an index on symbols.name keeps that under 0.1 s (created once, harmless if present) try: con.execute("CREATE INDEX IF NOT EXISTS symbols_name ON symbols(name)"); con.commit() @@ -149,7 +159,7 @@ def context_ids(): # file against the commit the graph was built from — then the blast radius of each from `axiomcode impact`: what must change # with it, who produces or writes it, who reads it, what reaches those, the tests. The moment this is useful is now. import concurrent.futures - fp = str(inp.get('file_path', '')); rel = rel_of(fp) + fp = _where._abs(inp.get('file_path', ''), scwd); rel = rel_of(fp) if not re.search(r'\.(java|ts|tsx|js|mjs|cjs|py)$', rel) or re.search(r'(^|/)(tests?|__tests__)/|/src/test/|Tests?\.java$|\.(spec|test)\.[jt]sx?$|(^|/)test_', rel): sys.exit(0) SCR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts') try: ch = json.loads(subprocess.run([sys.executable, os.path.join(SCR, 'axiomcode-changed'), cwd, rel, '--json'], capture_output=True, text=True, timeout=10).stdout or '{}') @@ -223,7 +233,7 @@ def names(xs, k=4): return ', '.join(f"{x['display']} {x['at'].split('/')[-1]}" lines.append(_graphline.body_line(os.path.join(os.environ.get('AXIOMCODE_GRAPH') or os.path.join(cwd, '.axiomcode'), 'out', 'graph.sqlite'), bodies + [(d, {}) for d in body[3:]])) elif tool == 'Read': - fp = str(inp.get('file_path', '')); rel = rel_of(fp) + fp = _where._abs(inp.get('file_path', ''), scwd); rel = rel_of(fp) a = int(inp.get('offset') or 1); b = a + int(inp.get('limit') or 100000) # a member the language synthesises (an enum's values() / valueOf(), a default constructor) is not declared on any line: not listed as one rows = q("SELECT s.id, s.method_id, s.display, s.line, s.end_line FROM symbols s JOIN methods m ON m.id = s.method_id WHERE (s.file = ? OR s.file LIKE ?) AND s.method_id IS NOT NULL AND s.kind <> 'module' AND m.kind NOT IN ('ENUM_VALUES', 'ENUM_VALUE_OF', 'DEFAULT_CONSTRUCTOR') AND s.line <= ? AND s.end_line >= ? ORDER BY s.line", rel, '%/' + rel.lstrip('/'), b, a) @@ -320,7 +330,7 @@ def line(x): for x in shown: lines.append(line(x)) left = [x for x in info if x not in shown] if rows else [] if left: lines.append(" " + ('+%d more: ' % len(left)) + ', '.join(f"{short(x['r']['display'])} ←{ups(x)}" + (f" →{len(x['dn'])}" if x['dn'] and not x['up'] else '') + (f" ?{x['un']}" if x['un'] else '') for x in sorted(left, key=lambda x: (-len(x.get('hits') or ()), -(len(x['up']) + x['un'])))[:6]) + (' …' if len(left) > 6 else '') + " (grep Type.name or axiomcode path to narrow)") -elif tool == 'Grep' and not (inp.get('path') and os.path.relpath(os.path.realpath(os.path.join(cwd, os.path.expanduser(str(inp['path'])))), os.path.realpath(cwd)).split(os.sep)[0] == '..'): +elif tool == 'Grep' and not (inp.get('path') and os.path.relpath(os.path.realpath(_where._abs(inp['path'], scwd)), os.path.realpath(cwd)).split(os.sep)[0] == '..'): # (a Grep of a path outside this tree is about another codebase — nothing here to add) # a real search is rarely one identifier: `hasNext\(\)|\.next\(\)|close\(\)`, `getScanner|RTBoundValidator|withSSTablesIterated`. # Split the alternation, strip the regex around each branch, keep the identifiers, look each one up — in parallel, one diff --git a/plugins/axiomcode/hooks/orient.py b/plugins/axiomcode/hooks/orient.py index a3241ff06..e78d27ec0 100755 --- a/plugins/axiomcode/hooks/orient.py +++ b/plugins/axiomcode/hooks/orient.py @@ -19,7 +19,7 @@ """ import json, os, re, subprocess, sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import _host +import _host, _where MARK = '.axiomcode/.oriented-{}' # once per session: keyed by session id (one stamp per repo never fired again) MAX_LINES = 14 @@ -56,8 +56,22 @@ def repo_root(start): return os.path.realpath(start) -cwd = repo_root(ev.get('cwd') or os.getcwd()) prompt = (ev.get('prompt') or '').strip() + +def prompt_root(prompt): + """the graph above a path the prompt names. A task given from a directory with no graph ("fix X in + /work/app/src/Y.java", "the repo is /work/app") is about that tree, and the working directory says nothing about it.""" + for m in re.finditer(r'(?:^|[\s\'"`(=])((?:/|~/)[^\s\'"`),;]+)', prompt): + p = os.path.expanduser(m.group(1).rstrip('.:')) + p = re.sub(r':\d+(?::\d+)?$', '', p) # file:line + if os.path.exists(p): + r = _where.root_of(p) + if r: + return r + return None + +_where.session(ev.get('session_id')) +cwd = prompt_root(prompt) or repo_root(ev.get('cwd') or os.getcwd()) if len(prompt) < 25: # too short to carry a task sys.exit(0) # a harness event delivered as a prompt (a background task finishing, a monitor line) is not the task, and @@ -74,8 +88,8 @@ def repo_root(start): # It is the only moment where saying nothing guarantees the skill is never used, so it says one thing and # takes the same once-per-repo stamp. Still bounded, still never repeated, and still silent where it would # be noise: a tree with no source in a supported language has nothing to offer and says nothing. - EXT = ('.java', '.ts', '.tsx', '.py', '.js', '.jsx', '.mjs', '.cjs') - SKIP = {'node_modules', '.git', 'dist', 'build', 'target', 'venv', '.venv', '__pycache__'} + EXT = ('.java', '.ts', '.tsx', '.py', '.js', '.jsx', '.mjs', '.cjs', '.cs') + SKIP = {'node_modules', '.git', 'dist', 'build', 'target', 'venv', '.venv', '__pycache__', 'obj'} found = 0 for root, dirs, files in os.walk(cwd): dirs[:] = [d for d in dirs if d not in SKIP and not d.startswith('.')] @@ -84,6 +98,14 @@ def repo_root(start): break if found < 25: sys.exit(0) + # a directory ABOVE an indexed tree (a workspace holding the project, its worktrees, its copies) is not a repository + # without a graph: saying so would send the agent to rebuild what it already has. Two levels down is where they sit. + def _below(d, depth): + try: subs = [e.path for e in os.scandir(d) if e.is_dir() and e.name not in SKIP and not e.name.startswith('.')] + except OSError: return False + return any(_where.has_graph(x) or (depth > 1 and _below(x, depth - 1)) for x in subs) + if _below(cwd, 2): + sys.exit(0) try: os.makedirs(os.path.dirname(stamp), exist_ok=True) open(stamp, 'w').write('1') diff --git a/plugins/axiomcode/hooks/refresh.py b/plugins/axiomcode/hooks/refresh.py index e821792dd..80de47e97 100644 --- a/plugins/axiomcode/hooks/refresh.py +++ b/plugins/axiomcode/hooks/refresh.py @@ -10,21 +10,29 @@ import os, sys sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts')) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import _host +import _host, _where try: ev = _host.read() except Exception: sys.exit(0) cwd = ev.get('cwd') or os.getcwd() -# the repository whose graph this is: the working directory or the nearest parent holding a graph -d = os.path.realpath(cwd) -while not os.path.exists(os.path.join(d, '.axiomcode', 'out', 'graph.sqlite')): - up = os.path.dirname(d) - if up == d: sys.exit(0) - d = up +# the repository whose graph this is: the one above the file or directory the tool touched (an edit by absolute path +# from a directory with no graph is still an edit to that repository), else the working directory or its nearest parent +# holding a graph. An event that names no path (a prompt, the end of a turn, a session start) also refreshes the +# repositories this session has worked in, the most recent two. +try: + d = _where.locate(ev.get('tool_name') or '', ev.get('tool_input') or {}, cwd, ev.get('session_id')) + repos = [d] if d else [] + if not ev.get('tool_name'): + repos += [r for r in _where.recent() if r not in repos][:2] +except Exception: + repos = [] +repos = [r for r in repos if os.path.exists(os.path.join(r, '.axiomcode', 'out', 'graph.sqlite'))] +if not repos: sys.exit(0) try: import ax_fresh - ax_fresh.kick(d, trigger=f"the {ev.get('hook_event_name') or 'hook'} hook" + (f" after {ev['tool_name']}" if ev.get('tool_name') else '')) + for d in repos: + ax_fresh.kick(d, trigger=f"the {ev.get('hook_event_name') or 'hook'} hook" + (f" after {ev['tool_name']}" if ev.get('tool_name') else '')) except Exception: pass # a hook never fails the tool call it rides on diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index bcb55b70e..8d334ddc8 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -66,7 +66,8 @@ # reads: a directory pruned here that the parser DOES read is an edit no refresh ever sees and that `index` calls up to # date, which is what one shared list did to `out`, `build`, `target` and `coverage` in Python and C# (and `coverage` # in Java). A directory watched here that the parser skips costs only a needless rebuild. Each set is the parser's own: -# java parser/src/constants/consts.ts EXCLUDED_DIRS, and every directory whose name starts with a dot +# java parser/src/constants/consts.ts EXCLUDED_DIRS except `build`, and every directory whose name starts with +# a dot; a `build` directory is skipped only as a Gradle project's output (gradle_output below) # typescript parser/src/constants/typescript-constants.ts TS_SKIP_DIRECTORIES, and dot directories # javascript parser/src/constants/javascript-constants.ts JS_SKIP_DIRECTORIES # python parser/src/workflows/python/python-project-analyzer.ts DEFAULT_EXCLUDES, `*.egg-info`, and under a @@ -75,7 +76,7 @@ # .axiomcode (the graph's own directory) and .git are pruned for every language: the build puts .axiomcode in # .git/info/exclude, so the parser skips it too. Directories git ignores are pruned for every language (git_ignored_dirs). SKIP = { - 'java': frozenset({'node_modules', '.git', '.idea', '.vscode', 'dist', 'build', 'target', 'out', '__pycache__', + 'java': frozenset({'node_modules', '.git', '.idea', '.vscode', 'dist', 'target', 'out', '__pycache__', '.pytest_cache', 'venv', 'env'}), 'typescript': frozenset({'node_modules', '.git', 'dist', 'build', 'out', 'coverage', '.next', '.nuxt', '.turbo', '.cache', '.yarn', 'bower_components'}), @@ -112,6 +113,15 @@ def generated_output(parent, name): return os.path.isfile(os.path.join(d, 'element-list')) or os.path.isfile(os.path.join(d, 'package-list')) or \ (os.path.isfile(os.path.join(d, 'navigation.html')) and os.path.isfile(os.path.join(d, 'scripts', 'sourceset_dependencies.js'))) +# A GRADLE PROJECT'S OUTPUT. The Java parser skips a directory named `build` only when a Gradle build or settings +# script sits beside it (parser/src/utils/generated-output.ts isGradleBuildOutput): `build` is also a Java package name, +# and one inside a source tree is read, so an edit there must be watched and its files counted. +GRADLE_BUILD_FILES = ('build.gradle', 'build.gradle.kts', 'settings.gradle', 'settings.gradle.kts') + +def gradle_output(parent, name): + """True when `parent`/`name` is the `build` directory a Gradle project or module writes its output into""" + return name == 'build' and any(os.path.isfile(os.path.join(parent, f)) for f in GRADLE_BUILD_FILES) + 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') @@ -170,6 +180,7 @@ def watched(root, lang): if ignored and os.path.normpath(os.path.join(rd, s)) in ignored: continue o = off | {l for l in langs if prunes(l, s, parent)} if web and not web <= o and generated_output(d, s): o = o | web # a build's output (#1545) + if 'java' in langs and 'java' not in o and gradle_output(d, s): o = o | {'java'} # Gradle's output if all(l in o for l in langs): continue keep.append(s); hidden[os.path.join(d, s)] = o subdirs[:] = keep diff --git a/tests/freshness.py b/tests/freshness.py index 6f190cb71..16aafe008 100644 --- a/tests/freshness.py +++ b/tests/freshness.py @@ -55,7 +55,8 @@ def ts_list(rel, name): def prune_checks(): parser = { - 'java': ts_list('constants/consts.ts', 'EXCLUDED_DIRS'), + # the Java walk skips `build` only beside a Gradle build script (isGradleBuildOutput), never by name + 'java': ts_list('constants/consts.ts', 'EXCLUDED_DIRS') - {'build'}, 'typescript': ts_list('constants/typescript-constants.ts', 'TS_SKIP_DIRECTORIES'), 'javascript': ts_list('constants/javascript-constants.ts', 'JS_SKIP_DIRECTORIES'), 'python': ts_list('workflows/python/python-project-analyzer.ts', 'DEFAULT_EXCLUDES'), @@ -70,13 +71,16 @@ def prune_checks(): 'python': ['app/out/calc.py', 'app/build/calc.py', 'target/t.py', 'coverage/c.py', 'build/lib/gen.py', 'build/frontend.py', '.venv/v.py', 'pkg.egg-info/e.py', 'dist/d.py', 'app/ok.py'], 'java': ['src/main/java/app/coverage/Calc.java', 'src/main/java/app/Ok.java', 'out/O.java', 'target/T.java', - 'build/B.java', '.hidden/H.java'], + 'build/B.java', '.hidden/H.java', 'src/main/java/app/build/StepBuilder.java', + 'mod/build.gradle.kts', 'mod/build/generated/sources/annotationProcessor/java/main/app/Gen.java', + 'mod/src/main/java/app/build/Step.java'], 'csharp': ['src/Shop/build/Calc.cs', 'src/Shop/out/O.cs', 'target/T.cs', 'coverage/C.cs', 'src/Shop/Ok.cs', 'src/Shop/obj/G.cs', 'bin/B.cs', 'packages/P.cs'], } read = { # what the parser reads (True) or skips (False), by its own lists 'python': [True, True, True, True, False, True, False, False, False, True], - 'java': [True, True, False, False, False, False], + # a `build` package is read; only a Gradle module's build/ (beside mod/build.gradle.kts, itself watched) is skipped + 'java': [True, True, False, False, True, False, True, True, False, True], 'csharp': [True, True, True, True, True, False, False, False], } for lang, files in tree.items(): diff --git a/tests/hooks_from_path.py b/tests/hooks_from_path.py new file mode 100644 index 000000000..6d961600f --- /dev/null +++ b/tests/hooks_from_path.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""tests/hooks_from_path.py: every hook finds the graph from the path the tool touched, not from the session's directory. + +Agents work from a directory with no graph and reach indexed trees by absolute path. The hooks looked for the graph +in the session's working directory only, so they almost never spoke. The layout here is that shape: + + ws/ the session's working directory, no graph + ws/app/ an indexed project + ws/plain/ the same sources, never indexed + ws/link -> ws/app a symlink to the indexed project + ws/app/vendored -> ws/plain a symlink inside the indexed project to a tree with no graph + +Promises, each with a near-miss control: + · a Read, a Grep with path=, a shell `sed -n` by absolute path and a `cd && grep` from ws/ are enriched, the + Read exactly as it is from inside ws/app; + · the state and budget files are kept in the repository's .axiomcode, not the working directory's; + · the directive stays silent on a shell `cat` of a file that is not source, as it is on a Read of one; + · controls: a path with no graph anywhere above it, a path ABOVE the indexed root, and a symlink out of the indexed + tree all stay silent; a symlink INTO it answers from the real graph; + · the prompt hook tells a C#-only tree with no graph that one can be built (#1453), and a workspace above an indexed + tree is not told it has no graph; + · finding the graph costs well under the hooks' budget: < 20 ms per lookup, cached per directory for the session. + + python3 tests/hooks_from_path.py +""" +import json, os, shutil, subprocess, sys, tempfile, time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +HOOKS = os.path.join(ROOT, 'plugins', 'axiomcode', 'hooks') +AX = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') +P = 'src/main/java/app/orders/' +SRC = { + 'pom.xml': '4.0.0appapp1\n', + P + 'OrderStore.java': 'package app.orders;\n\npublic class OrderStore {\n public String findById(int id) {\n return "order-" + id;\n }\n}\n', + P + 'OrderService.java': ('package app.orders;\n\npublic class OrderService {\n private final OrderStore store = new OrderStore();\n\n' + ' public String show(int id) { return store.findById(id); }\n\n' + ' public String cancel(int id) { return store.findById(id); }\n}\n'), + 'notes.md': '# notes\nfindById is the lookup\n', +} + +fails, checked = [], [] +def check(why, cond, detail=''): + checked.append(why) + print(('ok ' if cond else 'FAIL ') + why + (f'\n {str(detail)[:400]}' if not cond and detail else '')) + if not cond: + fails.append(why) + + +def fire(cwd, session, tool, inp, hook='enrich.py', event='PostToolUse', **extra): + ev = dict({'hook_event_name': event, 'tool_name': tool, 'tool_input': inp, 'cwd': cwd, 'session_id': session}, **extra) + r = subprocess.run([sys.executable, os.path.join(HOOKS, hook)], input=json.dumps(ev), capture_output=True, text=True, timeout=120) + out = r.stdout.strip() + if not out: + return '' + try: + return json.loads(out)['hookSpecificOutput']['additionalContext'] + except (ValueError, KeyError, TypeError): + return out + + +def write(base, files): + for n, t in files.items(): + os.makedirs(os.path.dirname(os.path.join(base, n)) or base, exist_ok=True) + open(os.path.join(base, n), 'w').write(t) + + +with tempfile.TemporaryDirectory() as tmp: + ws = os.path.realpath(tmp) + app, plain = os.path.join(ws, 'app'), os.path.join(ws, 'plain') + write(app, SRC); write(plain, SRC) + subprocess.run(['git', 'init', '-q'], cwd=app, capture_output=True) + subprocess.run(['git', '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qam', 'x', '--allow-empty'], cwd=app, capture_output=True) + subprocess.run(['git', 'add', '-A'], cwd=app, capture_output=True) + subprocess.run(['git', '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init'], cwd=app, capture_output=True) + built = subprocess.run(['bash', AX, 'index', app, '--lang', 'java'], capture_output=True, text=True, timeout=1800) + if not os.path.exists(os.path.join(app, '.axiomcode', 'out', 'graph.sqlite')): + print('FAIL could not index the project; the engine is needed\n ' + built.stderr.strip()[-300:]) + sys.exit(1) + os.symlink(app, os.path.join(ws, 'link')) + os.symlink(plain, os.path.join(app, 'vendored')) + store, svc = os.path.join(app, P, 'OrderStore.java'), os.path.join(app, P, 'OrderService.java') + + # ── Read / Grep / shell from a directory with no graph ──────────────────────────────────────────────── + inside = fire(app, 'in', 'Read', {'file_path': store}) + check('control: a Read from inside the indexed project is enriched', 'findById' in inside and 'graph:' in inside, inside) + outside = fire(ws, 'ws1', 'Read', {'file_path': store}) + check('a Read by absolute path from a directory with no graph gets the same block as from inside', outside == inside, outside) + g = fire(ws, 'ws2', 'Grep', {'pattern': 'findById', 'path': app}) + check('a Grep with path= the indexed project gets the caller preview', 'OrderStore.findById' in g and '← 2' in g, g) + s = fire(ws, 'ws3', 'Bash', {'command': f'sed -n 1,40p {store}'}) + check('a shell `sed -n` of a file by absolute path is enriched', 'findById' in s, s) + c = fire(ws, 'ws4', 'Bash', {'command': f'cd {app} && grep -rn findById src'}) + check('a `cd && grep` is enriched', 'OrderStore.findById' in c, c) + rel = fire(ws, 'ws5', 'Read', {'file_path': os.path.relpath(store, ws)}) + check('a relative file_path is resolved against the session directory', rel == inside, rel) + + # ── state is per repository ─────────────────────────────────────────────────────────────────────────── + check('the session state is kept in the repository it is about', + os.path.exists(os.path.join(app, '.axiomcode', 'hooks-state-ws1.json')), os.listdir(os.path.join(app, '.axiomcode'))) + check('and nothing is written in the working directory', not os.path.exists(os.path.join(ws, '.axiomcode'))) + again = fire(ws, 'ws1', 'Read', {'file_path': os.path.join(ws, 'link', P, 'OrderStore.java')}) + check('the per-session dedup holds across two spellings of one file (it is one repository\'s state)', again == '', again) + + # ── controls: silent where no graph is above the path ──────────────────────────────────────────────── + check('control: a Read of a file with no graph anywhere above it stays silent', + fire(ws, 'c1', 'Read', {'file_path': os.path.join(plain, P, 'OrderStore.java')}) == '') + check('control: the same file through the SESSION directory of an indexed project stays silent', + fire(app, 'c2', 'Read', {'file_path': os.path.join(plain, P, 'OrderStore.java')}) == '') + check('control: a Grep of a path above the indexed root stays silent', fire(ws, 'c3', 'Grep', {'pattern': 'findById', 'path': ws}) == '') + check('control: a Grep with no path from a directory with no graph stays silent', fire(ws, 'c4', 'Grep', {'pattern': 'findById'}) == '') + check('control: a shell grep of a tree with no graph stays silent', fire(ws, 'c5', 'Bash', {'command': f'grep -rn findById {plain}/src'}) == '') + ln = fire(ws, 'c6', 'Read', {'file_path': os.path.join(ws, 'link', P, 'OrderStore.java')}) + check('a symlink INTO the indexed project answers from its real graph', ln == inside, ln) + check('control: a symlink OUT of the indexed project to a tree with no graph stays silent', + fire(app, 'c7', 'Read', {'file_path': os.path.join(app, 'vendored', P, 'OrderStore.java')}) == '') + + # ── the directive (PreToolUse) ──────────────────────────────────────────────────────────────────────── + d = fire(ws, 'd1', 'Read', {'file_path': svc}, hook='direct.py', event='PreToolUse') + check('the directive speaks before a Read by absolute path from a directory with no graph', 'axiomcode_impact' in d, d) + check('control: the directive is silent before a Read in a tree with no graph', + fire(ws, 'd2', 'Read', {'file_path': os.path.join(plain, P, 'OrderStore.java')}, hook='direct.py', event='PreToolUse') == '') + check('the directive is silent on a shell `cat` of a file that is not source', + fire(app, 'd3', 'Bash', {'command': 'cat notes.md'}, hook='direct.py', event='PreToolUse') == '') + d = fire(app, 'd4', 'Bash', {'command': f'cat {os.path.relpath(store, app)}'}, hook='direct.py', event='PreToolUse') + check('control: the directive speaks on a shell `cat` of a source file', 'axiomcode_impact' in d, d) + + # ── orientation ────────────────────────────────────────────────────────────────────────────────────── + for i in range(30): + write(ws, {f'more/src/Extra{i}.java': f'class Extra{i} {{}}\n'}) + o = fire(ws, 'o1', 'Read', {}, hook='orient.py', event='UserPromptSubmit', prompt='what breaks if I change the findById method in this workspace') + check('a workspace above an indexed project is not told it has no graph', 'no call graph yet' not in o, o) + shutil.rmtree(os.path.join(ws, 'more')) + cs = os.path.join(ws, 'cs') + write(cs, {'App.csproj': 'net8.0\n'}) + for i in range(1, 31): + write(cs, {f'Widget{i}.cs': f'namespace App; public class Widget{i} {{ }}\n'}) + o = fire(cs, 'o2', 'Read', {}, hook='orient.py', event='UserPromptSubmit', prompt='what breaks if I change the Widget1 class in this repo') + check('a C#-only tree with no graph is told one can be built (#1453)', 'no call graph yet' in o, o) + for i in range(6, 31): + os.remove(os.path.join(cs, f'Widget{i}.cs')) + o = fire(cs, 'o3', 'Read', {}, hook='orient.py', event='UserPromptSubmit', prompt='what breaks if I change the Widget1 class in this repo') + check('control: a C# tree of five files is too small to be told', o == '', o) + + # ── cost ───────────────────────────────────────────────────────────────────────────────────────────── + sys.path.insert(0, HOOKS); import _where + _where.session('perf'); t = time.perf_counter() + r1 = _where.root_of(store); cold = time.perf_counter() - t + t = time.perf_counter() + for _ in range(50): _where.root_of(svc) + warm = (time.perf_counter() - t) / 50 + check(f'finding the graph costs < 20 ms (cold {cold * 1000:.2f} ms, cached {warm * 1000:.2f} ms)', r1 == app and cold < 0.02 and warm < 0.02, (r1, cold, warm)) + lang = os.path.join(ws, 'poly'); os.makedirs(os.path.join(lang, '.axiomcode', 'lang', 'python', 'out')); os.makedirs(os.path.join(lang, 'pkg')) + open(os.path.join(lang, '.axiomcode', 'lang', 'python', 'out', 'graph.sqlite'), 'w').close() + check('a repository whose only graph is a per-language one is found', _where.root_of(os.path.join(lang, 'pkg')) == lang) + check('and a file of that language is answered from it', + _where.graph_db(lang, 'pkg/a.py') == os.path.join(lang, '.axiomcode', 'lang', 'python', 'out', 'graph.sqlite')) + +print(f"\n{len(checked) - len(fails)}/{len(checked)} passed") +sys.exit(1 if fails or not checked else 0)