DEV Community

Avery Lin
Avery Lin

Posted on

Extract a Migration Inventory From Git Diffs, Then Lint Breaking-Change Claims

A documentation generator may draft inventories from diffs, but it cannot own breaking-change labels or support duration. Treat every compatibility sentence as a human-signed claim, not as autocomplete output from a model. The workflow below extracts a symbol inventory, drafts a migration skeleton, then lints claim language before publication. Teams that skip the last step publish promises the repository cannot actually keep.

The failure mode this workflow targets

Migration guides fail when generated prose restates a diff as a guarantee about downstream builds. A removed export is an observable fact; “this release is backward compatible” is a product decision with support cost. Models compress those two classes into the same paragraph because both look like English. Reviewers then argue about tone instead of about the claim that will trap a release manager.

Git history remains a useful source for inventories because it is cheap, local, and reproducible without a network. It is a poor source for severity, because a one-line rename can break more callers than a large internal rewrite. The method therefore splits work into extract, draft, classify, and lint, with different owners at each stage. Keep those stages in separate files so a fluent paragraph cannot hide an unsigned guarantee.

What a model may draft

A model may rearrange extracted rows into readable sections, propose heading order, and expand a symbol name into a short description. It may also suggest a checklist of caller updates when the inventory already lists the old and new identifiers. None of those drafts should include dates of support, “safe to upgrade” language, or security impact. If the draft needs a verb that implies a promise, replace it with a placeholder token before anyone reads the Markdown as a guide.

Keep the draft input narrow: a YAML inventory, a version pair, and a list of files that changed. Do not paste changelog marketing, issue-tracker sentiment, or unreviewed chat summaries into that prompt. Wider context increases fluency while also increasing unauthorized claims about compatibility. Label the prompt as a proposal, and store the exact inventory hash beside the draft so reviewers can see drift.

What a human must own

A human must label each inventory row as breaking, behavior, deprecation, or docs-only using the public contract, not the size of the diff. A human must write the upgrade command that the project will actually support, including the rollback path if the upgrade fails. A human must set the support window for the old symbol, or explicitly state that no window exists. Those three fields are the release, not decorative metadata around generated headings.

Security, licensing, and data-retention sentences are never draftable under this method. If the model emits them, the linter must fail the build rather than ask a reviewer to notice. Compatibility with named downstream frameworks is also human-owned, because it requires evidence from a test matrix the generator did not run. Unsigned sentences in those classes should block the tag, not wait for a documentation follow-up issue.

Workflow

1. Freeze the version pair and the public surface

Record the previous tag and the candidate revision before any prose is written. Export the public surface from tests or from an explicit __all__ list so the inventory cannot drift into private helpers. Store both facts in a small directory that CI can hash on every documentation job.

mkdir -p .migration
git rev-parse v1.4.0 > .migration/from.rev
git rev-parse HEAD    > .migration/to.rev
python -c "import json, mypkg; print(json.dumps(sorted(mypkg.__all__)))" \
  > .migration/public_surface.json
sha256sum .migration/* > .migration/inputs.sha256
Enter fullscreen mode Exit fullscreen mode

That trio is the only allowed source for later extraction. If a symbol is absent from public_surface.json, it does not belong in the customer migration guide. Private helpers can still change; they simply do not earn a compatibility sentence.

2. Extract a symbol inventory from the diff

Run a deterministic extractor against the two revisions and write YAML, not Markdown. Markdown invites early narration; YAML keeps rows classifiable later. The script below is a starting point for Python packages that export names from a single surface file. Treat it as a labeled example, not as a parser for every language in a monorepo.

# extract_migration_inventory.py — proposal for a single Python export surface
import ast, json, subprocess, sys, yaml
from pathlib import Path

SURFACE = Path(".migration/public_surface.json")
FROM_REV = Path(".migration/from.rev").read_text().strip()
TO_REV = Path(".migration/to.rev").read_text().strip()
TARGET = "src/mypkg/__init__.py"

def names_at(rev: str) -> set[str]:
    blob = subprocess.check_output(["git", "show", f"{rev}:{TARGET}"], text=True)
    tree = ast.parse(blob)
    public = set(json.loads(SURFACE.read_text()))
    found = set()
    for node in tree.body:
        if isinstance(node, ast.Assign):
            for t in node.targets:
                if isinstance(t, ast.Name) and t.id in public:
                    found.add(t.id)
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
            if node.name in public:
                found.add(node.name)
    return found

old, new = names_at(FROM_REV), names_at(TO_REV)
rows = []
for name in sorted(old | new):
    if name in old and name not in new:
        kind = "removed"
    elif name not in old and name in new:
        kind = "added"
    else:
        kind = "unchanged"
    if kind == "unchanged":
        continue
    rows.append({
        "symbol": name,
        "change": kind,
        "class": None,          # human-owned
        "support_window": None, # human-owned
        "rollback": None,       # human-owned
        "owned_by": None,
    })
Path(".migration/inventory.yaml").write_text(yaml.safe_dump(rows, sort_keys=False))
print(f"wrote {len(rows)} rows", file=sys.stderr)
Enter fullscreen mode Exit fullscreen mode

Run it from a clean worktree so uncommitted edits cannot leak into the inventory. Commit inventory.yaml with empty human fields rather than with guessed classes. Empty fields are a visible queue; guessed classes look finished and skip review.

python extract_migration_inventory.py
git add .migration/inventory.yaml
Enter fullscreen mode Exit fullscreen mode

3. Draft only the narrative scaffolding

Hand the YAML to a drafting model with a hard instruction: no compatibility verbs, no version support dates, no “users should be safe.” The allowed output is headings, bullet restatements of rows, and placeholder tokens such as {{SUPPORT_WINDOW}}. Placeholder tokens make missing human work visible in review, which is more useful than a polished paragraph that hides a missing owner.

Proposal prompt, not an executed production prompt:

Using only inventory.yaml, write MIGRATION.draft.md.
Restate each row as a bullet. Do not classify breakingness.
Do not write support dates, rollback advice, or security impact.
Insert {{CLASS}}, {{SUPPORT_WINDOW}}, and {{ROLLBACK}} tokens
wherever a human decision is required. Quote symbol names verbatim.
Enter fullscreen mode Exit fullscreen mode

If you use a hosted editor for that draft pass, keep the inventory file as the only attachment. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access and free server option can generate that scaffolding from the YAML inventory without requiring a paid seat for the draft step. The classification table and the linter still run locally in CI, which is the point of splitting ownership.

4. Classify every row with a decision table

Fill class, support_window, rollback, and owned_by by hand. Do not accept model-suggested classes unless a test in the repository demonstrates the behavior change. The table below is the entire policy for this workflow, and it should live next to the inventory rather than in a style guide nobody opens during a release.

Evidence in the two revisions Required class Who may write the sentence Block merge if missing
Public symbol removed or renamed breaking Human Support window and rollback
Same signature, default or error mapping changed, proven by a test behavior Human Test path cited in the row
Symbol still present, documented as going away later deprecation Human Removal version or “no date”
Docs, examples, or heading text only docs-only Model draft allowed None
Auth, crypto, privacy, license, or personal data never draftable Human only Entire section
Named downstream framework compatibility never from a diff Human only CI matrix job name

After classification, copy tokens out of the draft and into a second file named MIGRATION.md. Keep MIGRATION.draft.md unshipped so generated scaffolding cannot be installed as the user guide. The owned file is what packaging, GitHub releases, and the documentation site may render.

# .migration/inventory.yaml — human fields filled after the draft pass
- symbol: load_client
  change: removed
  class: breaking
  support_window: "none; removed in 2.0.0"
  rollback: "pin mypkg==1.4.x and import load_client"
  owned_by: "release-owner@example.com"
- symbol: Client.from_config
  change: added
  class: docs-only
  support_window: n/a
  rollback: n/a
  owned_by: "release-owner@example.com"
Enter fullscreen mode Exit fullscreen mode

5. Lint claim language before merge

Scan the owned Markdown for claim classes that require a signature. Fail CI when a sentence matches and the adjacent HTML comment lacks owned-by. This is mechanical, not stylistic, and it should run on the same job that builds the documentation site.

# lint_migration_claims.py
import re, sys
from pathlib import Path

CLAIM = re.compile(
    r"\b(backward compatible|backwards-compatible|safe to upgrade|"
    r"no breaking changes|always|never|guaranteed|supported until|"
    r"security|personally identifiable|GDPR|SOC 2)\b",
    re.I,
)
OWNED = re.compile(r"<!-- owned-by: .+ -->")

text = Path("MIGRATION.md").read_text()
failures = []
for i, line in enumerate(text.splitlines(), 1):
    if CLAIM.search(line) and not OWNED.search(line):
        failures.append(f"L{i}: unsigned claim: {line.strip()}")

if failures:
    print("\n".join(failures))
    sys.exit(1)
print("migration claims signed")
Enter fullscreen mode Exit fullscreen mode
python lint_migration_claims.py
# wire the same command into documentation CI, not into a laptop alias
Enter fullscreen mode Exit fullscreen mode

A signed line looks like the following example, which CI can grep without parsing English. Unsigned synonyms will still slip through, so treat the regex as a net, not as a contract lawyer.

`load_client` is removed in 2.0.0 with no support window.
<!-- owned-by: release-owner@example.com -->
Enter fullscreen mode Exit fullscreen mode

A compact owned guide, after lint

The shipped file should read as inventory plus signed decisions, not as a narrative of the sprint. The example below is short on purpose, because extra adjectives are where unsigned claims hide. Expand only after the linter is green.

# Migration 1.4.x to 2.0.0

## Breaking

- Replace `load_client(...)` with `Client.from_config(...)`.
  Support window: none; removed in 2.0.0.
  Rollback: pin `mypkg==1.4.x`.
  <!-- owned-by: release-owner@example.com -->

## Added

- `Client.from_config` is the supported constructor for file-based setup.
Enter fullscreen mode Exit fullscreen mode

Limitations

The extractor sees names, not behavior. A function that keeps its signature while changing a default timeout will not appear as breaking. The linter sees words, not legal meaning, so a careful paraphrase can evade the regex. Neither tool replaces a contract test suite that installs the previous client against the new server.

Git diffs also miss generated code and submodule pins unless those paths are included explicitly. Teams with multiple public languages need one inventory per language, because a Python rename does not describe the REST resource. The method assumes a tagged history; trunk-only repositories must invent a comparable freeze, or the version pair is fiction.

Who should not use this approach

Do not use this workflow to generate security advisories, SLA text, or statements about personal data. Do not use it when the public contract lives only in tribal knowledge and there is no __all__, OpenAPI file, or export map. Do not use it as evidence that a release is compatible because the linter passed.

Internal tools with a single caller may skip the prose draft entirely and ship the YAML inventory. Regulated products should treat every migration sentence as human-owned and should not introduce a model into the path at all. If the team cannot name an owner email for breaking rows, stop before the draft step.

Closing

Migration documentation stays honest when inventories are compiled and claims are signed as separate artifacts. Keep the generator on headings and restatements, and keep humans on breaking labels, support windows, and rollback. If a workspace already drafts the inventory sections, store the ownership table and the linter in the repository rather than in a chat transcript.

Top comments (0)