DEV Community

Avery Lin
Avery Lin

Posted on

Build a Local-Dev Command Inventory From Task Files; Keep Data-Risk Copy Human-Owned

Generated getting-started documents fail when task names are treated as permission to describe production effects. A model can inventory Makefile targets, Compose services, and package scripts with high recall, then draft command blocks that match those names. Humans must still own every sentence that asserts data safety, cost, irreversibility, or fitness for a shared environment. This article proposes a compile-then-sign workflow that keeps those two jobs from collapsing into one chat transcript.

Task files are facts about names, not facts about risk

Makefiles, package.json scripts, and Compose files remain unusually good sources for a documentation inventory of local commands. They already list the verbs a repository exposes, the services it boots, and the flags that local automation actually accepts. Those files do not record whether make reset drops a volume that still holds customer exports from a prior debugging session. They also omit whether a Compose override binds a database port onto a shared workstation that other people still use.

A drafting model that reads only the target name will often invent a reassuring clause so the tutorial feels complete. Reviewers then spend review time arguing about tone instead of checking whether the command is reversible at all. The practical split is therefore mechanical rather than stylistic, and it can be enforced in ordinary continuous integration. The model may draft headings, command fences, and argument tables that stay traceable to a frozen inventory file.

The human must write, or at least sign, any paragraph that talks about data, money, downtime, identity, or other people's machines. If a sentence cannot be traced to the inventory and is not stored in a signed section, it does not ship. That rule sounds harsh until a generated tutorial tells a new contributor to run make prune against a cloned production volume. The inventory would have listed prune; only a human can say that the target is forbidden on shared data.

Ownership map

The table below is an editorial contract, not a writing-style preference for tutorials. Rows marked draft may be produced from the inventory without additional narrative claims. Rows marked sign stay in a human-owned file that the pull request must still contain. Mixing a draftable command name with an undraftable safety claim moves the whole cell into the sign column.

Doc fragment Source of truth Model may draft? Human must sign
Command name and working directory Makefile, Justfile, package scripts Yes Spelling of the name only
Flags that appear in the task recipe Recipe text committed in-repo Yes, listed, not interpreted Meaning of destructive flags
Service name and Compose-published port docker-compose*.yml / compose.yml Yes Whether that port is reachable beyond localhost
Environment variable names Keys in .env.example only Yes Values, rotation, and secret storage
“Safe to run on a laptop with prod data” Nothing in task files No Always
Volume delete, migrate, force-push, drop Target name is a hint only No Always
Cost, quota, license, export control Policy docs, not task files No Always
“Idempotent”, “no downtime”, “will not charge” Nothing in task files No Always

make migrate may appear inside a command fence that the inventory can justify. The sentence that says the migration can be re-run on production data may not appear in the draft file. Treat that second sentence as signed copy even when it would read more smoothly beside the command.

Step 1: Freeze an inventory from the repository

Run the extractor in continuous integration so the inventory is a build artifact rather than a chat memory. The script below is labeled as a proposal for a small Python 3 tool with no network calls. It never opens .env files that might hold values, and it only reads .env.example keys. Commit both the script and the generated docs/inventory.json so reviewers can diff names without rereading Makefiles.

#!/usr/bin/env python3
"""inventory_tasks.py — proposal: compile local-dev names into JSON."""
from __future__ import annotations

import json
import re
from pathlib import Path

ROOT = Path(".").resolve()
MAKE_TARGET = re.compile(r"^([a-zA-Z0-9][a-zA-Z0-9_./-]*):", re.M)


def makefile_targets(text: str) -> list[str]:
    names: list[str] = []
    for match in MAKE_TARGET.finditer(text):
        name = match.group(1)
        if name.isupper() or name.startswith("."):
            continue
        names.append(name)
    return sorted(set(names))


def package_scripts(text: str) -> list[str]:
    scripts = json.loads(text).get("scripts") or {}
    return sorted(str(name) for name in scripts)


def compose_services(text: str) -> list[dict[str, object]]:
    """Proposal parser for a common two-space Compose subset only."""
    services: list[dict[str, object]] = []
    current: dict[str, object] | None = None
    in_services = False
    in_ports = False
    for line in text.splitlines():
        if re.match(r"^services:\s*$", line):
            in_services = True
            continue
        if in_services and re.match(r"^[A-Za-z]", line):
            break
        if not in_services:
            continue
        svc = re.match(r"^  ([a-zA-Z0-9._-]+):\s*$", line)
        if svc:
            current = {"name": svc.group(1), "ports": []}
            services.append(current)
            in_ports = False
            continue
        if current is None:
            continue
        if re.match(r"^    ports:\s*$", line):
            in_ports = True
            continue
        if in_ports:
            port = re.match(r"^      -\s+[\"']?(\d+)", line)
            if port:
                ports = current["ports"]
                assert isinstance(ports, list)
                ports.append(port.group(1))
            elif re.match(r"^    [A-Za-z]", line):
                in_ports = False
    return services


def env_example_keys(text: str) -> list[str]:
    keys: list[str] = []
    for raw in text.splitlines():
        line = raw.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key = line.split("=", 1)[0].strip()
        if key:
            keys.append(key)
    return sorted(set(keys))


def main() -> None:
    inventory = {
        "makefile_targets": [],
        "package_scripts": [],
        "compose_services": [],
        "env_names": [],
    }
    make = ROOT / "Makefile"
    if make.exists():
        inventory["makefile_targets"] = makefile_targets(make.read_text(encoding="utf-8"))
    pkg = ROOT / "package.json"
    if pkg.exists():
        inventory["package_scripts"] = package_scripts(pkg.read_text(encoding="utf-8"))
    for name in ("compose.yml", "docker-compose.yml"):
        path = ROOT / name
        if path.exists():
            inventory["compose_services"] = compose_services(
                path.read_text(encoding="utf-8")
            )
            break
    envf = ROOT / ".env.example"
    if envf.exists():
        inventory["env_names"] = env_example_keys(envf.read_text(encoding="utf-8"))
    out = ROOT / "docs" / "inventory.json"
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(json.dumps(inventory, indent=2) + "\n", encoding="utf-8")
    print(f"wrote {out}")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

A checked-in Makefile can expose the same extractor without hiding it inside a chat prompt. The commands below assume the script lives at tools/inventory_tasks.py and that Python 3 is already on the worker image.

.PHONY: docs-inventory docs-gate

docs-inventory:
    python3 tools/inventory_tasks.py

docs-gate: docs-inventory
    python3 tools/gate_getting_started.py
Enter fullscreen mode Exit fullscreen mode

After the script runs, treat any command absent from the JSON as ineligible for a generated fence. Regenerate the inventory on every pull request that touches task files, Compose files, or .env.example. If the JSON diff surprises the author, the getting-started draft is already out of date and should be rebuilt. Do not hand-edit the JSON to hide a dangerous target; hide it by renaming or by moving it out of the default Makefile.

Sample inputs help reviewers see what the inventory will contain before they read the parser. The fragments below are unlabeled fixtures, not a claim about any particular product repository.

.PHONY: test lint reset prune migrate

test:
    pytest -q

lint:
    ruff check .

reset:
    docker compose down -v

prune:
    docker volume prune -f

migrate:
    python3 -m app.migrate
Enter fullscreen mode Exit fullscreen mode
# compose.yml fixture
services:
  web:
    ports:
      - "3000:3000"
  db:
    ports:
      - "5432:5432"
Enter fullscreen mode Exit fullscreen mode
# .env.example — names only; never commit values
DATABASE_URL=
STRIPE_SECRET_KEY=
Enter fullscreen mode Exit fullscreen mode

The resulting docs/inventory.json should list reset, prune, and migrate beside test and lint. That listing is useful because it makes the dangerous names visible instead of leaving them as folklore in a maintainer’s head. Visibility is not permission; the signed risk file still has to say which of those names a new contributor may run.

Step 2: Split command copy from signed risk copy

Create two Markdown paths and refuse to let the drafter open the second path. docs/getting-started.commands.md is the only file a model may write during the drafting pass. docs/getting-started.risks.md is human-owned, reviewed like application code, and linked from the command file. A thin docs/getting-started.md page can include both files so readers still see one narrative in the rendered site.

<!-- docs/getting-started.md -->
# Local development

Command names below come from the frozen inventory. Risk notes are human-signed.

{% include "getting-started.commands.md" %}
{% include "getting-started.risks.md" %}
Enter fullscreen mode Exit fullscreen mode

The include page is allowed to contain one sentence of orientation and the two includes, nothing else. Orientation copy that describes risk belongs in the signed file even if it would read more smoothly beside the command. That friction is the point: smoothness is how unsigned safety claims enter generated tutorials. If your static site generator cannot include fragments, concatenate the two files in CI after the gate passes.

A signed risk file does not need to be long, but it does need to name the irreversible targets explicitly. The example below is labeled as sample copy for the fixture Makefile, not as advice for a live cluster.

<!-- docs/getting-started.risks.md — HUMAN OWNED; drafter must not write this path -->
## Data risk for local commands

`make test` and `make lint` read the repository and should not touch Docker volumes.

`make reset` runs `docker compose down -v` and deletes Compose volumes for this project directory.
Do not run it against a clone that still holds imported customer exports or shared database dumps.

`make prune` runs `docker volume prune -f` and can delete unused volumes outside this project.
Treat it as forbidden on shared workstations until a maintainer confirms the Docker context.

`make migrate` is not documented here as idempotent or production-safe. Sign a runbook before using it on shared data.

Environment value for `STRIPE_SECRET_KEY` must come from a secret store, never from the command tutorial.
Enter fullscreen mode Exit fullscreen mode

Step 3: Check in a drafter brief, then optionally run a model

Check a drafter brief into the repository so the allowed behavior is reviewed like any other policy file. The brief must name the inventory path, the output path, and the claim types that are forbidden in the output. It must also require a stub link to the signed risk file after every command table the model emits. Paste-based prompting is optional; the brief is not, because it is the contract the gate will later assume.

# docs/drafter-brief.md

Write Markdown only to docs/getting-started.commands.md.
Every fenced command must use a name present in docs/inventory.json.
List flags only when they already appear in the inventory sources.
Do not mention production, safety, cost, PII, downtime, or idempotence.
Do not invent ports, env values, or hostnames.
After each command table, link to docs/getting-started.risks.md without summarizing it.
Enter fullscreen mode Exit fullscreen mode

A local extractor does not need a hosted model, and many teams should stop after the inventory and the gate. Disclosure: This article was prepared as part of MonkeyCode's product outreach. When a drafting pass is useful, MonkeyCode's free model access and free server option can run against the inventory file. That keeps the allowlist in git and avoids pasting the whole repository into an unbounded chat window. The product does not decide which sentences are safe to publish; the signed risk file still ships with the commands. If that environment is already in your toolchain, point the drafter at docs/inventory.json and keep the human-owned risk file in the same pull request.

Step 4: Reject drafts that smuggle safety claims

The gate has two jobs: every fenced command must exist in the inventory, and forbidden claim patterns must not appear in the draft file. A third check confirms that the signed risk file still exists and is not empty when the command file is non-empty. None of these checks prove that the human text is correct; they only prove that the model stayed in its lane. Correctness of risk copy remains a review problem, which is cheaper when the draft cannot hide inside it.

#!/usr/bin/env python3
"""gate_getting_started.py — fail CI when drafts leave the inventory lane."""
from __future__ import annotations

import json
import re
import sys
from pathlib import Path

ROOT = Path(".").resolve()
INVENTORY = ROOT / "docs" / "inventory.json"
DRAFT = ROOT / "docs" / "getting-started.commands.md"
SIGNED = ROOT / "docs" / "getting-started.risks.md"

FENCE_CMD = re.compile(
    r"^```

(?:bash|sh|zsh|shell|make)?\n(.*?)

```",
    re.M | re.S,
)
TOKEN = re.compile(r"\b(make|npm run|pnpm|yarn|docker compose)\s+([a-zA-Z0-9:_-]+)")
FORBIDDEN = re.compile(
    r"\b(safe to run|production|prod data|pii|gdpr|hipaa|idempotent|"
    r"no downtime|will not charge|will not delete|harmless|ignore this)\b",
    re.I,
)


def load_allowed() -> set[str]:
    data = json.loads(INVENTORY.read_text(encoding="utf-8"))
    allowed = set(data.get("makefile_targets") or [])
    allowed.update(data.get("package_scripts") or [])
    for svc in data.get("compose_services") or []:
        allowed.add(str(svc["name"]))
    return allowed


def extract_invoked_names(markdown: str) -> set[str]:
    names: set[str] = set()
    for block in FENCE_CMD.findall(markdown):
        for _, name in TOKEN.findall(block):
            names.add(name)
    return names


def main() -> int:
    if not INVENTORY.exists():
        print("missing docs/inventory.json", file=sys.stderr)
        return 1
    if not DRAFT.exists():
        print("missing docs/getting-started.commands.md", file=sys.stderr)
        return 1
    draft = DRAFT.read_text(encoding="utf-8")
    if draft.strip() and (not SIGNED.exists() or not SIGNED.read_text(encoding="utf-8").strip()):
        print("non-empty draft requires non-empty docs/getting-started.risks.md", file=sys.stderr)
        return 1
    if FORBIDDEN.search(draft):
        print("draft contains forbidden safety or production claims", file=sys.stderr)
        return 1
    allowed = load_allowed()
    unknown = sorted(extract_invoked_names(draft) - allowed)
    if unknown:
        print("commands not in inventory: " + ", ".join(unknown), file=sys.stderr)
        return 1
    print("getting-started gate passed")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

A small unit test keeps the forbidden list honest when somebody later “softens” the regex. The test is a proposal you can run with the standard library; it does not claim coverage of every euphemism a model might emit.

# tests/test_gate_getting_started.py — proposal
from pathlib import Path
import sys

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
import gate_getting_started as gate  # noqa: E402


def test_forbidden_matches_common_overclaims() -> None:
    assert gate.FORBIDDEN.search("This is safe to run on production dumps.")
    assert gate.FORBIDDEN.search("The migrate target is idempotent.")
    assert not gate.FORBIDDEN.search("Run make test from the repository root.")
Enter fullscreen mode Exit fullscreen mode

Wire the gate to the same CI job that regenerates the inventory so a stale JSON cannot bless a stale tutorial. A typical Makefile target is docs-gate, invoked after unit tests and before any documentation publisher. Fail the pull request on a gate miss; do not warn, because warnings accumulate around getting-started files. If a new destructive target is added to the Makefile, the inventory diff and the missing risk paragraph should land together.

# .github/workflows/docs-gate.yml — proposal
name: docs-gate
on:
  pull_request:
    paths:
      - "Makefile"
      - "package.json"
      - "compose.yml"
      - "docker-compose.yml"
      - ".env.example"
      - "docs/**"
      - "tools/inventory_tasks.py"
      - "tools/gate_getting_started.py"
jobs:
  gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: python3 tools/inventory_tasks.py
      - run: python3 tools/gate_getting_started.py
Enter fullscreen mode Exit fullscreen mode

Limitations

The Makefile parser skips uppercase dummy targets and dotted targets, and it does not expand include directives. The Compose parser understands a common two-space subset and will miss anchors, extensions, and unusual indentation. Neither parser reads remote Compose files, encrypted SOPS documents, or task runners such as Just, Mage, or Bazel. Teams that rely on those tools should replace the extractor, not loosen the gate so unmatched commands can slip through.

Forbidden-phrase lists are brittle in the usual ways: a model can say “harmless” instead of “safe” and walk around the regex. Expand the list from real review misses, and still require a human to read the signed file. The workflow also does not measure tutorial quality, onboarding time, or whether a command succeeded on a clean machine. Those measurements need a separate harness and should not be inferred from a green documentation gate.

Inventory membership also cannot encode working-directory subtleties that live only in shell history. A target named test might run against a remote database when DATABASE_URL points outside the laptop, even though the Makefile line looks local. The signed risk file has to mention that class of failure; the extractor will not infer it from the target name. If reviewers cannot describe that failure in a short paragraph, the getting-started page should omit the command rather than draft a confident fence.

Who should skip this workflow

Do not use this workflow when the getting-started guide is also the production runbook for a shared cluster. Do not use it to generate legal, medical, financial, or compliance statements, including data-retention and export claims. Do not use it when the repository cannot store a human-owned Markdown file beside the generated command file. Skip the drafting pass entirely if the inventory already fits in a hand-maintained table of under twenty commands.

The core conclusion does not depend on a particular model host or on a particular documentation site generator. Inventory the names a repository already exposes, then refuse to let a drafter narrate the risk of those names. Humans remain the authors of data-risk paragraphs because task files never encoded that risk in the first place. When that split is visible in git, generated getting-started pages stop sounding finished before they are actually safe to publish.

Top comments (0)