1139 lines
50 KiB
Python
1139 lines
50 KiB
Python
"""skillgen: render graphify's committed skill artifacts from edited fragments.
|
|
|
|
Build-time only. Nothing here ships in the wheel. Fragments under
|
|
``tools/skillgen/fragments/`` are the single source of truth a human edits; the
|
|
files under ``graphify/skill*.md`` and ``graphify/skills/<platform>/references/``
|
|
are generated, committed artifacts. This module renders those artifacts and
|
|
guards them against drift.
|
|
|
|
Usage (from the repo root)::
|
|
|
|
python -m tools.skillgen # regen every platform's artifacts
|
|
python -m tools.skillgen --platform claude
|
|
python -m tools.skillgen --check # byte-diff render vs committed + expected/, exit 1 on drift
|
|
python -m tools.skillgen --audit-coverage# per host: assert every heading of that host's own v8 body single-homes in its render
|
|
python -m tools.skillgen --schema-singleton # assert the file_type enum is byte-identical everywhere
|
|
python -m tools.skillgen --monolith-roundtrip# assert each monolith == v8 modulo the enum unification
|
|
python -m tools.skillgen --always-on-roundtrip# assert each always_on/*.md reproduces its former constant
|
|
python -m tools.skillgen --bless # rewrite expected/ from the current render
|
|
|
|
The render is idempotent: the core template's per-platform slots are filled in a
|
|
fixed order, the reference index is sorted by name, output is LF-newline, and no
|
|
timestamp or version is ever written into a generated file.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from collections import Counter
|
|
try:
|
|
import tomllib # Python 3.11+ stdlib
|
|
except ModuleNotFoundError: # Python 3.10 - graphify supports >=3.10
|
|
import tomli as tomllib # type: ignore[no-redef]
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
# tools/skillgen/gen.py -> repo root is two parents up.
|
|
SKILLGEN_DIR = Path(__file__).resolve().parent
|
|
REPO_ROOT = SKILLGEN_DIR.parent.parent
|
|
FRAGMENTS_DIR = SKILLGEN_DIR / "fragments"
|
|
EXPECTED_DIR = SKILLGEN_DIR / "expected"
|
|
PLATFORMS_TOML = SKILLGEN_DIR / "platforms.toml"
|
|
|
|
# Immutable coverage baseline for --audit-coverage. The working-tree skill bodies
|
|
# are being replaced by the lean core, so the audit reads each host's v8 body
|
|
# straight from git instead of from disk. claude's v8 body is graphify/skill.md;
|
|
# every other split host has its own graphify/skill-<host>.md. Auditing each host
|
|
# against ITS OWN v8 body is the per-host guard: a drop that only hits one host
|
|
# (e.g. trae losing its AGENTS.md integration section) is invisible when every
|
|
# host is checked against claude's monolith, so the audit must be per-host.
|
|
#
|
|
# Baselines are pinned to the immutable pre-split commit SHA, NOT the moving
|
|
# `origin/v8` ref: once the split lands on v8, `origin/v8` no longer holds the
|
|
# original monolith bodies / inline constants, so a symbolic ref would compare
|
|
# the split against itself (vacuous) or fail to find the old constants. The SHA
|
|
# is an ancestor of origin/v8 and is fetched under the CI `fetch-depth: 0`.
|
|
_V8_BASELINE_SHA = "47042beb05d1f6dd2186c0c499ae2840ce604ead"
|
|
|
|
def _v8_baseline_ref(platform_key: str) -> str:
|
|
"""The git ref for a split host's own pre-split skill body."""
|
|
if platform_key == "claude":
|
|
return f"{_V8_BASELINE_SHA}:graphify/skill.md"
|
|
if platform_key == "agents":
|
|
# `agents` is a post-v8 platform with no own v8 body — it re-homes amp's
|
|
# agents-md body at the generic ~/.agents/skills location. Its render is
|
|
# amp's modulo the install/uninstall command wording (prose, not headings),
|
|
# so amp's v8 body is the correct per-host coverage baseline.
|
|
return f"{_V8_BASELINE_SHA}:graphify/skill-amp.md"
|
|
return f"{_V8_BASELINE_SHA}:graphify/skill-{platform_key}.md"
|
|
|
|
# Immutable baseline for --always-on-roundtrip. The six always-on instruction
|
|
# blocks used to be triple-quoted constants in graphify/__main__.py; they are now
|
|
# packaged graphify/always_on/*.md files the module reads at load. This ref points
|
|
# at the pre-extraction source (v8, before the extraction commit on this branch)
|
|
# so the round-trip validator can prove each rendered file reproduces its former
|
|
# constant byte for byte. It deliberately does NOT track HEAD: once the extraction
|
|
# lands, HEAD's constants are _always_on(...) calls, not the literals the
|
|
# validator needs to compare against.
|
|
ALWAYS_ON_BASELINE_REF = f"{_V8_BASELINE_SHA}:graphify/__main__.py"
|
|
|
|
# The always-on instruction blocks: rendered-file basename -> the __main__.py
|
|
# constant it must reproduce. Rendered to graphify/always_on/<basename>.md from
|
|
# the matching fragment under fragments/always-on/. These are not platform-
|
|
# specific, so they render once in a full run (not under --platform).
|
|
ALWAYS_ON_BLOCKS = {
|
|
"claude-md": "_CLAUDE_MD_SECTION",
|
|
"agents-md": "_AGENTS_MD_SECTION",
|
|
"gemini-md": "_GEMINI_MD_SECTION",
|
|
"vscode-instructions": "_VSCODE_INSTRUCTIONS_SECTION",
|
|
"antigravity-rules": "_ANTIGRAVITY_RULES",
|
|
"kiro-steering": "_KIRO_STEERING",
|
|
}
|
|
|
|
# Sanctioned divergences from the frozen always-on baseline above. The roundtrip
|
|
# guard deliberately does NOT track HEAD, so any *intentional* change to an
|
|
# always-on instruction block must be recorded here as an explicit, reviewable
|
|
# old -> new substitution keyed by the baseline constant. The guard applies these
|
|
# to the baseline before the byte-for-byte comparison; anything not covered here
|
|
# still fails the guard, so unrelated drift cannot slip through. Each entry is a
|
|
# one-time, audited edit to the otherwise-immutable v8 baseline.
|
|
ALWAYS_ON_SANCTIONED_EDITS: dict[str, tuple[tuple[str, str], ...]] = {
|
|
# #1530: install guidance must stay host-generic — do not tell agents to
|
|
# invoke a literal `skill` tool with `skill: "graphify"`, which is
|
|
# host-specific and not valid in every environment.
|
|
"_AGENTS_MD_SECTION": (
|
|
(
|
|
"When the user types `/graphify`, invoke the `skill` tool with "
|
|
'`skill: "graphify"` before doing anything else.',
|
|
"When the user types `/graphify`, use the installed graphify skill or instructions "
|
|
"before doing anything else.",
|
|
),
|
|
),
|
|
}
|
|
|
|
# The full six-value file_type enum (Decision A). Every rendered platform — split
|
|
# or monolith — must carry exactly this enum, byte for byte. schema-singleton
|
|
# guards it.
|
|
ENUM_VALUES = "code|document|paper|image|rationale|concept"
|
|
ENUM_PROSE = "`code`, `document`, `paper`, `image`, `rationale`, `concept`"
|
|
|
|
# The eight on-demand references every split platform renders. Six are
|
|
# shared-verbatim; two (extraction-spec, hooks) are variant-selected and resolved
|
|
# per platform from the extraction/hooks_variant fields.
|
|
_SHARED_REFERENCES = {
|
|
"update": "references/shared/update.md",
|
|
"exports": "references/shared/exports.md",
|
|
"github-and-merge": "references/shared/github-and-merge.md",
|
|
"transcribe": "references/shared/transcribe.md",
|
|
"add-watch": "references/shared/add-watch.md",
|
|
}
|
|
_EXTRACTION_SOURCE = {
|
|
"verbose": "references/shared/extraction-spec.md",
|
|
"compact": "references/shared/extraction-spec-compact.md",
|
|
}
|
|
# Single unified query reference + stub: superior vocab-expansion (Step 0) plus
|
|
# CLI traversal plus inline NetworkX fallback, shipped to every platform. The
|
|
# capabilities used to be split across cli.md / cli-inline.md so no platform got
|
|
# both — Claude had expansion but no fallback, the rest had the fallback but the
|
|
# weaker matcher (#1325).
|
|
_QUERY_REFERENCE = "references/query/default.md"
|
|
_QUERY_STUB = "query-stub/default.md"
|
|
# The hooks reference is host-flavored. Most hosts read CLAUDE.md and wire
|
|
# always-on via `graphify claude install` (the shared body). The agents-md hosts
|
|
# (trae, trae-cn, amp) read AGENTS.md and wire it via `graphify <host> install`.
|
|
# The agents-md fragment is a per-host template: the install/uninstall commands,
|
|
# the host display name, the heading suffix, and the PreToolUse caveat are slots
|
|
# filled from _AGENTS_MD_HOOKS per host. trae carries the v8 caveat that Trae does
|
|
# NOT support PreToolUse hooks; amp's v8 had no such caveat, so its slot is empty.
|
|
# Each variant also drives the @@HOOKS_TARGET@@ pointer text in the core. The
|
|
# variant key matches the prose target file the pointer names.
|
|
_HOOKS_SOURCE = {
|
|
"claude-md": "references/shared/hooks.md",
|
|
"agents-md": "references/host/hooks-agents-md.md",
|
|
}
|
|
|
|
# Per-host slots for the agents-md hooks reference template. Rendered EXACTLY as
|
|
# that host's v8 skill body had the "## For native AGENTS.md integration" section.
|
|
# trae's v8 heading carried a "(Trae)" suffix, the trae/trae-cn alt-command
|
|
# comments, and the no-PreToolUse-hooks Note; amp's v8 had a bare heading, a single
|
|
# install/uninstall line, and NO caveat. These are byte-faithful to v8.
|
|
_TRAE_PRETOOLUSE_NOTE = (
|
|
"\n> **Note:** Unlike Claude Code, Trae does NOT support PreToolUse hooks. "
|
|
"The AGENTS.md rules are the always-on mechanism — there is no automatic graph "
|
|
"rebuild on tool use. Run `/graphify --update` manually after code changes if "
|
|
"the graph needs refreshing.\n"
|
|
)
|
|
_AGENTS_MD_HOOKS: dict[str, dict[str, str]] = {
|
|
"trae": {
|
|
"heading_suffix": " (Trae)",
|
|
"host_display": "Trae",
|
|
"install_block": "graphify trae install # or: graphify trae-cn install",
|
|
"uninstall_block": "graphify trae uninstall # or: graphify trae-cn uninstall # remove the section",
|
|
"pretooluse_note": _TRAE_PRETOOLUSE_NOTE,
|
|
},
|
|
"amp": {
|
|
"heading_suffix": "",
|
|
"host_display": "Amp",
|
|
"install_block": "graphify amp install",
|
|
"uninstall_block": "graphify amp uninstall # remove the section",
|
|
"pretooluse_note": "",
|
|
},
|
|
"agents": {
|
|
# The generic cross-framework Agent-Skills target. Mirrors amp's bare,
|
|
# caveat-free agents-md section, worded for an unspecified host and
|
|
# pointing at `graphify agents install` (which wires AGENTS.md, like amp).
|
|
"heading_suffix": "",
|
|
"host_display": "your agent",
|
|
"install_block": "graphify agents install",
|
|
"uninstall_block": "graphify agents uninstall # remove the section",
|
|
"pretooluse_note": "",
|
|
},
|
|
}
|
|
# The prose file name the lean-core hooks pointer names, per hooks variant.
|
|
_HOOKS_TARGET = {
|
|
"claude-md": "CLAUDE.md",
|
|
"agents-md": "AGENTS.md",
|
|
}
|
|
|
|
# Allowlist for the per-host coverage audit (waves 2-3 consolidations).
|
|
#
|
|
# The lean core is one shared template across every split host, so a few v8
|
|
# headings deliberately do NOT survive verbatim in a given host's render. These
|
|
# are intentional consolidations, not content drops, and the audit must not flag
|
|
# them. Two classes:
|
|
#
|
|
# 1. SHARED_INTRO_ALLOWLIST — the lean intro consolidation. "## What graphify is
|
|
# for" is the lean intro the core carries; the minimal v8 bodies (kilo, vscode)
|
|
# had verbose intro prose with no such heading, while the richer v8 bodies
|
|
# already had it. Listing it documents the wave-2/3 intro consolidation; it
|
|
# single-homes in every render, so it is never itself a coverage hole. The enum
|
|
# unification (Decision A) is prose, not a heading, and is guarded separately by
|
|
# schema-singleton.
|
|
#
|
|
# 2. _CONSOLIDATION_ALLOWLIST[host] — per-host v8 headings the shared lean core
|
|
# re-homes under a reworded or re-leveled heading while preserving (or
|
|
# enriching) the content. The two minimal v8 bodies, kilo (414 L) and vscode
|
|
# (258 L), are the only hosts affected: the shared core is a richer superset
|
|
# that renamed their terse step/part headings and promoted kilo's
|
|
# "### Kilo-specific rules" to "## Kilo-specific rules". The mapped content
|
|
# lives in the core or a reference under the new heading; the audit confirms
|
|
# every NON-allowlisted v8 heading is single-homed, so a genuine drop (e.g.
|
|
# trae's native AGENTS.md integration) still fails loudly.
|
|
#
|
|
# Adding a heading here is a deliberate, reviewed act: it asserts "this v8
|
|
# heading was consolidated on purpose and its content is covered elsewhere."
|
|
SHARED_INTRO_ALLOWLIST: frozenset[str] = frozenset({
|
|
"## What graphify is for", # lean intro; v8 hosts had verbose intro prose, no heading.
|
|
})
|
|
|
|
_CONSOLIDATION_ALLOWLIST: dict[str, frozenset[str]] = {
|
|
# kilo's terse v8 step/part/section headings, renamed/re-leveled by the
|
|
# shared lean core. Content is preserved under the core's richer headings
|
|
# (Step 4 build/cluster/analyze, Step 5 label, Step 6 HTML, Step 9 report)
|
|
# and the query stub + references/query.md; "### Kilo-specific rules" is the
|
|
# same content promoted to "## Kilo-specific rules".
|
|
"kilo": frozenset({
|
|
"### Step 2.5 - Transcribe video or audio files (only if video files were detected)",
|
|
"#### Part B - Semantic extraction for docs, papers, and images",
|
|
"#### Part C - Merge AST and semantic extraction",
|
|
"### Step 4 - Build the graph and generate outputs",
|
|
"### Step 5 - Save manifest, clean up, and report",
|
|
"### Query mode",
|
|
"### Kilo-specific rules",
|
|
}),
|
|
# vscode's minimal v8 step/part headings, renamed by the shared lean core.
|
|
# The build/cluster, report/visualization, and completion-summary content is
|
|
# all present under the core's Step 4/5/6/9 headings.
|
|
"vscode": frozenset({
|
|
"#### Part A - Structural extraction (AST, free, no API cost)",
|
|
"#### Part B - Semantic extraction (AI, costs tokens)",
|
|
"### Step 4 - Build graph and cluster",
|
|
"### Step 5 - Generate report and visualization",
|
|
"### After completing all steps",
|
|
}),
|
|
}
|
|
|
|
|
|
def _audit_allowlist(platform_key: str) -> frozenset[str]:
|
|
"""The full set of v8 headings the audit may skip for this host."""
|
|
return SHARED_INTRO_ALLOWLIST | _CONSOLIDATION_ALLOWLIST.get(platform_key, frozenset())
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Platform:
|
|
"""One render unit parsed from platforms.toml."""
|
|
|
|
key: str
|
|
bucket: str
|
|
skill_dst: str
|
|
# split-only template inputs
|
|
core: str | None = None
|
|
refs_dst: str | None = None
|
|
name: str = "graphify"
|
|
description: str | None = None
|
|
trigger: str | None = None # removed — not part of Agent Skills spec (#1180)
|
|
dispatch: str | None = None
|
|
extraction: str = "verbose"
|
|
shell: str = "posix"
|
|
claude_md: bool = False
|
|
hooks_variant: str = "claude-md"
|
|
extra_sections: tuple[str, ...] = ()
|
|
# monolith-only inputs
|
|
monolith: str | None = None
|
|
roundtrip_ref: str | None = None
|
|
|
|
def reference_sources(self) -> dict[str, str]:
|
|
"""Resolve the rendered-name -> source-fragment map for this split platform."""
|
|
refs = dict(_SHARED_REFERENCES)
|
|
refs["extraction-spec"] = _EXTRACTION_SOURCE[self.extraction]
|
|
refs["query"] = _QUERY_REFERENCE
|
|
refs["hooks"] = _HOOKS_SOURCE[self.hooks_variant]
|
|
return refs
|
|
|
|
@property
|
|
def hooks_target(self) -> str:
|
|
"""The prose file name the lean-core hooks pointer names for this host."""
|
|
return _HOOKS_TARGET[self.hooks_variant]
|
|
|
|
|
|
def load_platforms() -> dict[str, Platform]:
|
|
"""Parse platforms.toml into Platform records, keyed by platform name."""
|
|
data = tomllib.loads(PLATFORMS_TOML.read_text(encoding="utf-8"))
|
|
out: dict[str, Platform] = {}
|
|
for key, cfg in data.get("platform", {}).items():
|
|
out[key] = Platform(
|
|
key=key,
|
|
bucket=cfg["bucket"],
|
|
skill_dst=cfg["skill_dst"],
|
|
core=cfg.get("core"),
|
|
refs_dst=cfg.get("refs_dst"),
|
|
name=cfg.get("name", "graphify"),
|
|
description=cfg.get("description"),
|
|
trigger=cfg.get("trigger"),
|
|
dispatch=cfg.get("dispatch"),
|
|
extraction=cfg.get("extraction", "verbose"),
|
|
shell=cfg.get("shell", "posix"),
|
|
claude_md=bool(cfg.get("claude_md", False)),
|
|
hooks_variant=cfg.get("hooks_variant", "claude-md"),
|
|
extra_sections=tuple(cfg.get("extra_sections", [])),
|
|
monolith=cfg.get("monolith"),
|
|
roundtrip_ref=cfg.get("roundtrip_ref"),
|
|
)
|
|
return out
|
|
|
|
|
|
def _read_fragment(rel: str) -> str:
|
|
"""Read a fragment file under fragments/, normalised to LF newlines."""
|
|
text = (FRAGMENTS_DIR / rel).read_text(encoding="utf-8")
|
|
return _normalise(text)
|
|
|
|
|
|
def _normalise(text: str) -> str:
|
|
"""Force LF newlines and exactly one trailing newline."""
|
|
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
return text.rstrip("\n") + "\n"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RenderedArtifact:
|
|
"""A single generated file: its repo-relative path and exact bytes."""
|
|
|
|
path: str # relative to REPO_ROOT
|
|
content: str
|
|
|
|
|
|
def _render_frontmatter(platform: Platform) -> str:
|
|
"""Render the YAML frontmatter from the platform's name and description.
|
|
|
|
Only emits fields from the Agent Skills spec (name, description).
|
|
The description is preserved verbatim from platforms.toml — never invented.
|
|
"""
|
|
if platform.description is None:
|
|
raise ValueError(f"split platform '{platform.key}' is missing a description")
|
|
lines = ["---", f"name: {platform.name}", f'description: "{platform.description}"']
|
|
lines.append("---")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _render_core(platform: Platform) -> str:
|
|
"""Fill the shared core template's per-platform slots for this platform."""
|
|
template = _read_fragment(f"core/{platform.core}.md")
|
|
|
|
if platform.dispatch is None:
|
|
raise ValueError(f"split platform '{platform.key}' is missing a dispatch variant")
|
|
|
|
install = _read_fragment(f"shell/{platform.shell}.md").rstrip("\n")
|
|
dispatch = _read_fragment(f"dispatch/{platform.dispatch}.md").rstrip("\n")
|
|
query_stub = _read_fragment(_QUERY_STUB).rstrip("\n")
|
|
|
|
if platform.extra_sections:
|
|
extra = "".join(
|
|
_read_fragment(f"extra/{name}.md").rstrip("\n") + "\n\n"
|
|
for name in platform.extra_sections
|
|
)
|
|
else:
|
|
extra = ""
|
|
|
|
body = (
|
|
template.replace("@@FRONTMATTER@@", _render_frontmatter(platform))
|
|
.replace("@@INSTALL@@", install)
|
|
.replace("@@DISPATCH@@", dispatch)
|
|
.replace("@@QUERY_STUB@@", query_stub)
|
|
.replace("@@HOOKS_TARGET@@", platform.hooks_target)
|
|
.replace("@@EXTRA@@", extra)
|
|
)
|
|
if "@@" in body:
|
|
leftover = sorted(set(re.findall(r"@@\w+@@", body)))
|
|
raise ValueError(f"unfilled core slots for '{platform.key}': {leftover}")
|
|
return _normalise(body)
|
|
|
|
|
|
def _render_agents_md_hooks(platform: Platform) -> str:
|
|
"""Fill the agents-md hooks template's per-host slots for this platform.
|
|
|
|
The fragment is one template shared by every AGENTS.md host (trae, trae-cn,
|
|
amp). The install/uninstall commands, the host display name, the heading
|
|
suffix, and the PreToolUse caveat are filled from _AGENTS_MD_HOOKS so each host
|
|
renders its OWN v8 wording — trae keeps the "(Trae)" heading suffix and the
|
|
no-PreToolUse Note; amp gets a bare heading, single-line commands, and no
|
|
caveat (its v8 never had one).
|
|
"""
|
|
template = _read_fragment(_HOOKS_SOURCE["agents-md"])
|
|
slots = _AGENTS_MD_HOOKS.get(platform.key)
|
|
if slots is None:
|
|
raise ValueError(
|
|
f"platform '{platform.key}' uses the agents-md hooks variant but has no "
|
|
f"_AGENTS_MD_HOOKS entry"
|
|
)
|
|
body = (
|
|
template.replace("@@AGENTS_HEADING_SUFFIX@@", slots["heading_suffix"])
|
|
.replace("@@HOST_DISPLAY@@", slots["host_display"])
|
|
.replace("@@AGENTS_INSTALL_BLOCK@@", slots["install_block"])
|
|
.replace("@@AGENTS_UNINSTALL_BLOCK@@", slots["uninstall_block"])
|
|
.replace("@@AGENTS_PRETOOLUSE_NOTE@@", slots["pretooluse_note"])
|
|
)
|
|
if "@@" in body:
|
|
leftover = sorted(set(re.findall(r"@@\w+@@", body)))
|
|
raise ValueError(f"unfilled agents-md hooks slots for '{platform.key}': {leftover}")
|
|
return _normalise(body)
|
|
|
|
|
|
def render(platform: Platform) -> list[RenderedArtifact]:
|
|
"""Render every committed artifact for one platform.
|
|
|
|
A split platform yields the lean core SKILL.md plus one file per reference,
|
|
in a stable order (core first, then references sorted by name). A monolith
|
|
yields a single inline skill body.
|
|
"""
|
|
if platform.bucket == "monolith":
|
|
body = _read_fragment(f"core/{platform.monolith}.md")
|
|
return [RenderedArtifact(platform.skill_dst, body)]
|
|
|
|
if platform.bucket != "split":
|
|
raise ValueError(f"unknown bucket '{platform.bucket}' for platform '{platform.key}'")
|
|
|
|
if platform.refs_dst is None:
|
|
raise ValueError(f"split platform '{platform.key}' is missing refs_dst")
|
|
|
|
artifacts: list[RenderedArtifact] = [
|
|
RenderedArtifact(platform.skill_dst, _render_core(platform))
|
|
]
|
|
|
|
references = platform.reference_sources()
|
|
# Sorted reference index keeps the output idempotent regardless of map order.
|
|
for name in sorted(references):
|
|
# The agents-md hooks reference is a per-host template; everything else is
|
|
# read verbatim.
|
|
if name == "hooks" and platform.hooks_variant == "agents-md":
|
|
body = _render_agents_md_hooks(platform)
|
|
else:
|
|
body = _read_fragment(references[name])
|
|
rel = f"{platform.refs_dst}/{name}.md"
|
|
artifacts.append(RenderedArtifact(rel, body))
|
|
return artifacts
|
|
|
|
|
|
def render_always_on() -> list[RenderedArtifact]:
|
|
"""Render the six always-on instruction blocks to graphify/always_on/*.md.
|
|
|
|
These are the blocks the installer injects into shared files (CLAUDE.md,
|
|
AGENTS.md, GEMINI.md, .github/copilot-instructions.md, Antigravity rules,
|
|
Kiro steering). They used to be triple-quoted constants in __main__.py and
|
|
are now packaged markdown the module reads at load. Rendering them through
|
|
skillgen puts them under the --check / expected/ drift guard like every other
|
|
generated artifact. They are not platform-specific, so they render once.
|
|
"""
|
|
out: list[RenderedArtifact] = []
|
|
for basename in sorted(ALWAYS_ON_BLOCKS):
|
|
body = _read_fragment(f"always-on/{basename}.md")
|
|
out.append(RenderedArtifact(f"graphify/always_on/{basename}.md", body))
|
|
return out
|
|
|
|
|
|
def render_all(platforms: dict[str, Platform], only: str | None = None) -> list[RenderedArtifact]:
|
|
"""Render the selected platforms (or all), flattened into one artifact list.
|
|
|
|
A full render (no ``only``) also includes the always-on blocks; a single
|
|
``--platform`` render does not, since the always-on files are shared, not
|
|
per-platform.
|
|
"""
|
|
keys = [only] if only else sorted(platforms)
|
|
out: list[RenderedArtifact] = []
|
|
for key in keys:
|
|
if key not in platforms:
|
|
raise SystemExit(f"error: unknown platform '{key}'. Known: {', '.join(sorted(platforms))}")
|
|
out.extend(render(platforms[key]))
|
|
if only is None:
|
|
out.extend(render_always_on())
|
|
return out
|
|
|
|
|
|
def write_artifacts(artifacts: list[RenderedArtifact]) -> list[str]:
|
|
"""Write artifacts to disk under REPO_ROOT. Returns the paths written."""
|
|
written: list[str] = []
|
|
for art in artifacts:
|
|
dst = REPO_ROOT / art.path
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
dst.write_text(art.content, encoding="utf-8", newline="\n")
|
|
written.append(art.path)
|
|
return written
|
|
|
|
|
|
def _expected_path(rel: str) -> Path:
|
|
"""Map a repo-relative artifact path to its expected/ snapshot path.
|
|
|
|
The artifact path is flattened (``/`` -> ``__``) into a single filename so
|
|
the snapshot tree never contains a ``skills/`` path component, which the
|
|
repo .gitignore ignores. This keeps expected/ a flat, fully tracked dir.
|
|
"""
|
|
return EXPECTED_DIR / (rel.replace("/", "__"))
|
|
|
|
|
|
def bless(artifacts: list[RenderedArtifact]) -> list[str]:
|
|
"""Write the current render into expected/ as the blessed snapshot."""
|
|
written: list[str] = []
|
|
for art in artifacts:
|
|
dst = _expected_path(art.path)
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
dst.write_text(art.content, encoding="utf-8", newline="\n")
|
|
written.append(str(dst.relative_to(SKILLGEN_DIR)))
|
|
return written
|
|
|
|
|
|
def check(artifacts: list[RenderedArtifact]) -> list[str]:
|
|
"""Byte-diff the render against both committed artifacts and expected/.
|
|
|
|
Returns a list of human-readable drift messages. Empty list means clean.
|
|
This is the anti-drift guard wired into CI and pre-commit: any hand-edit of
|
|
a generated file, or a stale expected/ snapshot, is caught here.
|
|
"""
|
|
problems: list[str] = []
|
|
for art in artifacts:
|
|
committed = REPO_ROOT / art.path
|
|
if not committed.exists():
|
|
problems.append(f"missing committed artifact: {art.path} (run: python -m tools.skillgen)")
|
|
elif committed.read_text(encoding="utf-8") != art.content:
|
|
problems.append(f"committed artifact out of date: {art.path} (run: python -m tools.skillgen)")
|
|
|
|
snapshot = _expected_path(art.path)
|
|
if not snapshot.exists():
|
|
problems.append(f"missing expected/ snapshot: {art.path} (run: python -m tools.skillgen --bless)")
|
|
elif snapshot.read_text(encoding="utf-8") != art.content:
|
|
problems.append(f"expected/ snapshot out of date: {art.path} (run: python -m tools.skillgen --bless)")
|
|
return problems
|
|
|
|
|
|
def headings(markdown: str) -> list[str]:
|
|
"""Return the ATX markdown headings in source order, ignoring code fences.
|
|
|
|
A ``#``-prefixed line inside a fenced code block is a shell comment, not a
|
|
heading, so fence state is tracked to avoid counting them.
|
|
"""
|
|
out: list[str] = []
|
|
in_fence = False
|
|
fence_marker = ""
|
|
for line in markdown.splitlines():
|
|
stripped = line.lstrip()
|
|
if stripped.startswith("```") or stripped.startswith("~~~"):
|
|
marker = stripped[:3]
|
|
if not in_fence:
|
|
in_fence = True
|
|
fence_marker = marker
|
|
elif marker == fence_marker:
|
|
in_fence = False
|
|
fence_marker = ""
|
|
continue
|
|
if in_fence:
|
|
continue
|
|
# An ATX heading is 1-6 '#' then a space then text.
|
|
if stripped.startswith("#"):
|
|
hashes = len(stripped) - len(stripped.lstrip("#"))
|
|
if 1 <= hashes <= 6 and stripped[hashes:hashes + 1] == " ":
|
|
out.append(stripped.strip())
|
|
return out
|
|
|
|
|
|
def _git_show(ref: str) -> str:
|
|
"""Read a blob from git, normalised to LF."""
|
|
result = subprocess.run(
|
|
["git", "show", ref],
|
|
cwd=REPO_ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
)
|
|
if result.returncode != 0:
|
|
raise SystemExit(f"error: could not read {ref}: {result.stderr.strip()}")
|
|
return result.stdout
|
|
|
|
|
|
def _v8_available() -> bool:
|
|
"""Whether origin/v8 is fetchable in this checkout.
|
|
|
|
The git-show validators (audit-coverage, monolith-roundtrip,
|
|
always-on-roundtrip) read blobs from origin/v8. CI's default shallow checkout
|
|
does not fetch that ref, so the validators set fetch-depth: 0 to fetch it.
|
|
This probe lets the CLI skip with a clear, actionable message (rather than
|
|
crash with a cryptic git error) when the ref is genuinely unreachable.
|
|
"""
|
|
result = subprocess.run(
|
|
["git", "rev-parse", "--verify", "--quiet", "origin/v8"],
|
|
cwd=REPO_ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return result.returncode == 0
|
|
|
|
|
|
def audit_coverage(platform: Platform) -> list[str]:
|
|
"""Assert every heading of THIS host's v8 body single-homes in its render.
|
|
|
|
The audit reads the host's OWN v8 skill body (graphify/skill.md for claude,
|
|
graphify/skill-<host>.md otherwise) and checks that every v8 heading lands in
|
|
that host's generated core or in exactly one of its reference fragments. This
|
|
is the per-host guard: a content drop that only hits one host (the trae native
|
|
AGENTS.md integration regression that motivated this change) is invisible when
|
|
every host is checked against claude's monolith, so each host is checked
|
|
against itself.
|
|
|
|
v8 headings are exempt and documented as deltas, not holes:
|
|
- waves 2-3 consolidations (the lean "## What graphify is for" intro and the
|
|
per-host re-homed step/part headings on the minimal kilo/vscode bodies),
|
|
tracked in the audit allowlist.
|
|
Anything NOT exempt and NOT single-homed fails the audit.
|
|
"""
|
|
if platform.bucket != "split":
|
|
return [] # monoliths are guarded by the round-trip validator instead.
|
|
|
|
problems: list[str] = []
|
|
baseline_headings = headings(_git_show(_v8_baseline_ref(platform.key)))
|
|
allowlist = _audit_allowlist(platform.key)
|
|
|
|
artifacts = render(platform)
|
|
by_path = {a.path: a.content for a in artifacts}
|
|
core_headings = set(headings(by_path[platform.skill_dst]))
|
|
|
|
# Map each reference's rendered heading set.
|
|
ref_headings: dict[str, set[str]] = {}
|
|
for name in platform.reference_sources():
|
|
rel = f"{platform.refs_dst}/{name}.md"
|
|
ref_headings[name] = set(headings(by_path[rel]))
|
|
|
|
for h in baseline_headings:
|
|
# Allowlisted consolidations + the lean intro are intentional deltas.
|
|
if h in allowlist:
|
|
continue
|
|
homes = []
|
|
if h in core_headings:
|
|
homes.append("core")
|
|
for name, hs in ref_headings.items():
|
|
if h in hs:
|
|
homes.append(f"references/{name}.md")
|
|
if not homes:
|
|
problems.append(f"v8 heading not covered anywhere: {h!r}")
|
|
elif len(homes) > 1:
|
|
problems.append(f"v8 heading double-homed in {homes}: {h!r}")
|
|
return problems
|
|
|
|
|
|
def _enum_lines(content: str) -> list[str]:
|
|
"""Return every line in a rendered artifact that carries the file_type enum."""
|
|
return [
|
|
line
|
|
for line in content.splitlines()
|
|
if ENUM_VALUES in line or ENUM_PROSE in line
|
|
]
|
|
|
|
|
|
# Legacy enum fragments that must never survive the six-value unification. Each
|
|
# is a strict prefix of the full superset, so a line carrying one WITHOUT the
|
|
# full superset is a stale 4- or 5-value enum.
|
|
_LEGACY_ENUMS = (
|
|
"code|document|paper|image|rationale", # 5-value
|
|
"code|document|paper|image", # 4-value
|
|
)
|
|
|
|
|
|
def legacy_enum_lines(content: str) -> list[str]:
|
|
"""Return lines carrying a legacy (sub-superset) file_type enum.
|
|
|
|
A line counts as legacy only when it has a 4- or 5-value enum fragment but
|
|
NOT the full six-value superset. The schema-singleton guard treats any such
|
|
line as drift.
|
|
"""
|
|
out: list[str] = []
|
|
for line in content.splitlines():
|
|
if ENUM_VALUES in line:
|
|
continue
|
|
if any(bad in line for bad in _LEGACY_ENUMS):
|
|
out.append(line.strip())
|
|
return out
|
|
|
|
|
|
def schema_singleton(platforms: dict[str, Platform]) -> list[str]:
|
|
"""Assert the file_type enum block is byte-identical across every platform.
|
|
|
|
Every rendered artifact that mentions the enum — the verbose and compact
|
|
extraction specs, and the inline monolith bodies — must carry exactly the
|
|
six-value superset and nothing else. A stray 4- or 5-value enum line is the
|
|
failure this guard exists to catch.
|
|
"""
|
|
problems: list[str] = []
|
|
for key in sorted(platforms):
|
|
for art in render(platforms[key]):
|
|
for stripped in legacy_enum_lines(art.content):
|
|
problems.append(
|
|
f"[{key}] {art.path}: legacy file_type enum (not the six-value superset): {stripped!r}"
|
|
)
|
|
return problems
|
|
|
|
|
|
def _is_enum_line(line: str) -> bool:
|
|
"""Whether a line carries the file_type enum (its v8 or unified form).
|
|
|
|
The unified six-value enum (``ENUM_VALUES``/``ENUM_PROSE``) and the v8
|
|
five-value form it replaced both match here, so the round-trip's multiset
|
|
diff classifies the removed-v8 line as well as the added-rendered line. The
|
|
five-value schema string is a prefix of the six-value one, and both prose
|
|
forms open with the same ``file_type:"rationale"`` guidance clause.
|
|
"""
|
|
return (
|
|
ENUM_VALUES in line
|
|
or ENUM_PROSE in line
|
|
or "code|document|paper|image|rationale" in line
|
|
or 'file_type:"rationale"` for concept-like nodes' in line
|
|
)
|
|
|
|
|
|
def _is_frontmatter_description_line(line: str) -> bool:
|
|
"""Whether a line is a YAML frontmatter description field.
|
|
|
|
The unified description (graphify #1106) rewrites the frontmatter
|
|
``description`` on every host, monoliths included. That line is now an
|
|
allowed diff against v8 alongside the enum unification.
|
|
"""
|
|
return line.lstrip().startswith("description:")
|
|
|
|
|
|
def _is_chunk_cleanup_line(line: str) -> bool:
|
|
"""Whether a line is the Step 9 chunk-file cleanup ``rm -f`` command.
|
|
|
|
The bare glob ``.graphify_chunk_*.json`` in the v8 cleanup line aborts the
|
|
whole ``rm`` under fish/zsh when no chunk files exist (no-match is a hard
|
|
error there, unlike bash). The fix (graphify #1172) drops the glob from the
|
|
``rm`` and deletes the chunk files with ``find ... -delete`` instead. That
|
|
rewrite touches the single cleanup line in place (no line added or removed),
|
|
so it joins the enum and description unifications as an allowed monolith diff.
|
|
Both the v8 form (bare ``.graphify_chunk_*.json`` glob, removed) and the fixed
|
|
form (``find ... -delete``, added) match here so the multiset diff classifies
|
|
each side of the change.
|
|
"""
|
|
s = line.lstrip()
|
|
if not s.startswith("rm -f"):
|
|
return False
|
|
return ".graphify_chunk_*.json" in line or ("find " in line and "-name '.graphify_chunk_" in line)
|
|
|
|
|
|
def _is_trigger_line(line: str) -> bool:
|
|
"""Whether a line is the non-spec ``trigger:`` frontmatter field (#1180).
|
|
|
|
The Agent Skills spec does not include ``trigger:`` — only name/description
|
|
and a few optional fields. Removing it is a permitted monolith diff.
|
|
"""
|
|
return line.strip().startswith("trigger:")
|
|
|
|
|
|
def _is_directed_fix_line(line: str) -> bool:
|
|
"""Whether a line is part of the ``--directed`` propagation fix (#1392).
|
|
|
|
The monolith runbooks built every graph undirected, so a ``--directed`` run
|
|
silently collapsed reciprocal A<->B edges. Every ``build_from_json(...)`` call
|
|
now threads ``directed=IS_DIRECTED`` and a prose line tells the agent to
|
|
substitute it like ``INPUT_PATH``. Both the old bare call (removed) and the
|
|
new threaded call (added) match here, plus the substitution instruction.
|
|
"""
|
|
return (
|
|
"build_from_json(" in line and "import" not in line
|
|
) or "directed=IS_DIRECTED" in line or (
|
|
"IS_DIRECTED" in line and "Substitute it everywhere" in line
|
|
)
|
|
|
|
|
|
def _is_content_scope_fix_line(line: str) -> bool:
|
|
"""Whether a line is part of the content-only semantic scope fix (#1392).
|
|
|
|
Flattening every detect category fed code files (already covered by the AST
|
|
pass) back to the semantic step. The fix scopes to document/paper/image.
|
|
"""
|
|
return (
|
|
"detect['files'].values()" in line
|
|
or "for cat in ('document', 'paper', 'image')" in line
|
|
or "Only content files go to semantic extraction" in line
|
|
or "structurally by the AST pass" in line
|
|
or "extraction step re-read every source file" in line
|
|
)
|
|
|
|
|
|
def _is_cache_unlink_fix_line(line: str) -> bool:
|
|
"""Whether a line is part of the stale-cache unlink fix (#1392).
|
|
|
|
The cache file was written only on a hit, so a miss left a prior run's
|
|
``.graphify_cached.json`` for Part C to merge. The miss branch now deletes it.
|
|
"""
|
|
return (
|
|
".graphify_cached.json').unlink(missing_ok=True)" in line
|
|
or line.strip() == "else:"
|
|
or "Always (re)write the cache file" in line
|
|
or "stale .graphify_cached.json" in line
|
|
)
|
|
|
|
|
|
def _is_zero_node_guard_fix_line(line: str) -> bool:
|
|
"""Whether a line is part of the zero-node / shrink-guard ordering fix (#1392).
|
|
|
|
Step 4 wrote GRAPH_REPORT.md, graph.json and the analysis sidecar *before*
|
|
the zero-node guard, so an empty extraction clobbered a good graph; and the
|
|
report was written even when ``to_json`` refused to shrink (#479). The guard
|
|
now runs before any write and the report/analysis are gated on ``to_json``.
|
|
Both the old (removed) and new (added) forms of these lines match here.
|
|
"""
|
|
s = line.strip()
|
|
return (
|
|
"number_of_nodes() == 0" in line
|
|
or "Graph is empty - extraction produced no nodes" in line
|
|
or s.startswith("print('Possible causes:")
|
|
or s == "raise SystemExit(1)"
|
|
or "to_json(G, communities," in line
|
|
or s == "if not wrote:"
|
|
or "refused to shrink graphify-out/graph.json" in line
|
|
or "Guard BEFORE any write" in line
|
|
or "GRAPH_REPORT.md / analysis sidecar" in line
|
|
or "Persist the graph first" in line
|
|
or "to_json refuses to shrink an existing graph.json" in line
|
|
or "report describing a graph we did not write" in line
|
|
)
|
|
|
|
|
|
def _is_manifest_root_fix_line(line: str) -> bool:
|
|
"""Whether a line is part of the manifest-portability fix (#1417).
|
|
|
|
The monolith Step 9 called ``save_manifest(detect['files'])`` with no
|
|
``root=``, so the manifest stored absolute path keys and a clone or move
|
|
broke ``--update`` — every cached file missed and the whole corpus
|
|
re-extracted. The call now threads ``root='INPUT_PATH'`` so keys are
|
|
relativized to the scan root, matching the native ``graphify update`` path.
|
|
Both the old bare call (removed) and the new rooted call (added) match here;
|
|
the ``import`` guard avoids matching the ``from graphify.detect import
|
|
save_manifest`` line.
|
|
"""
|
|
return "save_manifest(" in line and "import" not in line
|
|
|
|
|
|
def _is_no_api_key_fix_line(line: str) -> bool:
|
|
"""Whether a line is part of the "no API key required" clarity (#1461).
|
|
|
|
The aider/devin monoliths described Step 3 semantic extraction without ever
|
|
stating that graphify needs no API key, and (like the subagent-host skills)
|
|
framed the no-key path only around dispatching subagents. Terminal hosts that
|
|
run the CLI directly and can't dispatch subagents looped for minutes insisting
|
|
on a missing key. A single blockquote added after the "two parts" line states
|
|
that no key is ever required and gives a non-subagent fallback.
|
|
"""
|
|
return "graphify needs no API key" in line
|
|
|
|
|
|
def _is_shebang_allowlist_fix_line(line: str) -> bool:
|
|
"""Whether a line is part of the Homebrew ``python@`` shebang allowlist fix (#1586).
|
|
|
|
The interpreter-detection guard rejected any shebang containing a character
|
|
outside ``[!a-zA-Z0-9/_.-]``, but Homebrew installs versioned Python under
|
|
``python@3.13``, so a valid interpreter path legitimately contains ``@`` and
|
|
detection fell through to a bare ``python3`` that lacked graphify. ``@`` is now
|
|
allowed, matching the #473 hooks.py fix; injection chars are still rejected.
|
|
Both the old (removed) and new (added) allowlist forms match here.
|
|
"""
|
|
return "[!a-zA-Z0-9/_." in line
|
|
|
|
|
|
def _is_obsidian_usage_comment_line(line: str) -> bool:
|
|
"""Whether a line is part of the ``/graphify`` usage-comment fix (#1681).
|
|
|
|
The Usage block's bare ``/graphify`` comment said "full pipeline on current
|
|
directory -> Obsidian vault", contradicting Step 6 (HTML always; Obsidian vault
|
|
only when ``--obsidian`` is explicitly given). The comment now describes the
|
|
real default. Both the old (removed) and new (added) comment forms match here.
|
|
"""
|
|
return "# full pipeline on current directory" in line
|
|
|
|
|
|
def _is_uv_from_interpreter_fix_line(line: str) -> bool:
|
|
"""Whether a line is part of the uv interpreter-detection fix (#1735).
|
|
|
|
Step 1's POSIX interpreter probe ran ``uv tool run graphifyy python -c ...``,
|
|
but ``graphifyy`` exposes its executable as ``graphify``, so uv treated
|
|
``python`` as a missing ``graphifyy`` command and the probe silently failed
|
|
(the ``2>/dev/null`` swallowed uv's "use --from" hint), leaving PYTHON on a
|
|
graphify-less system interpreter. The probe now runs
|
|
``uv tool run --from graphifyy python -c ...``. Both the old (removed) and new
|
|
(added) forms match here.
|
|
"""
|
|
return "uv tool run" in line and "graphifyy python" in line
|
|
|
|
|
|
# Every line that may differ between a rendered monolith and its pristine v8
|
|
# baseline. Each predicate documents one sanctioned change-class; a blank line is
|
|
# allowed because the multi-line fix blocks insert spacing. Anything else failing
|
|
# all of these is an unsanctioned drift the round-trip must catch.
|
|
_SANCTIONED_MONOLITH_DIFFS = (
|
|
_is_enum_line,
|
|
_is_frontmatter_description_line,
|
|
_is_chunk_cleanup_line,
|
|
_is_directed_fix_line,
|
|
_is_content_scope_fix_line,
|
|
_is_cache_unlink_fix_line,
|
|
_is_zero_node_guard_fix_line,
|
|
_is_manifest_root_fix_line,
|
|
_is_no_api_key_fix_line,
|
|
_is_shebang_allowlist_fix_line,
|
|
_is_obsidian_usage_comment_line,
|
|
_is_uv_from_interpreter_fix_line,
|
|
)
|
|
|
|
|
|
def _is_sanctioned_monolith_diff(line: str) -> bool:
|
|
"""Whether a single added/removed monolith line is an allowed change."""
|
|
return not line.strip() or any(pred(line) for pred in _SANCTIONED_MONOLITH_DIFFS)
|
|
|
|
|
|
def monolith_roundtrip(platform: Platform) -> list[str]:
|
|
"""Assert a monolith renders diff-clean vs its v8 blob modulo allowed changes.
|
|
|
|
The monolith bodies are hand-maintained single files frozen against a pinned
|
|
pristine v8 blob (``roundtrip_ref``); this is the guard that stops an
|
|
arbitrary edit (even a blessed one) from drifting them. Sanctioned changes are
|
|
enumerated as predicates in ``_SANCTIONED_MONOLITH_DIFFS``: the file_type enum
|
|
unification, the unified frontmatter description, the chunk-cleanup rewrite
|
|
(#1172), and the four #1392 runbook fixes (directed propagation, content-only
|
|
semantic scope, stale-cache unlink, and the zero-node/shrink-guard ordering).
|
|
|
|
The comparison is a multiset diff, not a positional zip: a line whose text is
|
|
unchanged but merely *moved* (the report-write line shifted below ``to_json``
|
|
in the ordering fix) cancels out and is not flagged. Only lines whose content
|
|
is genuinely added or removed are checked, and each must be sanctioned.
|
|
"""
|
|
if platform.bucket != "monolith":
|
|
return []
|
|
if platform.roundtrip_ref is None:
|
|
return [f"[{platform.key}] monolith is missing roundtrip_ref"]
|
|
|
|
rendered_lines = render(platform)[0].content.splitlines()
|
|
# Strip trigger lines from the original — they are non-spec and their removal
|
|
# (#1180) is a permitted diff.
|
|
original_lines = [
|
|
l for l in _normalise(_git_show(platform.roundtrip_ref)).splitlines()
|
|
if not _is_trigger_line(l)
|
|
]
|
|
|
|
added = Counter(rendered_lines) - Counter(original_lines)
|
|
removed = Counter(original_lines) - Counter(rendered_lines)
|
|
|
|
problems: list[str] = []
|
|
for line in list(added.elements()) + list(removed.elements()):
|
|
if _is_sanctioned_monolith_diff(line):
|
|
continue
|
|
problems.append(
|
|
f"[{platform.key}] unsanctioned monolith change vs pristine v8: {line!r}"
|
|
)
|
|
return problems
|
|
|
|
|
|
def _always_on_constants(ref: str) -> dict[str, str]:
|
|
"""Parse the always-on string constants out of a __main__.py blob.
|
|
|
|
Reads the module source from git and walks its top-level assignments,
|
|
returning ``name -> value`` for each constant in ALWAYS_ON_BLOCKS. Parsing
|
|
the source (rather than importing the live module) keeps the baseline
|
|
immutable: the validator proves fidelity against the pre-extraction text even
|
|
after the live module is rewritten to read the packaged files.
|
|
"""
|
|
import ast
|
|
|
|
src = _git_show(ref)
|
|
wanted = set(ALWAYS_ON_BLOCKS.values())
|
|
out: dict[str, str] = {}
|
|
for node in ast.parse(src).body:
|
|
if not isinstance(node, ast.Assign):
|
|
continue
|
|
if len(node.targets) != 1 or not isinstance(node.targets[0], ast.Name):
|
|
continue
|
|
name = node.targets[0].id
|
|
if name in wanted and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
|
|
out[name] = node.value.value
|
|
return out
|
|
|
|
|
|
def always_on_roundtrip() -> list[str]:
|
|
"""Assert each always_on/*.md reproduces its former constant byte for byte.
|
|
|
|
The six always-on instruction blocks were extracted from triple-quoted
|
|
constants in __main__.py into packaged markdown. This validator renders each
|
|
block and compares it, byte for byte, against the constant's value in the
|
|
pre-extraction source (ALWAYS_ON_BASELINE_REF). A mismatch means the
|
|
extraction is not faithful and the install-string / issue-#580 contract would
|
|
break.
|
|
"""
|
|
baseline = _always_on_constants(ALWAYS_ON_BASELINE_REF)
|
|
problems: list[str] = []
|
|
rendered = {a.path: a.content for a in render_always_on()}
|
|
for basename, const_name in sorted(ALWAYS_ON_BLOCKS.items()):
|
|
path = f"graphify/always_on/{basename}.md"
|
|
if const_name not in baseline:
|
|
problems.append(f"could not find constant {const_name} in {ALWAYS_ON_BASELINE_REF}")
|
|
continue
|
|
expected = baseline[const_name]
|
|
for old, new in ALWAYS_ON_SANCTIONED_EDITS.get(const_name, ()):
|
|
if old not in expected:
|
|
problems.append(
|
|
f"sanctioned edit for {const_name} no longer applies: "
|
|
f"old text not found in {ALWAYS_ON_BASELINE_REF}"
|
|
)
|
|
expected = expected.replace(old, new)
|
|
if rendered[path] != expected:
|
|
problems.append(
|
|
f"always_on/{basename}.md does not reproduce {const_name} byte for byte "
|
|
f"(rendered {len(rendered[path])} chars vs baseline {len(expected)} chars)"
|
|
)
|
|
return problems
|
|
|
|
|
|
def _parse_args(argv: list[str]) -> argparse.Namespace:
|
|
p = argparse.ArgumentParser(
|
|
prog="python -m tools.skillgen",
|
|
description="Render and guard graphify's committed skill artifacts.",
|
|
)
|
|
p.add_argument("--platform", help="render or check just this platform key")
|
|
p.add_argument("--check", action="store_true", help="byte-diff render vs committed + expected/, exit 1 on drift")
|
|
p.add_argument("--audit-coverage", action="store_true", help="per host: assert every heading of that host's own v8 body single-homes in its render")
|
|
p.add_argument("--schema-singleton", action="store_true", help="assert the file_type enum is byte-identical everywhere")
|
|
p.add_argument("--monolith-roundtrip", action="store_true", help="assert each monolith == v8 modulo the enum unification")
|
|
p.add_argument("--always-on-roundtrip", action="store_true", help="assert each always_on/*.md reproduces its former __main__.py constant byte for byte")
|
|
p.add_argument("--bless", action="store_true", help="rewrite expected/ from the current render")
|
|
return p.parse_args(argv)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = _parse_args(argv if argv is not None else sys.argv[1:])
|
|
platforms = load_platforms()
|
|
|
|
# The git-show validators read origin/v8. On a shallow checkout that ref is
|
|
# absent; skip with a clear, actionable message instead of crashing. CI fixes
|
|
# this for real by setting fetch-depth: 0 so the validators actually run.
|
|
_GIT_SHOW_VALIDATORS = (args.audit_coverage, args.monolith_roundtrip, args.always_on_roundtrip)
|
|
if any(_GIT_SHOW_VALIDATORS) and not _v8_available():
|
|
print(
|
|
"SKIPPED: origin/v8 is not fetchable in this checkout, so the git-show "
|
|
"validators cannot run. On CI, set fetch-depth: 0 on this job (actions/"
|
|
"checkout) so origin/v8 is fetched and the validators run for real.",
|
|
file=sys.stderr,
|
|
)
|
|
return 0
|
|
|
|
if args.audit_coverage:
|
|
keys = [args.platform] if args.platform else sorted(platforms)
|
|
all_problems: list[str] = []
|
|
for key in keys:
|
|
if key not in platforms:
|
|
raise SystemExit(f"error: unknown platform '{key}'")
|
|
all_problems.extend(f"[{key}] {m}" for m in audit_coverage(platforms[key]))
|
|
if all_problems:
|
|
print("audit-coverage FAILED:", file=sys.stderr)
|
|
for m in all_problems:
|
|
print(f" {m}", file=sys.stderr)
|
|
return 1
|
|
print("audit-coverage OK: every per-host v8 heading single-homes in that host's render.")
|
|
return 0
|
|
|
|
if args.schema_singleton:
|
|
problems = schema_singleton(
|
|
{args.platform: platforms[args.platform]} if args.platform else platforms
|
|
)
|
|
if problems:
|
|
print("schema-singleton FAILED (file_type enum drift):", file=sys.stderr)
|
|
for m in problems:
|
|
print(f" {m}", file=sys.stderr)
|
|
return 1
|
|
print("schema-singleton OK: the file_type enum is the six-value superset everywhere.")
|
|
return 0
|
|
|
|
if args.monolith_roundtrip:
|
|
keys = [args.platform] if args.platform else sorted(platforms)
|
|
all_problems = []
|
|
for key in keys:
|
|
all_problems.extend(monolith_roundtrip(platforms[key]))
|
|
if all_problems:
|
|
print("monolith-roundtrip FAILED:", file=sys.stderr)
|
|
for m in all_problems:
|
|
print(f" {m}", file=sys.stderr)
|
|
return 1
|
|
print("monolith-roundtrip OK: each monolith matches v8 modulo the enum unification.")
|
|
return 0
|
|
|
|
if args.always_on_roundtrip:
|
|
problems = always_on_roundtrip()
|
|
if problems:
|
|
print("always-on-roundtrip FAILED:", file=sys.stderr)
|
|
for m in problems:
|
|
print(f" {m}", file=sys.stderr)
|
|
return 1
|
|
print("always-on-roundtrip OK: each always_on/*.md reproduces its former constant byte for byte.")
|
|
return 0
|
|
|
|
artifacts = render_all(platforms, only=args.platform)
|
|
|
|
if args.check:
|
|
problems = check(artifacts)
|
|
if problems:
|
|
print("check FAILED (skill artifacts have drifted):", file=sys.stderr)
|
|
for m in problems:
|
|
print(f" {m}", file=sys.stderr)
|
|
return 1
|
|
print(f"check OK: {len(artifacts)} artifact(s) match committed output and expected/.")
|
|
return 0
|
|
|
|
if args.bless:
|
|
written = bless(artifacts)
|
|
print(f"blessed {len(written)} artifact(s) into expected/.")
|
|
return 0
|
|
|
|
written = write_artifacts(artifacts)
|
|
print(f"rendered {len(written)} artifact(s):")
|
|
for path in written:
|
|
print(f" {path}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|