2222
2323Historical release notes and example-provenance content (e.g. "written
2424against 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
2736from __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+
432520def _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+
502602def 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+ "\n Align 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