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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Short version: Drift installs as a Claude Code plugin and works inside the agent
- add amphetamin — hold the session open on machine-checked work
- guard TypeScript and JavaScript, not only Python
- notice when the index describes a tree that has moved
- guard Go

### Fixed

Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ plugin that fires in every project you open has no business leaving a directory
in each of them. A project that wants the index alongside its source opts in by
creating a `.drift/` directory.

**Python, TypeScript and JavaScript** (`.py .ts .tsx .js .jsx .mjs .cjs`), in one
index — a name defined in Python is found from TypeScript, because
`validate_token` and `validateToken` normalise to the same thing. Other
languages pass through untouched.
**Python, TypeScript, JavaScript and Go** (`.py .ts .tsx .js .jsx .mjs .cjs .go`),
in one index — a name defined in Python is found from TypeScript, because
`validate_token`, `validateToken` and `ValidateToken` all normalise to the same
thing. Other languages pass through untouched.

<!-- Re-enable once GitHub Actions is unlocked (billing) — runs currently fail before starting, which renders a misleading red badge:
[![CI](https://github.com/mick-gsk/drift/actions/workflows/ci.yml/badge.svg)](https://github.com/mick-gsk/drift/actions/workflows/ci.yml)
Expand Down
10 changes: 5 additions & 5 deletions docs-site/claude-code-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ business leaving a directory in each of them. A project that wants the index
alongside its source opts in by creating a `.drift/` directory; `DRIFT_CACHE_HOME`
overrides both.

!!! note "Python, TypeScript and JavaScript"
`.py .ts .tsx .js .jsx .mjs .cjs`, all in one index — a name defined in
Python is found from TypeScript, because `validate_token` and
`validateToken` normalise to the same thing. Python is parsed with `ast`;
TypeScript and JavaScript are matched against top-level declarations,
!!! note "Python, TypeScript, JavaScript and Go"
`.py .ts .tsx .js .jsx .mjs .cjs .go`, all in one index — a name defined in
Python is found from Go, because `validate_token`, `validateToken` and
`ValidateToken` all normalise to the same thing. Python is parsed with `ast`;
the others are matched against top-level declarations,
because the guard may not grow a parser dependency. That trade costs recall,
never precision: every pattern is anchored to column zero and to a
declaration keyword, so a miss is possible and an invented symbol is not.
Expand Down
8 changes: 6 additions & 2 deletions scripts/gates/measure_noise.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,15 @@ def measure_boundary_reach(repo: pathlib.Path, conn) -> dict:
source = (repo / rel).read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
_, imports = extract.extract(source, pathlib.PurePosixPath(rel).suffix)
suffix = pathlib.PurePosixPath(rel).suffix
_, imports = extract.extract(source, suffix)
src_dir = build.dir_of(rel)
edges = set()
for specifier in imports:
destination = build.import_to_dir(specifier, src_dir, known_dirs)
# The suffix matters: without it every Go module path falls through
# the TypeScript branch and resolves to nothing, which reports a
# tidy zero for a signal that is working fine.
destination = build.import_to_dir(specifier, src_dir, known_dirs, suffix)
if destination and destination != src_dir:
edges.add((src_dir, destination))
per_file[rel] = edges
Expand Down
28 changes: 25 additions & 3 deletions src/drift/guard/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,36 @@ def relative_to_dir(specifier: str, src_dir: str, known_dirs: set[str]) -> str |
return parent if parent in known_dirs else None


def import_to_dir(specifier: str, src_dir: str, known_dirs: set[str]) -> str | None:
def suffix_to_dir(specifier: str, known_dirs: set[str]) -> str | None:
"""Match the trailing segments of a full module path onto a directory.

Go imports carry the whole module path — `github.com/org/repo/internal/db`
— and the repository knows itself as `internal/db`. Dropping leading
segments until something matches finds it without reading `go.mod`.

This is reserved for Go. Applying it to TypeScript would resolve
`lodash/fp` onto a directory named `fp`, and date-fns has one.
"""
parts = specifier.split("/")
for start in range(len(parts)):
candidate = "/".join(parts[start:])
if candidate in known_dirs:
return candidate
return None


def import_to_dir(
specifier: str, src_dir: str, known_dirs: set[str], suffix: str = ".py"
) -> str | None:
"""Map any import specifier onto a repository directory, if it is one.

Bare TypeScript specifiers (`react`, `@scope/pkg`) name packages rather
than places in this repository, so they resolve to nothing.
"""
if specifier.startswith("."):
return relative_to_dir(specifier, src_dir, known_dirs)
if suffix in extract.GO_SUFFIXES:
return suffix_to_dir(specifier, known_dirs)
if "/" in specifier:
return None
return module_to_dir(specifier, known_dirs)
Expand Down Expand Up @@ -171,7 +193,7 @@ def build_full(repo_root: pathlib.Path) -> dict:
file_rows.append((rel, _sha256(path), now))
symbol_rows.extend((rel, s.name, s.norm_name, s.kind, s.sig_hash, s.line) for s in symbols)
for module in imports:
dst_dir = import_to_dir(module, src_dir, known_dirs)
dst_dir = import_to_dir(module, src_dir, known_dirs, path.suffix)
if dst_dir is None or dst_dir == src_dir:
continue
edge_counts[(src_dir, dst_dir)] = edge_counts.get((src_dir, dst_dir), 0) + 1
Expand Down Expand Up @@ -230,7 +252,7 @@ def update_file(repo_root: pathlib.Path, rel_path: str) -> None:
known_dirs |= {dir_of(row[0]) for row in conn.execute("SELECT path FROM files")}
src_dir = dir_of(rel_path)
for module in imports:
dst_dir = import_to_dir(module, src_dir, known_dirs)
dst_dir = import_to_dir(module, src_dir, known_dirs, path.suffix)
if dst_dir is None or dst_dir == src_dir:
continue
conn.execute(
Expand Down
71 changes: 70 additions & 1 deletion src/drift/guard/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
#: File types the guard indexes. Everything else passes through untouched.
PYTHON_SUFFIXES = frozenset({".py"})
TS_JS_SUFFIXES = frozenset({".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"})
GUARDED_SUFFIXES = PYTHON_SUFFIXES | TS_JS_SUFFIXES
GO_SUFFIXES = frozenset({".go"})
GUARDED_SUFFIXES = PYTHON_SUFFIXES | TS_JS_SUFFIXES | GO_SUFFIXES

#: Normalized names too common to ever be a meaningful duplicate.
STOPWORDS = frozenset(
Expand Down Expand Up @@ -102,6 +103,36 @@
)


#: Go declarations. `func (r *Receiver) Name(` is deliberately absent: methods
#: repeat across every type in a package — `String`, `Error`, `Read`, `Close` —
#: exactly as they do in Python and TypeScript, where they are excluded for the
#: same reason. The issue that scoped this work argued for including them; the
#: noise measurement that came before it argued louder.
_GO_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
("function", re.compile(r"^func\s+([A-Za-z_][\w]*)\s*\(", re.MULTILINE)),
# Only the single-declaration form. A `type (...)` block would need the
# names matched at one indent level, which is also where struct fields
# live — and a struct field read as a type declaration is the kind of
# invented symbol these patterns exist to avoid.
(
"type",
re.compile(
r"^type\s+([A-Za-z_][\w]*)\s+(?:struct|interface|func|map|chan|\[|\*|[A-Za-z_])",
re.MULTILINE,
),
),
)

#: Import paths, both the single form and the parenthesised block. Go writes
#: them as full module paths (`github.com/org/repo/internal/db`), so the
#: resolver matches trailing segments against the repository's directories.
_GO_IMPORTS: tuple[re.Pattern[str], ...] = (
re.compile(r'^import\s+(?:[A-Za-z_.]\w*\s+)?"([^"]+)"', re.MULTILINE),
re.compile(r"^import\s*\(([^)]*)\)", re.MULTILINE | re.DOTALL),
)
_GO_IMPORT_LINE = re.compile(r'^\s*(?:[A-Za-z_.]\w*\s+)?"([^"]+)"', re.MULTILINE)


class Symbol(NamedTuple):
name: str
norm_name: str
Expand Down Expand Up @@ -139,9 +170,47 @@ def extract(source: str, suffix: str = ".py") -> tuple[list[Symbol], list[str]]:
return extract_ts(source)
if suffix in PYTHON_SUFFIXES:
return extract_python(source)
if suffix in GO_SUFFIXES:
return extract_go(source)
return ([], [])


def extract_go(source: str) -> tuple[list[Symbol], list[str]]:
"""Go, matched rather than parsed, on the same terms as TypeScript.

Package-level `func` and `type` only. Anchoring to column zero is unusually
reliable here because gofmt is not optional in practice: every declaration
that is package-level starts at column zero in formatted code.
"""
symbols: list[Symbol] = []
seen: set[str] = set()

for kind, pattern in _GO_PATTERNS:
for match in pattern.finditer(source):
name = match.group(1)
if name in seen:
continue
seen.add(name)
symbols.append(
Symbol(
name=name,
norm_name=normalize(name),
kind=kind,
sig_hash=signature_hash(kind, []),
line=source.count("\n", 0, match.start()) + 1,
)
)

imports: list[str] = []
for match in _GO_IMPORTS[0].finditer(source):
imports.append(match.group(1))
for block in _GO_IMPORTS[1].finditer(source):
imports.extend(line.group(1) for line in _GO_IMPORT_LINE.finditer(block.group(1)))

symbols.sort(key=lambda s: s.line)
return (symbols, imports)


def extract_ts(source: str) -> tuple[list[Symbol], list[str]]:
"""TypeScript and JavaScript, matched rather than parsed.

Expand Down
5 changes: 4 additions & 1 deletion src/drift/guard/lookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from __future__ import annotations

import pathlib
import sqlite3
from typing import NamedTuple

Expand Down Expand Up @@ -160,7 +161,9 @@ def find_novel_edges(conn: sqlite3.Connection, rel_path: str, imports: list[str]
hits: list[Hit] = []
reported: set[str] = set()
for module in imports:
dst_dir = build.import_to_dir(module, src_dir, known_dirs)
dst_dir = build.import_to_dir(
module, src_dir, known_dirs, pathlib.PurePosixPath(rel_path).suffix
)
if dst_dir is None or dst_dir == src_dir:
continue
if dst_dir in existing or dst_dir in reported:
Expand Down
Loading
Loading