Skip to content

Commit 43da80f

Browse files
authored
Merge pull request #312 from ai-agent-assembly/v0.0.1/AAASM-4719/harden_version_generator
[AAASM-4719] ♻️ (examples): Own every SDK-version surface + orphan-literal audit
2 parents 9f4f7fb + cdbf0fe commit 43da80f

5 files changed

Lines changed: 513 additions & 1 deletion

File tree

.github/workflows/example-metadata-check.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ on:
1919
- "snippets/**"
2020
- "scenarios/**/pyproject.toml"
2121
- "scenarios/**/package.json"
22+
- "scenarios/**/go.mod"
23+
- "scenarios/**/Dockerfile"
2224
- "scenarios/**/README.md"
25+
- "scripts/test_generate_example_metadata.py"
2326
- ".github/workflows/example-metadata-check.yml"
2427
push:
2528
branches:
@@ -60,3 +63,9 @@ jobs:
6063
echo "::error:: python scripts/extract_snippets.py"
6164
exit 1
6265
fi
66+
67+
- name: Audit for orphan SDK-version literals
68+
run: python scripts/generate_example_metadata.py --check
69+
70+
- name: Run generator unit tests
71+
run: python -m unittest scripts.test_generate_example_metadata

go/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ All examples use the [`github.com/ai-agent-assembly/go-sdk`](https://pkg.go.dev/
3030
## Prerequisites
3131

3232
- Go >= 1.26
33-
- Agent Assembly Go SDK v0.0.1-rc.3
33+
- Agent Assembly Go SDK v0.0.1-rc.5
3434

3535
A live gateway is **not required** to run any of these examples — each uses an
3636
offline mock `GovernanceClient` by default so you can explore governance behavior

scripts/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Repo tooling package.
2+
3+
Exists so the metadata generator's unit tests are importable as
4+
``scripts.test_generate_example_metadata`` (``python -m unittest``). The
5+
generator and snippet extractors are also runnable directly by path, which does
6+
not depend on this marker.
7+
"""

scripts/generate_example_metadata.py

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@
2222
2323
Historical release notes and example-provenance content (e.g. "written
2424
against 0.0.1-rc.3") are out of scope and must not be touched.
25+
26+
Audit mode (``--check``): instead of rewriting, scan the same bounded set of
27+
human-authored surfaces (manifest pins under ``python/ node/ go/ scenarios/``
28+
and SDK-version prose in READMEs / ``docs/*.md`` — never lockfiles or vendored
29+
trees) and exit non-zero, naming each ``file:line``, if any SDK-version literal
30+
has drifted from the SoT. This is the anti-recurrence invariant: a newly added
31+
stale surface fails CI. Legitimate historical/provenance text is exempted per
32+
line by the inline marker ``sdk-version-exempt``; such a line is never rewritten
33+
and never audited.
2534
"""
2635

2736
from __future__ import annotations
@@ -251,6 +260,32 @@ def _sub(match: re.Match[str]) -> str:
251260
return _write_if_changed(path, new_text)
252261

253262

263+
# ---------------------------------------------------------------------------
264+
# Dockerfile pin rewrites
265+
# ---------------------------------------------------------------------------
266+
#
267+
# A handful of scenario Dockerfiles ``pip install`` the published Python SDK by
268+
# an exact ``agent-assembly==<version>`` pin (the live-core-enforcement
269+
# python-agent image). Left to hand-maintenance these drift on every SDK bump —
270+
# AAASM-4702 had to hand-correct one. The generator now owns that pin too so
271+
# future bumps (and the --check audit) cover it. The negative lookbehind anchors
272+
# the match to the standalone ``agent-assembly`` project name so it can never
273+
# fire on ``@agent-assembly/sdk`` or ``github.com/ai-agent-assembly/...``.
274+
_DOCKERFILE_PIN_RE = re.compile(
275+
r'(?<![\w/@.-])(?P<pre>agent-assembly==)(?P<ver>[^"\s]+)'
276+
)
277+
278+
279+
def rewrite_dockerfile(path: Path, sdk: PythonSdk) -> bool:
280+
text = path.read_text(encoding="utf-8")
281+
282+
def _sub(match: re.Match[str]) -> str:
283+
return f"{match.group('pre')}{sdk.version}"
284+
285+
new_text = _DOCKERFILE_PIN_RE.sub(_sub, text)
286+
return _write_if_changed(path, new_text)
287+
288+
254289
# ---------------------------------------------------------------------------
255290
# README bounded-block rewrites
256291
# ---------------------------------------------------------------------------
@@ -420,6 +455,41 @@ def _sub(match: re.Match[str]) -> str:
420455
return _write_if_changed(path, new_text)
421456

422457

458+
# Some landing-page READMEs state the same requirement as a Markdown *bullet*
459+
# instead of a table row — ``go/README.md`` carries ``- Agent Assembly Go SDK
460+
# <version>`` under ``## Prerequisites`` (AAASM-4717). The bullet is anchored to
461+
# a list marker (``-``/``*``), so it can never collide with the table row (which
462+
# starts with ``|``) nor with the generated sdk-install block. Only the first
463+
# version token in the value is rewritten, so a trailing note is preserved
464+
# verbatim, exactly like the table-row pass.
465+
def _prereq_bullet_re(label: str) -> re.Pattern[str]:
466+
return re.compile(
467+
r"^(?P<pre>[ \t]*[-*][ \t]+Agent Assembly "
468+
+ re.escape(label)
469+
+ r" SDK[ \t]+)(?P<val>.*)$",
470+
re.MULTILINE,
471+
)
472+
473+
474+
def rewrite_prereq_bullet(path: Path, label: str, version: str) -> bool:
475+
"""Align a bullet-form ``- Agent Assembly <label> SDK <version>`` line.
476+
477+
The list-item sibling of ``rewrite_prereq_row``. A no-op for READMEs that
478+
state the requirement as a table row or omit it entirely; when the bullet is
479+
present only its first version token is aligned with the SoT.
480+
"""
481+
482+
text = path.read_text(encoding="utf-8")
483+
bullet_re = _prereq_bullet_re(label)
484+
485+
def _sub(match: re.Match[str]) -> str:
486+
new_val = _VERSION_TOKEN_RE.sub(version, match.group("val"), count=1)
487+
return f"{match.group('pre')}{new_val}"
488+
489+
new_text = bullet_re.sub(_sub, text)
490+
return _write_if_changed(path, new_text)
491+
492+
423493
# ---------------------------------------------------------------------------
424494
# Directory walkers
425495
# ---------------------------------------------------------------------------
@@ -429,6 +499,24 @@ def _sub(match: re.Match[str]) -> str:
429499
_README = "README.md"
430500

431501

502+
def _globbed(repo_root: Path, patterns: tuple[str, ...]) -> list[Path]:
503+
"""Return the de-duplicated files matched by a set of bounded globs.
504+
505+
The patterns are explicit (no ``**`` recursion) so vendored trees
506+
(``node_modules``, ``.venv``, ``dist``, build contexts) can never leak into
507+
the generator's or the audit's scope.
508+
"""
509+
510+
seen: set[Path] = set()
511+
out: list[Path] = []
512+
for pattern in patterns:
513+
for path in sorted(repo_root.glob(pattern)):
514+
if path.is_file() and path not in seen:
515+
seen.add(path)
516+
out.append(path)
517+
return out
518+
519+
432520
def _python_subprojects(repo_root: Path) -> list[Path]:
433521
"""Return every directory that holds a python `pyproject.toml` in scope."""
434522

@@ -499,6 +587,18 @@ def _go_subprojects(repo_root: Path) -> list[Path]:
499587
return out
500588

501589

590+
# Bounded, non-recursive walk over the scenario Dockerfiles: a scenario may keep
591+
# its Dockerfile at ``scenarios/<scenario>/Dockerfile`` or one level deeper at
592+
# ``scenarios/<scenario>/<component>/Dockerfile`` (e.g. the python-agent image).
593+
_DOCKERFILE_GLOBS = ("scenarios/*/Dockerfile", "scenarios/*/*/Dockerfile")
594+
595+
596+
def _dockerfiles(repo_root: Path) -> list[Path]:
597+
"""Return every in-scope scenario Dockerfile."""
598+
599+
return _globbed(repo_root, _DOCKERFILE_GLOBS)
600+
601+
502602
def process_python(repo_root: Path, versions: SdkVersions) -> list[Path]:
503603
"""Rewrite all in-scope python manifests and READMEs.
504604
@@ -557,6 +657,19 @@ def process_go(repo_root: Path, versions: SdkVersions) -> list[Path]:
557657
return changed
558658

559659

660+
def process_dockerfiles(repo_root: Path, versions: SdkVersions) -> list[Path]:
661+
"""Align the ``agent-assembly==`` pin in every in-scope Dockerfile.
662+
663+
Returns the list of files that were rewritten.
664+
"""
665+
666+
changed: list[Path] = []
667+
for dockerfile in _dockerfiles(repo_root):
668+
if rewrite_dockerfile(dockerfile, versions.python):
669+
changed.append(dockerfile)
670+
return changed
671+
672+
560673
# Bounded set of README locations that may carry a prose ``## Prerequisites``
561674
# table: the per-example READMEs, the language landing pages, and the scenario
562675
# READMEs (both scenario-level and per-agent). Kept as explicit globs rather
@@ -605,11 +718,146 @@ def process_prereq_rows(repo_root: Path, versions: SdkVersions) -> list[Path]:
605718
for label, version in labels:
606719
if rewrite_prereq_row(readme, label, version):
607720
touched = True
721+
if rewrite_prereq_bullet(readme, label, version):
722+
touched = True
608723
if touched:
609724
changed.append(readme)
610725
return changed
611726

612727

728+
# ---------------------------------------------------------------------------
729+
# Orphan-version-literal audit (--check)
730+
# ---------------------------------------------------------------------------
731+
#
732+
# The rewriters above make the generator the single *writer* of every SDK-version
733+
# literal it knows about; this audit is the matching *reader* that fails CI when
734+
# a new human-authored surface introduces a literal the generator does not yet
735+
# own, or when someone hand-edits an owned literal out of sync. It scans a
736+
# bounded set of human-authored surfaces — never lockfiles (``go.sum``,
737+
# ``pnpm-lock.yaml``), vendored trees, or build output — and reports each
738+
# ``file:line`` whose SDK-version literal differs from the SoT.
739+
#
740+
# Exemption: any line containing the inline marker ``sdk-version-exempt`` is
741+
# skipped, for legitimate historical / provenance text (e.g. a release note or
742+
# a "written against 0.0.1-rc.3" verification report).
743+
744+
EXEMPT_MARKER = "sdk-version-exempt"
745+
746+
# Which SDK a prose line refers to, so its version token can be compared against
747+
# the right SoT entry.
748+
_PROSE_LABEL_RE = re.compile(r"Agent Assembly (Python|Node\.js|Go) SDK")
749+
750+
# Precise main-SDK pin matchers for the manifest audit. Each captures the pinned
751+
# version in group ``ver``. The identifiers are anchored so a subpackage
752+
# (``@agent-assembly/runtime-*``) or a non-``go-sdk`` org path
753+
# (``github.com/ai-agent-assembly/...``) can never false-match: the python id is
754+
# ``agent-assembly`` immediately followed by a PEP 440 operator; the node id is
755+
# exactly ``@agent-assembly/sdk``; the go id is exactly
756+
# ``github.com/ai-agent-assembly/go-sdk`` followed by whitespace.
757+
_PY_PIN_AUDIT_RE = re.compile(
758+
r"(?<![\w/@.-])agent-assembly(?:==|>=|~=|<=|!=|<|>)(?P<ver>[^\"'\s,]+)"
759+
)
760+
_NODE_PIN_AUDIT_RE = re.compile(r'"@agent-assembly/sdk"\s*:\s*"(?P<ver>[^"]+)"')
761+
_GO_PIN_AUDIT_RE = re.compile(
762+
r"github\.com/ai-agent-assembly/go-sdk(?=\s)\s+(?P<ver>\S+)"
763+
)
764+
765+
# Bounded globs for the pin audit — every manifest that carries a main-SDK pin
766+
# under python/, node/, go/, and scenarios/. Lockfiles and go.sum are excluded
767+
# by construction (never globbed).
768+
_PY_PIN_GLOBS = (
769+
"python/*/pyproject.toml",
770+
"scenarios/*/pyproject.toml",
771+
"scenarios/*/*/pyproject.toml",
772+
)
773+
_NODE_PIN_GLOBS = (
774+
"node/*/package.json",
775+
"scenarios/*/package.json",
776+
"scenarios/*/*/package.json",
777+
)
778+
_GO_PIN_GLOBS = (
779+
"go/*/go.mod",
780+
"scenarios/*/go.mod",
781+
"scenarios/*/*/go.mod",
782+
)
783+
784+
785+
def _audit_lines(path: Path):
786+
"""Yield ``(lineno, line)`` pairs for a text file, 1-based."""
787+
788+
for lineno, line in enumerate(
789+
path.read_text(encoding="utf-8").splitlines(), start=1
790+
):
791+
yield lineno, line
792+
793+
794+
def _audit_pins(repo_root: Path, versions: SdkVersions) -> list[str]:
795+
"""Report every manifest/Dockerfile pin whose version drifts from the SoT."""
796+
797+
checks = (
798+
(_PY_PIN_GLOBS, _PY_PIN_AUDIT_RE, versions.python.version),
799+
(_NODE_PIN_GLOBS, _NODE_PIN_AUDIT_RE, versions.node.version),
800+
(_GO_PIN_GLOBS, _GO_PIN_AUDIT_RE, versions.go.version),
801+
(_DOCKERFILE_GLOBS, _DOCKERFILE_PIN_RE, versions.python.version),
802+
)
803+
problems: list[str] = []
804+
for globs, pin_re, expected in checks:
805+
for path in _globbed(repo_root, globs):
806+
for lineno, line in _audit_lines(path):
807+
if EXEMPT_MARKER in line:
808+
continue
809+
match = pin_re.search(line)
810+
if match and match.group("ver") != expected:
811+
rel = path.relative_to(repo_root)
812+
problems.append(
813+
f"{rel}:{lineno}: pins {match.group('ver')!r}, "
814+
f"expected {expected!r}"
815+
)
816+
return problems
817+
818+
819+
def _audit_prose(repo_root: Path, versions: SdkVersions) -> list[str]:
820+
"""Report every README/doc prose line whose SDK version drifts from the SoT.
821+
822+
Only lines that both name a language SDK (``Agent Assembly <Lang> SDK``) and
823+
carry a version token are checked; the token is compared like the rewriter's
824+
first-token substitution, so a generator-produced tree always audits clean.
825+
"""
826+
827+
expected_by_label = {
828+
"Python": versions.python.version,
829+
"Node.js": versions.node.version,
830+
"Go": versions.go.version,
831+
}
832+
problems: list[str] = []
833+
for path in _globbed(repo_root, _README_GLOBS + ("docs/*.md",)):
834+
for lineno, line in _audit_lines(path):
835+
if EXEMPT_MARKER in line:
836+
continue
837+
label_match = _PROSE_LABEL_RE.search(line)
838+
if not label_match:
839+
continue
840+
token_match = _VERSION_TOKEN_RE.search(line)
841+
if token_match is None:
842+
continue
843+
expected = expected_by_label[label_match.group(1)]
844+
if token_match.group(0) != expected:
845+
rel = path.relative_to(repo_root)
846+
problems.append(
847+
f"{rel}:{lineno}: states {token_match.group(0)!r}, "
848+
f"expected {expected!r}"
849+
)
850+
return problems
851+
852+
853+
def audit(repo_root: Path, versions: SdkVersions) -> list[str]:
854+
"""Return every drifted SDK-version literal as a sorted ``file:line`` list."""
855+
856+
return sorted(
857+
_audit_pins(repo_root, versions) + _audit_prose(repo_root, versions)
858+
)
859+
860+
613861
# ---------------------------------------------------------------------------
614862
# CLI
615863
# ---------------------------------------------------------------------------
@@ -628,13 +876,38 @@ def main(argv: list[str] | None = None) -> int:
628876
default=_repo_root_from_script(),
629877
help="Path to the repository root (defaults to the script's parent).",
630878
)
879+
parser.add_argument(
880+
"--check",
881+
action="store_true",
882+
help=(
883+
"Audit mode: do not rewrite. Scan the bounded human-authored "
884+
"surfaces and exit non-zero, naming each file:line, if any "
885+
"SDK-version literal has drifted from the SoT."
886+
),
887+
)
631888
args = parser.parse_args(argv)
632889

633890
versions = load_sdk_versions(args.repo_root)
891+
892+
if args.check:
893+
problems = audit(args.repo_root, versions)
894+
if problems:
895+
print("SDK-version literals out of sync with the SoT:")
896+
for problem in problems:
897+
print(f" {problem}")
898+
print(
899+
"\nAlign metadata/sdk-versions.yaml + re-run the generator, or "
900+
f"mark legitimate historical text with '{EXEMPT_MARKER}'."
901+
)
902+
return 1
903+
print("--check: every audited surface matches the SoT.")
904+
return 0
905+
634906
changed: list[Path] = []
635907
changed.extend(process_python(args.repo_root, versions))
636908
changed.extend(process_node(args.repo_root, versions))
637909
changed.extend(process_go(args.repo_root, versions))
910+
changed.extend(process_dockerfiles(args.repo_root, versions))
638911
changed.extend(process_prereq_rows(args.repo_root, versions))
639912

640913
if changed:

0 commit comments

Comments
 (0)