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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,8 @@ indexed copy afterwards, the answer degrades to `RECALL DEGRADED` and names the
instead of quietly trusting the wrong line numbers. So does a change to a file the
index does not model at all — SQL, a `Dockerfile`, CI YAML, CSS, config — unless it is
a test, docs or a type declaration: not indexed is not "no impact" (TECHNICAL.md
"Unmodeled Paths Degrade Too").
"Unmodeled Paths Degrade Too"). A registry's `file_triggers` maps such paths to the
journeys they affect instead (TECHNICAL.md "File Triggers").

Each run appends one `selection` row to `~/.local/share/testgraph/ledger.jsonl`
(`{kind, ts, repo, base, head, commit, status, n_journeys, journey_ids, duration_ms,
Expand Down
44 changes: 44 additions & 0 deletions TECHNICAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ leaving edges wrong) — only a full `codegraph index` did. Three checks:
- `TESTGRAPH_STATE_DIR` relocates the invocation log (default
`${XDG_DATA_HOME:-~/.local/share}/testgraph`).
- Registry `spot_checks`: `{symbol: {min_caller_edges, file}}` — the guard floors.
- Registry `file_triggers`: `{glob: [journey ids]}` — paths mapped straight to
journeys (issue #118; "File Triggers").

## Deployment

Expand Down Expand Up @@ -649,6 +651,48 @@ byte-identical with the degrade reverted, because no labeled commit exercises th
zero-seed path (`0b4135f` already degraded via a whole-file change). The guard is
justified by the reproduced failure in #29, not by a metric move.

## File Triggers (issue #118)

Some dependencies the graph cannot model are still known: a SQL migration under
`packages/db/migrations/` can change RLS for every journey. A registry declares them:

```json
"file_triggers": {
"packages/db/migrations/**": ["J1", "J2", "J3"],
"apps/web/src/**/*.css": ["J8"]
}
```

- **Globs** are repo-relative and match the whole path, case-sensitively: `**` is
zero or more whole segments, `*` any run inside one segment, `?` one character
inside a segment. No `[...]`, `{...}`, `!` or `\`; no empty, `.` or `..`
segment (so no leading `./` or `/`, trailing `/`, or `//`); `**` only as a whole
segment, at most two per glob (more backtrack for seconds on deep paths).
- **Matched against every raw diff path** (`diff_files`: added, deleted, both
sides of a rename, binary, submodule), before the product filter.
- **Additive.** A journey the graph also selected keeps its graph row and gains
`triggered_by: [{pattern, path}]`, with no `reason`. A trigger-only journey gets
a row with `reason: "file-trigger"`, `confidence: 1.0`, `verify_manually: false`:
a human declared that dependency, which is stronger than any edge. The result
carries `file_triggers: [{path, pattern, journeys}]`; select prints
``trigger: <path> matches `<glob>` → J…``, and both select and the hook label the row.
- **A trigger is a complete claim** for the paths it matches. A matched
unmodeled path does not force the #117 all-journeys fallback, and is not
reported as unmodeled in the text or the hook. There is no partial coverage: to
be conservative, list more journeys. A matched product path still goes
through the graph (and #29) as before.
- **An unmatched unmodeled path still degrades.** The globs that can never
match a file (the shapes above) are refused. A plausible but wrong glob can't
be detected. It fails safe for an unmodeled path, which falls back to every
journey, but NOT for an exempt or product path, which just loses the declared
journeys, so check new triggers against a real diff.
- **The export map lists the triggers** under `## File triggers` (and in the
`--json` sidecar's `meta.file_triggers`), and refuses an invalid trigger as
`select` does. Otherwise the map would disagree with the selector.
- **Invalid triggers BLOCK**: a malformed glob, a value that isn't a non-empty
list, or an unknown journey id. A typo'd id would otherwise drop a journey
the registry declares affected.

## Unmodeled Paths Degrade Too (issue #117)

The parser used to drop every path outside `PRODUCT_EXT` before anything looked
Expand Down
5 changes: 5 additions & 0 deletions skills/testgraph-propose/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ python3 -m testgraph.select --repo <path> --registry <path>/.testgraph/journeys/

## 6. Approve

If the app has behaviour the graph cannot see — SQL migrations, RLS policies,
stylesheets a journey depends on — declare it before approving, so those diffs
select the right journeys instead of every one (TECHNICAL.md "File Triggers"):
`"file_triggers": {"packages/db/migrations/**": ["J1", "J2"]}`.

Set `"approved": true`, drop the `blind_spots` you resolved (keep the ones you
consciously accepted), rewrite `note` to say who reviewed it and what is knowingly
uncovered, and rename the file to `.testgraph/journeys/<target>.json` — in the same
Expand Down
6 changes: 4 additions & 2 deletions skills/testgraph-verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ out of scope.
use the line range as the fallback key and read every row it plausibly covers.
An edit you cannot attribute to any row is *unknown* — the absence rule below
does not apply to it.
4. Union the journey IDs. That is your verification set.
4. Union the journey IDs, plus the journeys of every `## File triggers` glob a
changed path matches (declared in the registry, not derived from the graph).
That is your verification set.
5. A journey marked `!` was reached only through weak or synthesized graph edges.
Do not treat it as probably-fine — verify by hand or say you did not.
6. Report the journey IDs and names you verified, and name any you did not.
Expand All @@ -56,7 +58,7 @@ below means *unknown*, never *none*:
| You **added** a `.py` function or file | Run `select` (below). A new symbol has no row by definition. |
| You **deleted** or **renamed** a `.py` file | Run `select`. If the file is still in the index it seeds that file's symbols precisely and returns a *narrow* list — correct, not a malfunction. Only when the file has already left the index does it report unbounded impact and list every journey. |
| You changed **any non-`.py` file** (`.js`, `.jsx`, `.ts`, `.tsx`, `.svelte`, `.vue`, …) | Run `select` — it seeds these paths (issue #21; it used to answer `NONE` for all of them). If your extension is not in the index it prints `RECALL DEGRADED` and lists every journey: that is *unknown*, not none. A journey reached only through frontend code often arrives on a weak edge — expect `!` and verify by hand. |
| You changed a file the index does not model — SQL, `Dockerfile`, CI YAML, CSS, JSON/toml config, anything that is not code, a test, docs or a `.d.ts` | Run `select`. It names the path as `unmodeled:`, prints `RECALL DEGRADED` and lists every journey (issue #117): its impact is *unknown*, not none. The map can never answer for it. |
| You changed a file the index does not model — SQL, `Dockerfile`, CI YAML, CSS, JSON/toml config, anything that is not code, a test, docs or a `.d.ts` | If a `## File triggers` glob in the map matches it, its journeys are the declared answer (issue #118). Otherwise run `select`: it names the path as `unmodeled:`, prints `RECALL DEGRADED` and lists every journey (issue #117), because its impact is *unknown*, not none. |
| The file has no `###` section at all | Unknown. The indexer may not cover it. Escalate and say so. |
| `generated from commit` is far behind HEAD | The map under-reports. Regenerate (below) or escalate. |
| The header says the **registry was not human-approved** | A different failure from the one below, with a different fix. The index may be fine; what is unverified is whether the registry lists every journey this app has. Absence of a symbol from every journey is *unknown*, not *unaffected*. **Do not re-index** — it cannot clear this. Escalate, or review and approve the registry (`skills/testgraph-propose`). |
Expand Down
28 changes: 27 additions & 1 deletion testgraph/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,22 @@ def render_markdown(rows_by_file, registry, meta):
entries = ", ".join(f"`{e['name']}`" for e in spec["entries"])
out.append(f"- **{jid}** {spec['name']} — entry: {entries}")
out.append("")
# The selector adds these journeys for any changed path matching the glob,
# whatever the graph says (#118). Without them here the map disagrees with
# `select`, and an agent reading it would skip a declared journey.
triggers = registry.get("file_triggers") or {}
if triggers:
out += [
"## File triggers",
"",
"Declared, not graph-derived: if a path you changed matches one of "
"these globs (`**` = any number of directories, `*` = within one), "
"add its journeys to your set, whatever the rows below say.",
"",
]
for pattern, jids in triggers.items():
out.append(f"- `{pattern}` → {', '.join(jids)}")
out.append("")
out.append("## Symbols by file")
for path in sorted(rows_by_file):
out.append("")
Expand Down Expand Up @@ -397,6 +413,14 @@ def main(argv=None):
f"index; it would silently vanish from every row"
)

# Same refusal as `select`: an unknown journey id in a trigger would drop a
# declared journey from the map's trigger list too (#118).
trigger_problems = reg.trigger_problems(registry)
if trigger_problems:
print("BLOCKED — registry file_triggers invalid; map NOT written",
file=sys.stderr)
for t in trigger_problems:
print(f" x {t}", file=sys.stderr)
if provenance:
print("BLOCKED — provenance unverifiable; map NOT written", file=sys.stderr)
for p in provenance:
Expand All @@ -405,7 +429,7 @@ def main(argv=None):
print("BLOCKED — index not trustworthy; map NOT written", file=sys.stderr)
for b in blocking:
print(f" x {b}", file=sys.stderr)
if provenance or blocking:
if provenance or blocking or trigger_problems:
return 2

rows_by_file = build_map(conn, registry)
Expand All @@ -423,6 +447,8 @@ def main(argv=None):
# same rule, different cause: whether a human ever reviewed the registry
# is a provenance fact about the map's INPUT, not about the index
"registry_approval": approval,
# the --json consumer needs the declared triggers too (#118)
"file_triggers": registry.get("file_triggers") or {},
}

md = render_markdown(rows_by_file, registry, meta)
Expand Down
5 changes: 4 additions & 1 deletion testgraph/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ def render(result, repo, more_cmd=None):
]
for j in journeys[:MAX_JOURNEYS]:
flag = " ! VERIFY MANUALLY" if j.get("verify_manually") else ""
if j.get("reason") == "file-trigger":
flag = " (file-trigger)"
lines.append(
f" [{j['rank']:>3}] {j['id']} {j['name']} "
f"(conf {j['confidence']}){flag}"
Expand Down Expand Up @@ -112,7 +114,8 @@ def render(result, repo, more_cmd=None):
# Same reasoning as the confined line: a path the index does not model
# forced the all-journeys fallback (issue #117), and the reader should see
# which one without depending on the capped warnings below.
unmodeled = result.get("unmodeled_files", [])
covered = {t["path"] for t in result.get("file_triggers", [])}
unmodeled = [p for p in result.get("unmodeled_files", []) if p not in covered]
if unmodeled:
shown = ", ".join(unmodeled[:MAX_CONFINED])
if len(unmodeled) > MAX_CONFINED:
Expand Down
81 changes: 81 additions & 0 deletions testgraph/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
node ids at run time.
"""
import ast
import functools
import json
import os
import re

from . import db as dbmod

Expand Down Expand Up @@ -461,6 +463,85 @@ def _binds(node, name):
return any(isinstance(t, ast.Name) and t.id == name for t in targets if t)


# `file_triggers` (issue #118): {"<glob>": ["J1", ...]} maps changed paths straight
# to journeys, for dependencies the graph cannot model (a SQL migration that
# changes RLS for every journey). Globs are repo-relative and match the whole
# path: `**` is zero or more whole segments, `*` any run inside one segment, `?`
# one character inside a segment. fnmatch's `*` crosses `/`, and
# PurePath.full_match / glob.translate are 3.13+, so the translation is ours.
_GLOB_UNSUPPORTED = re.compile(r"[\[\]!{}\\]")
# More `**` segments than this backtrack badly on deep paths (8 chained took
# >20s, enough to hang the pre-push hook), and no real trigger needs them.
MAX_DOUBLE_STAR = 2


@functools.lru_cache(maxsize=None)
def _trigger_regex(pattern):
out = []
parts = pattern.split("/")
for i, part in enumerate(parts):
last = i == len(parts) - 1
if part == "**":
# [\s\S], not `.`: a path may contain a newline (git quotes it, the
# diff parser unquotes it), and `.` would silently miss it.
out.append(r"[\s\S]*" if last else "(?:[^/]+/)*")
continue
seg = "".join("[^/]*" if c == "*" else "[^/]" if c == "?" else re.escape(c)
for c in part)
out.append(seg if last else seg + "/")
return re.compile("".join(out) + r"\Z")


def trigger_matches(pattern, path):
"""Whether a repo-relative `path` matches a `file_triggers` glob."""
return _trigger_regex(pattern).match(path) is not None


def trigger_problems(registry):
"""Why `file_triggers` is unusable, as blocking messages; [] when fine.

Blocking, not a warning: an unknown journey id would silently drop a journey
a human declared affected. A typo'd GLOB cannot be caught here (it matches
nothing), but it fails safe: the path falls through to the #117 fallback."""
triggers = registry.get("file_triggers")
if triggers is None:
return []
if not isinstance(triggers, dict):
return ["file_triggers must be an object of {glob: [journey ids]}"]
known = registry.get("journeys", {})
problems = []
for pattern, jids in triggers.items():
segs = pattern.split("/") if isinstance(pattern, str) else []
# Every one of these can never match a file path, so it would drop the
# declared journeys behind a clean NONE on an exempt or graph-mapped
# path: an empty segment (`docs/`, `a//b`, a leading `/`), `.` or `..`,
# `**` inside a segment (`tests/**.json` is a one-segment `*`).
if (not segs or _GLOB_UNSUPPORTED.search(pattern)
or any(s in ("", ".", "..") or ("**" in s and s != "**")
for s in segs)
or segs.count("**") > MAX_DOUBLE_STAR):
problems.append(
f"file_triggers glob {pattern!r} can never match as written — "
f"repo-relative, no empty, `.` or `..` segments, `**` only as a "
f"whole segment (at most {MAX_DOUBLE_STAR}), only * ** ?"
)
continue
if (not isinstance(jids, list) or not jids
or not all(isinstance(j, str) for j in jids)):
problems.append(
f"file_triggers[{pattern!r}] must be a non-empty list of journey ids"
)
continue
unknown = [j for j in jids if j not in known]
if unknown:
problems.append(
f"file_triggers[{pattern!r}] names unknown journey(s) "
f"{', '.join(unknown)} — a typo here would silently drop a "
f"journey the registry declares affected"
)
return problems


def journey_name(registry, jid):
return registry["journeys"][jid]["name"]

Expand Down
Loading
Loading