Skip to content

jutaz/symtether

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

53 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

#sym:tether

Tethered Docs. Real Code. Zero Hallucinations.

CI npm downloads node license

Website  ·  Guide  ·  Spec  ·  Discussions

symtether

Docs that point at real code, and fail CI when they stop. Built for AGENTS.md and the other docs coding agents read as instructions.

Your AGENTS.md says "follow the pattern in fetchData." Three sprints later someone renames fetchData and nothing fails. The doc still reads fine, and everyone who follows the pointer spends time hunting for code that is gone.

Broken URLs return 404. Broken code references do not. A link checker verifies the file, and nothing verifies the symbol inside it.

<!-- The file exists, so every link checker passes this, -->
<!-- but fetchData was renamed two weeks ago. -->
Follow the fetch pattern in [fetchData](src/api/client.ts#L42).

The #L42 fragment is worse than a bare file link, because after the file shifts it points at whatever code moved into line 42.

symtether fixes this. The reference names the symbol, and the tool checks it against the code itself.

Follow the fetch pattern in [ApiClient.fetchData](src/api/client.ts#sym:ApiClient.fetchData).

This is still a plain markdown link, so it renders and clicks on GitHub. Now symtether check can resolve it against the AST and fail CI when the symbol moves or disappears, and symtether fix repairs the common cases automatically. symtether is a one-page open spec for the #sym: reference syntax, plus the reference toolkit that enforces it.

30 seconds

$ npx symtether check
docs/agents/fetching.md
  ✗ src/api/client.ts#sym:ApiClient.fetchData   BROKEN (line 14)
      file OK; symbol not found
      closest in file: ApiClient.fetchAgentData (method)
      → symtether fix docs/agents/fetching.md

$ npx symtether fix --write
$ npx symtether check && echo green
green

A rename that used to pass unnoticed now fails CI, and the fix is one command away. There is no config file, and the markdown links are the only source of truth. If you turn on staleness detection, symtether also writes an optional symtether.sum file. That file holds derived checksums, and you can delete and regenerate it at any time. It is never a source of truth. Exclusions come from your .gitignore, and node_modules is always skipped (GLOB_OPTIONS).

Why agents make this worse

Coding agents read AGENTS.md, CLAUDE.md, and skill files as instructions. The most useful instruction in these files is a pointer to existing code, e.g., "follow the pattern in fetchData". An agent pointed at a deleted symbol searches for it, guesses, and then imitates whatever it finds instead. Agents also cause the breakage, because every refactor an agent lands can break the pointers the next session depends on.

The #sym: convention helps even before the tool is installed. An agent reading src/client.ts#sym:ApiClient.fetchData has the file path and an exact string to grep. With a bare file link the agent has to read hundreds of lines hoping to spot the pattern, and with a line link the agent reads the wrong lines after the file shifts. symtether makes the convention enforceable. check in CI catches what agents and humans break, fix repairs it, and init installs a short managed block that tells agents to keep refs working themselves.

Usage

npx symtether check [globs…]     # validate refs; exit 1 on broken
npx symtether check --json       # stable machine output (schemas/check-output.schema.json)
npx symtether fix [globs…]       # propose repairs (dry-run)
npx symtether fix --write        # apply them
npx symtether fix --canonicalize # also rewrite compat-form refs to #sym:
npx symtether init               # install the agent block into AGENTS.md
npx symtether init --ci          # + a GitHub Actions workflow
npx symtether update [targets…]  # stamp review: (re)generate symtether.sum
npx symtether update --check     # CI: fail if symtether.sum is out of date
npx symtether check --strict     # also fail when stamped targets changed
npx symtether check --strict=warn  # …or just report staleness

Exit codes:

  • 0. All refs pass.
  • 1. Broken refs, stale refs under --strict, or an outdated sum file under update --check.
  • 2. Usage or runtime error.

You can also use symtether as a library. The CLI calls the same four functions the library exports: check, fix, init, and update.

import { check } from 'symtether';
const report = await check({ cwd: '/path/to/repo' });

GitHub Action

You can run symtether in CI without copying a workflow file. Reference this repo as a reusable GitHub Action. It runs the published CLI through npx, so there is nothing to build or maintain, and the tool version matches the release you reference.

# .github/workflows/symtether.yml in YOUR repo
name: symtether
on: [push, pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: jutaz/symtether@v1        # or @v1.2.3 to pin a release
        with:
          command: check
          strict: true                   # also fail on stale stamped refs
          exclude: |
            test/fixtures/**

The action builds the CLI flags from these inputs:

  • command. The subcommand to run: check (default), fix, init, or update.
  • strict. Staleness gate for check. Use '' (off, default), true to fail on stale refs (--strict), or warn to report only (--strict=warn).
  • globs. Files to check, one per line. When empty, symtether finds files from the repo root and honors .gitignore (GLOB_OPTIONS).
  • exclude. Globs to exclude, one per line (e.g. test/fixtures/**). Each line becomes a --exclude flag.
  • json. Set to true to emit stable JSON (schemas/check-output.schema.json) for another action to read. The default is false.
  • working-directory. The directory to run in, relative to the repo root. The default is the repo root.
  • args. Raw extra flags for anything not covered above (e.g. --write).

Version pinning. The action pins the tool version to the git ref you reference. It reads its own package.json at that ref and runs npx symtether@<that version>. So @v1.2.3 runs symtether 1.2.3, and @v1 runs whatever the moving v1 tag points at. If your repo depends on symtether itself, npx prefers your local copy, and the pinned version no longer applies.

If you would rather own the workflow file, run npx symtether init --ci. It writes .github/workflows/symtether.yml into your repo. That file uses the same action.

The syntax

Full spec: SPEC.md. The short version:

[text](path/to/file.ts#sym:Class.method)
[text](path/to/file.ts#sym:fn:parseConfig)      ← optional kind: fn | class | type | const
[text](/src/from-repo-root.ts#sym:Widget)

The dotpath is a suffix match against the definition's nesting chain, so the natural short form works across languages. Exactly one match passes. Zero matches is broken. Two or more matches is ambiguous, and the error asks you to qualify the ref.

Resolution tiers

Every ref resolves at one of three tiers, and the tier is part of the output. Anything that could not be fully verified shows up as lexical or file-only rather than passing quietly (see Resolver):

Tier When Meaning
ast TypeScript, TSX, JavaScript, Python, Go, Rust, Java, Kotlin, Swift, Ruby, PHP, C, C++, C#, Scala, Elixir, Lua, Bash Symbol verified against the parsed AST
lexical any other text file Word-boundary match for the symbol name
file-only fragment not checkable Path existence only, reported as a warning

Adding a tier-1 language is mostly a grammar import plus fixtures (see the registry in loadLanguage). Open an issue if yours is missing.

Staleness detection

By default check fails only on broken refs. If you also want to find out when the implementation behind a ref changes, use the sum file. The flow is:

  1. npx symtether update writes symtether.sum, which holds a normalized content hash (hashDefinition) for every resolvable ref. Reformatting does not change a hash. Renaming does not either, because the hash excludes the symbol's own name. That is what lets fix detect renames by content.
  2. npx symtether check --strict marks refs stale when their target's hash no longer matches, and lists every doc referencing the changed target. --strict=warn reports without failing.
  3. Re-read the prose, fix it or confirm it, then re-stamp with npx symtether update <target>.

The sum file holds derived checksums, not decisions. go.sum uses the same idea (sumfile.ts). If you delete the sum file, check passes or fails exactly as before, and update writes the sum file back. A repo that never runs update gives up two things and only two things. It gives up staleness detection, and it gives up the ability of fix to detect renames by content.

There is one trade-off. Entries are stored per target, and sumKey ignores the written kind, so re-stamping a target clears staleness for every doc that references it. That is why stale output lists every referencing doc for review.

Limits

  • symtether guarantees the pointer resolves. It does not guarantee the prose around the pointer is still true. --strict flags refs whose implementation changed, but you or your agents judge whether the prose still holds.
  • Resolution checks that a definition exists in the linked file. There is no import following or re-export chasing, so a symbol re-exported but not defined in the linked file counts as broken. Link to the defining file instead.

Prior art

Other tools work on the same problem in different ways.

Tool Mechanism Difference
Fiberplane Drift Stateful binder. drift link writes bindings and AST fingerprints into drift.lock The lockfile is the source of truth, and every intentional change needs re-stamping
docref Early exploration of markdown path#Symbol links plus tree-sitter and .docref.lock Lockfile-first, cargo-only, and never released. It prototyped the direction and deserves the credit
Roam-Code A codebase intelligence platform with a SQLite symbol index Requires indexing, and doc checking is one feature among hundreds
AgentLinter Lints AGENTS.md structure, token budget, and file-level references Overlaps with symtether's file-only tier, and symtether adds AST symbol resolution. A repo can run both linters
lychee, markdown-link-check HTTP and filesystem link checkers They verify that URLs return 200 and that files exist. Neither reads the code, so #L42 and #sym: fragments pass as long as the file does. They are complementary to symtether

symtether differs in three ways:

  • ordinary clickable markdown links are the only source of truth,
  • the optional symtether.sum file holds derived checksums that you can delete and regenerate at any time, and no other index is maintained,
  • checking fails only when a ref is broken, and staleness detection stays opt-in.

You get the same guarantees as Drift without the lockfile step.

License

MIT © Justas Brazauskas

About

Broken URLs 404. Broken code references don't. #sym: verifies markdown references against the code itself, and fails CI when they break.

Topics

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors