Files
zeekayandHanzo Dev 0671d5aad8 chore: rename the last live symbols, and let the rule see them
Four internal names, none of them on a wire:

  is_hog_identifier_kind    pub(crate) in the InsightsQL parser
  runHogliListUnits/DevApply unexported, in the phrocs TUI
  "Hogli's db:restore-..."   a CI comment
  ".TH HOGLI 1"              the generated man page's title

And the reason all four survived a rule that exists to refuse exactly
this: `HOGLI_` only matches when an underscore follows. The man page
says `HOGLI 1`, the TUI says `Hogli`, and `tools/insightscli/AGENTS.md`
wrote the heading as `HOGLI\_\*` -- markdown-escaped, so the backslash
sat between the letters and the underscore the rule was anchored on.
Its own body already said `INSIGHTSCLI_` throughout; only the heading
was stale.

So the rule takes the escaped form, the bare word, and the capitalized
one. Checked against `INSIGHTSCLI_DEBUG` and `hogline`, which must stay
unmatched.

Rust and Go renames are whole-word, with definitions and call sites
counted and no leftovers; neither toolchain is installed here, so that
count is the verification, not a compile.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-13 20:58:01 -07:00

2220 lines
105 KiB
Python
Executable File

#!/usr/bin/env python3
"""Turn an upstream PostHog tree into the Hanzo Insights tree.
This is the single source of truth for the fork's rename. Convergence with
upstream is a recurring job, so the rename is a program, not a memory: point
this at a freshly-imported upstream tree and it reproduces our naming exactly.
bin/debrand rewrite content, then rename paths
bin/debrand --check assert the invariants; exit 1 on violation
bin/debrand --stat report what would change, touch nothing
Idempotent: applying it to an already-debranded tree is a no-op. That holds
because no rule's replacement contains another rule's pattern, and the rules
are applied longest-pattern-first so `posthog` is consumed before the bare
`hog` rule can see it.
Rules are derived from the shipped fork, not invented. `bin/debrand --verify`
in the convergence notes explains how to re-measure them against a known-good
tree when upstream renames something out from under us.
"""
from __future__ import annotations
import argparse
import ast
import glob
import json
import os
import re
import subprocess
import sys
import tomllib
from collections.abc import Iterable
# --- content rules ------------------------------------------------------
#
# Order is load-bearing where a pattern is a prefix of another; the table is
# grouped most-specific-first and the bare brand rules come last. `com -> ai`
# is NOT a rule: it only ever appears inside the domain, so it lives there.
CONTENT_RULES: list[tuple[str, str]] = [
# Domains and social handles.
("posthog.com", "hanzo.ai"),
("PostHog.com", "Hanzo.ai"),
("app.posthog", "app.insights"),
# npm scopes. The design system is `elements`; icons keep their name.
("@posthog/lemon-ui", "@hanzo/elements"),
("@posthog/hogvm", "@hanzo/scriptvm"),
("@posthog/icons", "@hanzo/icons"),
("@posthog/", "@hanzo/"),
# The scope also appears without its slash -- as a path segment in
# `path.resolve(dir, '@posthog', 'brand')`, as `@posthog[\\/]` inside a
# bundler regex, and in `!frontend/@posthog` in .dockerignore. Without this
# rule those fall through to the bare brand rule below and become
# `@insights`, a scope we do not publish under, so the alias silently
# resolves to nothing. PATH_RULES has always carried the bare form; this is
# content catching up.
("@posthog", "@hanzo"),
# SDKs. The python SDK package is `hanzo_insights`; the browser SDK keeps
# the product name.
("posthoganalytics", "hanzo_insights"),
("posthog-js", "insights-js"),
("posthog_js", "insights_js"),
# Query language. Handled by CONTENT_REGEX rather than here, because the
# suffix carries seven casings across the tree (`HOGQL`, `HogQL`, `Hogql`,
# `hogQL`, `hogQl`, `HogQl`, `hogql`) and only the prefix is ours to rename.
# Seven string pairs was the shape that kept coming up one short.
# Warehouse. We say datastore everywhere. The ORM is first because our fork
# flattened the vendor's namespace package: `infi.clickhouse_orm` is one
# distribution, and the generic rule below would leave the `infi.` prefix
# on a module that no longer has one.
("infi.clickhouse_orm", "datastore_orm"),
("infi-clickhouse-orm", "datastore-orm"),
("CLICKHOUSE", "DATASTORE"),
("ClickHouse", "Datastore"),
("Clickhouse", "Datastore"),
# camelCase identifiers, the same shape as `postHog` below and missing for
# the same reason it was: a table of whole-word pairs reads the tree in the
# casings a sentence uses and misses the ones an identifier uses. Without it
# `clickHouseEvent`, `clickHouseTimestampToISO` and `clickHousePersons` were
# the only spelling of the engine left in our own code, across nodejs and the
# frontend, while every type they touched already said Datastore. Renames the
# prefix only, so the rest of the name stays exactly as its author wrote it.
("clickHouse", "datastore"),
("clickhouse", "datastore"),
# Script VM, flows, functions.
("hog-transformations", "script-transformations"),
("hog-functions", "insights-functions"),
("hog_functions", "insights_functions"),
("HogFunction", "InsightsFunction"),
("hogFunction", "insightsFunction"),
("hog_function", "insights_function"),
# Both spellings are needed here, not just in PATH_RENAMES: renaming the
# directory alone leaves `from .hog_flow.hog_flow import ...` pointing at a
# path that no longer exists.
("hog_flow", "insights_flow"),
("hogflows", "insightsflows"),
("hogflow", "insightsflow"),
("HogFlow", "InsightsFlow"),
# The VM's name is in CONTENT_REGEX with the query language's, for the same
# reason: `hogvm`, `HogVM`, `Hogvm`, `hogVm` and `HOGVM` are all in the tree.
# Ahead of HOG_, which would otherwise split POSTHOG_FOO into POST + HOG_
# and yield INSIGHTS_FOO — an env var no deployment sets. The live pods set
# INSIGHTS_SKIP_MIGRATION_CHECKS, read by bin/migrate-check on the container
# entrypoint, so getting this wrong stops the container rather than a feature.
("POSTHOG", "INSIGHTS"),
# The mascot's name shouts too, and for the same two reasons the brand's
# does. Left out, `HEDGEHOG` was rewritten by nothing and reached the tree
# four times -- an installer message, a stale generated enum member, and
# the banner constant in three report scripts whose own comment already
# said "mascot". Placed here rather than beside the other spellings below
# because `HOG_` overlaps its right edge exactly as it overlaps the
# brand's: run second, `HEDGEHOG_MODE` becomes `HEDGEINSIGHTS_MODE`, a
# name nothing defines. Same failure, same remedy, same position.
("HEDGEHOG", "MASCOT"),
# An enum member, and the same right-edge overlap as HEDGEHOG above: run
# after `HOG_`, a sibling named REVIEW_HOG_SOMETHING would come out
# REVIEW_INSIGHTS_SOMETHING. The member's value was already `review`; only
# the member said otherwise, in three enums that had to agree.
("REVIEW_HOG", "REVIEW"),
# HOG_ names the script engine's constants. Measured against the shipped
# tree: every HOG_ name that has a counterpart there is spelled INSIGHTS_
# (HOG_QL -> INSIGHTS_QL, HOG_FUNCTION_TEMPLATES -> INSIGHTS_FUNCTION_TEMPLATES,
# 12 of 12 determinate cases); INSIGHTS_ appears nowhere in it.
("HOG_", "INSIGHTS_"),
# Mascot, CLI, person service.
("Hedgehog", "Mascot"),
("HedgeHog", "Mascot"),
("hedgehog", "mascot"),
# The CLI's env prefix, which HOG_ above cannot reach: `HOGLI_` is
# H-O-G-L, so `HOG_` never matches it and the lowercase rule below never
# sees an uppercase name. The prefix is the program's own name, so it
# follows the program: bin/insightscli reads INSIGHTSCLI_DEBUG. There is
# no alias -- an env var read under two names is two names.
("HOGLI_", "INSIGHTSCLI_"),
("hogli", "insightscli"),
# The package is `personinsights`; the types inside it are `Person*`, so
# nothing stutters at the call site (`personinsights.PersonService`). Both
# halves are measured, not chosen: the shipped descriptor in
# personinsights_client/proto/generated says PersonService, and the test
# classes say TestFakePersonClient. Only the .proto still disagreed --
# which meant the service name the client dials and the one the server
# would have served were two different strings.
("PersonHog", "Person"),
("Personhog", "Person"),
("person_hog", "person"),
("personhog", "personinsights"),
# The Stripe mock's fictional customer. This fork already has one made-up
# company -- Hedgebox, in tools/hedgebox-dummy and five hundred fixtures --
# and a second one that exists only to carry the mascot is a name we were
# keeping for no reason.
("StreamHog", "Hedgebox"),
("STREAMHOG", "HEDGEBOX"),
("streamhog", "hedgebox"),
# Physical names. Both were carried for a while on the grounds that a
# table is a fact rather than a word, which is true right up until the
# table is empty -- and both were. `hog_invocation_results` holds function
# invocations, so it is `invocations`; the heavy-hitter store counts per
# function, so it is `topfn`. Neither is reachable by the bare `\bhog\b`
# rule below (`_` and `p` are word characters, so there is no boundary to
# match on), which is why each needs saying here.
("hog_invocation_results", "invocations"),
("TopHog", "TopFn"),
("tophog", "topfn"),
("PostholgStories", "InsightsStories"),
("PosthogStories", "InsightsStories"),
# Design system.
("lemon-ui", "elements"),
("lemonToast", "toast"),
# Core brand. Late, so the compounds above -- the rules whose patterns
# CONTAIN the brand -- match first. "Last, so the compounds above win" was
# the old reasoning here, and it is only half the constraint: a rule does
# not have to contain the brand to break it, only to overlap its edge, and
# `HOG_` overlaps the right edge of `POSTHOG_`. So the uppercase spelling is
# not in this group -- it runs above, ahead of `HOG_` -- and
# `brand_survives_adjacent_rules` is what holds it there.
("PostHog", "Insights"),
("Posthog", "Insights"),
# camelCase identifiers: postHogStatusTooltip -> insightsStatusTooltip.
("postHog", "insights"),
("posthog", "insights"),
]
# Applied after the table above. `Lemon<Component>` is the design-system
# prefix strip: LemonButton -> Button. `hog` alone means a script, and `hoge`
# is that script compiled -- upstream's pairing, kept as `.py`/`.pyc` keeps it,
# because the two names have to stay one letter apart or the compiler and the
# runner stop agreeing about what they hand each other.
CONTENT_REGEX: list[tuple[re.Pattern[str], str]] = [
# `Lemon` names our design system, but it also opens `LemonSqueezy` -- a
# payments vendor whose warehouse source we import. Stripping the prefix off
# a name we do not own renames someone else's product to `Squeezy`, so the
# third parties are named here and the rule declines them.
(re.compile(r"\bLemon(?!Squeezy)(?=[A-Z][A-Za-z0-9]*)"), ""),
# The query language and the VM. Only the prefix is ours -- `QL` is SQL's
# and `VM` is a virtual machine's -- so the rule renames the prefix and
# leaves the suffix's casing exactly as the author wrote it. One rule per
# prefix casing covers every spelling in the tree and every one upstream
# coins next, which a table of whole-word pairs cannot: it was seven pairs
# deep and still missing `Hogql`, `hogQl`, `HogQl`, `Hogvm`, `hogVm` and
# `HOGVM`. Ahead of the bare `hog` word rule, which these overlap.
(re.compile(r"HOG(?=[Qq][Ll])"), "INSIGHTS"),
(re.compile(r"Hog(?=[Qq][Ll])"), "Insights"),
(re.compile(r"hog(?=[Qq][Ll])"), "insights"),
(re.compile(r"HOG(?=[Vv][Mm])"), "SCRIPT"),
(re.compile(r"Hog(?=[Vv][Mm])"), "Script"),
(re.compile(r"hog(?=[Vv][Mm])"), "script"),
# Ahead of the bare word only for reading order: `hog` cannot match inside
# `hoge` anyway, because `g`->`e` is not a boundary. That asymmetry is what
# split the language three ways before -- `".hog"` was rewritten to
# `".script"` and `".hoge"` was left alone, so the runner looked for one
# extension, the compiler wrote another, and the files on disk carried a
# third. One rule per spelling, in one table, is the fix.
(re.compile(r"\bhoge\b"), "scriptc"),
(re.compile(r"\bHoge\b"), "Scriptc"),
(re.compile(r"\bhog\b"), "script"),
(re.compile(r"\bHog\b"), "Script"),
# Django squashes app label and model name into one lowercase word for a
# table, so the separatorless spelling is the shape a PHYSICAL name takes:
# `insights_hogfunction`, the constraint `unique_hogfunction_revision_
# version`, the migration `0948_hogfunction_batch_export`. Those are
# identities the database holds, refused here for the same reason
# MIGRATION_RE refuses a migration filename. The lookbehind is the whole
# distinction: joined to a word on its left it is a database identifier,
# standing at a token boundary it is a label -- a kea path, a toast id, a
# template directory -- and only labels are ours to rename.
(re.compile(r"(?<![A-Za-z0-9_])hogfunction"), "insightsfunction"),
]
# --- the ordering invariant ---------------------------------------------
#
# Order is load-bearing in two directions, and only one of them was ever
# written down. A rule whose pattern CONTAINS the brand -- `posthog.com`,
# `@posthog/` -- must run BEFORE the brand rule, or the brand rule fires first
# and the compound never matches. That much the table has always said. The
# other direction is that a rule need not contain the brand to destroy it: it
# only has to overlap the brand's edge. `HOG_` overlaps the right edge of
# `POSTHOG_`, so running it first split `POSTHOG_TEAM_ID` into `POST` + `FN_`,
# and the brand rule -- correctly ordered last, per the old reasoning -- then
# had nothing left to match. The result was `POSTFN_TEAM_ID`: a name nothing
# defines, nothing sets, and no reader of the table would predict. It reached
# production and crash-looped there.
#
# The brand as it is actually spelled. Their replacements are read out of the
# table rather than restated here, so the two cannot drift apart.
BRAND_TOKENS = ("POSTHOG", "PostHog", "Posthog", "postHog", "posthog")
# A brand token almost never stands alone -- it is `POSTHOG_TEAM_ID`,
# `posthogInstance`, `PostHog2` -- and it is the character AFTER it that gives
# an adjacent rule something to overlap. Deliberately excludes `-`, `.` and `/`:
# those legitimately begin a compound the table handles first (`posthog-js`,
# `posthog.com`, `@posthog/`), so probing them would report a working rule as a
# violation.
IDENT_CONTINUATIONS = ("_", "A", "z", "0")
# --- path rules ---------------------------------------------------------
#
# Deliberately NOT the content table. `kafka` is the wire protocol and stays
# in code; only the directory that wraps it is ours to name. Conversely the
# `hog -> script` word rule must never touch a path, where it would maul
# unrelated names.
PATH_RULES: list[tuple[str, str]] = [
("posthog.com", "hanzo.ai"),
("hog-transformations", "script-transformations"),
("hog-functions", "insights-functions"),
# `hog-function-utils.ts` is imported as `./script-function-utils` once the
# content rule rewrites the bare word, so the file has to move with it.
("hog-function-utils", "script-function-utils"),
("hog_functions", "insights_functions"),
("hog_function", "insights_function"),
("HogFunction", "InsightsFunction"),
# camelCase too, because CONTENT_RULES rewrites it and the import has to
# land on the file: `hogFunctionConfigurationLogic.tsx` is imported as
# `./insightsFunctionConfigurationLogic` the moment content is rewritten.
("hogFunction", "insightsFunction"),
("hog_flow", "insights_flow"),
("HogFlow", "InsightsFlow"),
("hogflows", "insightsflows"),
("hogflow", "insightsflow"),
("PosthogStories", "InsightsStories"),
("PersonHog", "Person"),
("person_hog", "person"),
("personhog", "personinsights"),
# The two physical names also name directories: the model package that
# holds each family's SQL, and the skill that documents querying one.
("hog_invocation_results", "invocations"),
("TopHog", "TopFn"),
("tophog", "topfn"),
("HEDGEHOG", "MASCOT"),
("Hedgehog", "Mascot"),
("HedgeHog", "Mascot"),
("hedgehog", "mascot"),
("hogli", "insightscli"),
("lemon-ui", "elements"),
("clickhouse", "datastore"),
("ClickHouse", "Datastore"),
("posthoganalytics", "hanzo_insights"),
("posthog-js", "insights-js"),
("@posthog", "@hanzo"),
("PostHog", "Insights"),
("Posthog", "Insights"),
("posthog", "insights"),
]
# Directory renames that are ours by convention rather than by branding.
# `kafka` is scoped to the client wrapper so real vendored Kafka code and the
# livestream Go bindings keep their upstream names.
PATH_DIR_RULES: list[tuple[str, str]] = [
("nodejs/src/kafka/", "nodejs/src/stream/"),
("nodejs/src/session-recording/kafka/", "nodejs/src/session-recording/stream/"),
]
# An import specifier IS a path, so a path shares CONTENT_REGEX -- the same
# object, not a copy of it. Two lists that had to agree is precisely what kept
# not agreeing: the design-system strip landed in content and not in paths, and
# every `@hanzo/elements` import dangled; the script extension landed in paths
# and not in content, and the runner, the compiler and the files on disk ended
# up spelling it three different ways; `-hog-` was rewritten to `-mascot-` in
# paths while content said `script`, so ReviewHog's skill directories stopped
# matching the prefix that discovers them and the product silently ran with no
# perspectives at all. Sharing the list is what makes those unrepresentable --
# there is nothing left to keep in step.
#
# Every rule here is therefore constrained twice over: it must be right for a
# path AND right for a file's bytes. `\bLemon(?=[A-Z])` and nothing looser,
# because `lemon_squeezy` is Lemon Squeezy, a payments vendor whose warehouse
# source we import, and a rule matching a bare `lemon` would rename someone
# else's product. `\bhog\b` cannot reach `hedgehog`, `tophog`, `stamp`,
# `hog_invocation_results` or `hogfunction` -- every one of those is named
# above instead, because a rule that cannot reach a name has not spared it, it
# has only failed to see it.
PATH_REGEX = CONTENT_REGEX
# Compose files follow the house convention.
PATH_EXACT_RULES: dict[str, str] = {
"docker-compose.base.yml": "compose.base.yml",
"docker-compose.dev.yml": "compose.dev.yml",
"docker-compose.dev-full.yml": "compose.dev-full.yml",
"docker-compose.dev-minimal.yml": "compose.dev-minimal.yml",
"docker-compose.dev-coordinator.yml": "compose.dev-coordinator.yml",
"docker-compose.hobby.yml": "compose.hobby.yml",
"docker-compose.playwright.yml": "compose.playwright.yml",
}
# --- scope --------------------------------------------------------------
#
# Upstream ships products we do not run. Carrying them costs review surface,
# CI time and image size, and each one is a Django app that must import
# cleanly at boot. Dropping them is the same decision the fork made before,
# re-derived: every entry here is a product absent from the shipped tree.
#
# Not listed, and deliberately kept: alerts, annotations, subscriptions,
# exports, event_definitions, access_control, notifications, messaging and
# session_replay. Upstream is mid-extraction on those — the implementation
# still also lives in the core app, which we DO run, so dropping the
# products/ half risks removing live code rather than dead weight.
DROP_DIRS: list[tuple[str, str]] = [
("products/warehouse_sources", "managed warehouse connectors; we do not run them"),
("products/warehouse_sources_queue", "queue for the above"),
("products/desktop", "desktop recorder app"),
("products/ai_observability", "we run LLM observability on Hanzo o11y"),
("products/replay_vision", "vision model over replays"),
("products/review_hog", "upstream code-review bot"),
("products/engineering_analytics", "upstream-internal engineering metrics"),
("products/mcp_analytics", "upstream MCP analytics"),
("products/mcp_store", "upstream MCP registry"),
("products/tracing", "we trace on Hanzo o11y"),
("products/metrics", "we meter on Hanzo o11y"),
("products/visual_review", "visual regression review"),
("products/business_knowledge", "knowledge base product"),
("products/data_catalog", "catalog product"),
("products/stamp", "upstream stamp/rewards toy"),
(
"products/games",
"two mascot easter-egg games (368 hedgehogs, flappy hedgehog). The last "
"place upstream's mascot artwork still rendered, and the artwork is the "
"whole product -- there is nothing to debrand, only to drop",
),
("products/legal_documents", "upstream legal doc workflow"),
("products/streamlit_apps", "arbitrary Streamlit hosting"),
("products/skills", "agent skill registry"),
("products/pulse", "upstream internal pulse survey"),
("products/demo", "upstream demo-data product"),
("products/wizard", "upstream onboarding wizard"),
("products/approvals", "approval workflow product"),
("products/reminders", "reminder product"),
("products/data_tools", "misc data tooling product"),
("products/platform_features", "upstream platform feature registry"),
("products/field_notes", "field notes product"),
("products/ai_gateway", "we route models through the Hanzo gateway"),
("products/ingestion", "ingestion product shell"),
("products/support", "upstream support product"),
("products/integrations", "upstream integration shell"),
("ee", "enterprise-licensed tree; we run none of it and it is not installed"),
("posthog/migrations", "replaced by our squashed initial migration"),
]
# Directories that are build or test caches. They regenerate, and carrying a
# stale one is worse than carrying none.
DROP_GLOBS: list[tuple[str, str]] = [
(".sqlx/", "sqlx offline query cache; regenerated from the database"),
]
# --- attribution --------------------------------------------------------
#
# A licence is the one text in the tree that is not ours to rewrite. The MIT
# grant we hold this code under is conditioned on carrying the original
# copyright notice forward, so renaming the upstream company inside a licence
# both breaks that condition and asserts our copyright over their code. It is
# also invisible: the file still reads like a valid licence.
#
# This is structural, not an allowlist entry, because the allowlist is a list
# someone can add to and this must not be opt-out-able. Paths still rename —
# a licence that moves with its directory is fine. Only the bytes are frozen.
LICENCE_FILE = re.compile(
r"^(licen[cs]e|copying|copyright|notice)" # the notice itself
r"([-_][a-z0-9\-]+)?" # an optional variant: LICENSE-MIT
r"(\.(md|txt|rst|markdown))?$", # prose only: license.py is a module
re.IGNORECASE,
)
def is_licence(path: str) -> bool:
return bool(LICENCE_FILE.match(os.path.basename(path)))
# --- allowlist ----------------------------------------------------------
#
# Content that legitimately still says the upstream name. Each entry is a
# path and the reason it is exempt. This list is the whole exemption surface:
# anything not named here must come out clean, and --check enforces that.
CONTENT_ALLOWLIST: dict[str, str] = {
"bin/debrand": (
"This file. The rules are the upstream names, so it necessarily "
"spells them out."
),
"insights/datastore/migrations/0300_invocations.py": (
"Retires `hog_invocation_results` by dropping it. A migration that "
"drops a table has to name the table, and this is the one place the "
"old spelling still means something -- everywhere else it is a leak."
),
"bin/debrand-selftest": (
"Tests this file. Its fixtures are an upstream copyright notice that "
"must survive verbatim and an upstream import that must not, so it "
"spells out both; rewriting them would assert the tests pass by "
"editing what they compare against."
),
"LLM.md": (
"States which upstream commit we last converged from. Naming the "
"upstream is the point of the sentence. It also records the brand "
"hues and the wordmark path data the marks check refuses, so the "
"next reader knows what those magic strings are; spelling them is "
"how the note earns its keep."
),
"insights/management/commands/migrate_datastore.py": (
"LEGACY_PACKAGE_NAMES reads historical rows already written to the "
"datastore migrations table under their original package names. "
"Renaming these strings would orphan every applied migration."
),
"playwright/live/smoke.spec.ts": (
"The live smoke asserts the upstream name is ABSENT from the served "
"page; the assertion must spell the name it forbids."
),
"frontend/src/scenes/saved-insights/insightTypesMetadata.tsx": (
"Aliases `IconHogQL` from the icons package, which we take from "
"upstream under our own scope. The export name is theirs; aliasing it "
"on import is how the name stops at the boundary."
),
"nodejs/src/common/utils/insights.ts": (
"Aliases `PostHog` from `insights-node`, which pnpm-workspace.yaml "
"serves from the upstream package. That class name is what the package "
"exports, so the import spells it and renames it on the way in -- the "
"same boundary as IconHogQL above. Rewriting it produces `Insights as "
"Insights`, an import of a name the package does not have."
),
"Dockerfile.node": (
"One comment, naming the upstream package that `insights-node` is a "
"registry alias for. The sentence exists to say which artifact the "
"alias resolves to, so renaming it leaves the alias pointing at itself."
),
"rust/capture-logs/src/log_record.rs": (
"`#[derive(Row)]` expands to an unqualified `clickhouse::Row`, resolved "
"in this crate's namespace rather than the macro's, so the crate we link "
"as `datastore` also has to be bound under the name the generated code "
"emits. The file's comment is about that one forced binding, and names "
"the macro, the crate and the path it writes."
),
}
# Never rewrite bytes in these. Lockfiles pin upstream package identities we
# genuinely depend on; binaries and archives are not text.
SKIP_CONTENT_SUFFIX = (
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".svg.gz", ".woff", ".woff2",
".ttf", ".eot", ".mp4", ".webm", ".mp3", ".wav", ".pdf", ".zip", ".gz", ".br",
".bz2", ".zst", ".tar", ".whl", ".so", ".dylib", ".dll", ".wasm", ".bin",
".jsdata", ".parquet", ".avro", ".pyc",
)
SKIP_CONTENT_NAMES = {"pnpm-lock.yaml", "uv.lock", "Cargo.lock", "package-lock.json", "poetry.lock"}
# A licence is the one text in the tree that a rename must never touch.
#
# MIT's single condition is that the copyright notice travels with the code, so
# rewriting the copyright holder's name inside it does not debrand anything --
# it asserts our copyright over someone else's work, and it does so invisibly,
# because the file still reads like a valid licence. `--check` therefore also
# refuses to let the upstream notice go missing.
#
# This is a refusal, not an allowlist entry: allowlisted files are ones we have
# The notice MIT obliges us to carry, and the file that has to carry it.
REQUIRED_NOTICE = "PostHog Inc."
LICENSE_PATH = "LICENSE"
def tracked_files(root: str) -> list[str]:
out = subprocess.run(
["git", "ls-files", "-z"], cwd=root, capture_output=True, check=True
).stdout
return [p.decode() for p in out.split(b"\0") if p]
# Identities that belong to someone else, in the files where they must still
# resolve. Renaming an upstream GitHub org or a tag cut in their repo invents
# a URL that 404s, and it surfaces far away as an unresolvable dependency.
# Scoped to manifests on purpose: a link in prose is a doc link and gets
# rewritten like everything else, but a dependency source has to fetch.
# Pointing one of these at a repo we host instead is a curated decision that
# belongs in the manifest, not something a rename can derive.
PRESERVE: list[re.Pattern[str]] = [
# Case-insensitive on the org: GitHub treats it that way, upstream spells it
# both ways in its own manifests, and a case-sensitive rule silently rewrote
# `github.com/posthog/axum-test-helper` into an org that does not exist —
# which surfaces far away, as a cargo fetch failure inside a wheel build.
re.compile(r"https?://(?:www\.)?github\.com/posthog[\w.\-/]*", re.IGNORECASE),
re.compile(r"\bv[\d.]+-posthog\.\d+\b"),
# A third party's distribution name. `hog -> script` reads this as a word
# in a sentence and invents `drf-exceptions-script`, which resolves
# nowhere. Renaming a package we merely depend on has no upside: it cannot
# make the dependency ours, only unresolvable.
re.compile(r"\bdrf-exceptions-hog\b"),
# The far side of an alias. `insights-node: npm:[email protected]` says
# our name for it and whose package it actually is; rewriting the second
# half would point the alias back at itself. Same for cargo's `package =`
# and for a version pinned in the release-age exclusions.
#
# The version runs to the closing quote rather than through a word class:
# `^1.12.0` is a range, not a version, and `^` is not a word character, so
# the old pattern simply failed to match `npm:@clickhouse/client@^1.12.0` and
# left the one alias in the tree that carries a caret unheld. The rule read
# as if it covered every alias and covered only the exactly-pinned ones.
re.compile(r"""npm:@?[\w.\-]+(?:/[\w.\-]+)?@[^'"\s,}\]]+"""),
re.compile(r'package = "[\w.\-]+"'),
re.compile(r"'@posthog/[\w.\-]+@[\w.\-]+'"),
]
# pnpm 10 reads overrides, catalogs and workspace settings from
# pnpm-workspace.yaml rather than package.json, which makes it a dependency
# manifest and subject to the same rule: identities in it have to resolve.
MANIFESTS = {"pyproject.toml", "Cargo.toml", "package.json", "pnpm-workspace.yaml"}
# Our own repository, which is the one github.com/posthog URL in the tree that
# is not a dependency. It has to be substituted before PRESERVE runs, or the
# rule above holds it as a third party's and every homepage, issues and clone
# URL keeps pointing at the project we forked from.
SELF_URL = re.compile(r"github\.com/posthog/posthog(?![\w-])", re.IGNORECASE)
SELF_URL_TO = "github.com/hanzoai/insights"
# --- workspace ----------------------------------------------------------
#
# Dropping a product removes a pnpm workspace package, and every manifest that
# depended on it is left naming a package that is no longer there. `workspace:*`
# has no registry to fall back on, so pnpm treats an unresolvable one as fatal:
#
# ERR_PNPM_WORKSPACE_PKG_NOT_FOUND In frontend: "@hanzo/products-games@
# workspace:*" is in the dependencies but no package named ... is present
#
# That is the first line of the Docker build, so a drop that skips the manifest
# does not fail where the product was removed -- it fails the image.
def workspace_globs(root: str) -> tuple[list[str], list[str]]:
"""The `packages:` list from pnpm-workspace.yaml, as (include, exclude).
Parsed by hand rather than with PyYAML: this script is the convergence tool
and runs wherever an upstream import lands, so it stays dependency-free.
The list is a flat sequence of scalars, which is the one YAML shape this
needs to understand.
"""
path = os.path.join(root, "pnpm-workspace.yaml")
if not os.path.exists(path):
return [], []
inc: list[str] = []
exc: list[str] = []
in_packages = False
for line in open(path, encoding="utf-8"):
if not line.strip() or line.lstrip().startswith("#"):
continue
if re.match(r"^\S", line): # a new top-level key ends the list
in_packages = line.startswith("packages:")
continue
if not in_packages:
continue
mm = re.match(r"\s*-\s*(.+?)\s*$", line)
if not mm:
continue
pat = mm.group(1).strip().strip("'\"")
(exc if pat.startswith("!") else inc).append(pat.lstrip("!"))
return inc, exc
def workspace_packages(root: str) -> dict[str, str]:
"""Map every package name in the workspace to its directory."""
inc, exc = workspace_globs(root)
dirs: set[str] = set()
for pat in inc:
for d in glob.glob(os.path.join(root, pat)):
if os.path.isdir(d):
dirs.add(os.path.normpath(d))
for pat in exc:
for d in glob.glob(os.path.join(root, pat)):
dirs.discard(os.path.normpath(d))
names: dict[str, str] = {}
for d in sorted(dirs):
manifest = os.path.join(d, "package.json")
if not os.path.exists(manifest):
continue
try:
name = json.load(open(manifest, encoding="utf-8")).get("name")
except (OSError, ValueError):
continue
if name:
names[name] = os.path.relpath(d, root)
return names
DEP_SECTIONS = ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies")
def dangling_workspace_deps(root: str) -> list[tuple[str, str, str]]:
"""Every `workspace:` dep naming a package the workspace does not have."""
present = workspace_packages(root)
out: list[tuple[str, str, str]] = []
for _, rel in sorted(present.items(), key=lambda kv: kv[1]):
manifest = os.path.join(root, rel, "package.json")
try:
data = json.load(open(manifest, encoding="utf-8"))
except (OSError, ValueError):
continue
for section in DEP_SECTIONS:
for dep, spec in (data.get(section) or {}).items():
if isinstance(spec, str) and spec.startswith("workspace:") and dep not in present:
out.append((os.path.join(rel, "package.json"), section, dep))
return out
def prune_manifests(root: str, dry: bool) -> int:
"""Drop `workspace:` deps on packages the workspace no longer contains.
Derived from the tree rather than from DROP_DIRS: a dep is removed only
once the package it names is genuinely absent, so this repairs a drop that
a previous pass left half-done as readily as one made in this pass, and it
needs no second list to keep in step with the first. Each removal is
printed, because a package missing for any other reason must be visible
rather than quietly deleted from the manifest.
"""
dangling = dangling_workspace_deps(root)
if not dangling:
return 0
by_manifest: dict[str, list[tuple[str, str]]] = {}
for rel, section, dep in dangling:
by_manifest.setdefault(rel, []).append((section, dep))
for rel, items in sorted(by_manifest.items()):
for section, dep in items:
print(f"{'would drop' if dry else 'dropped'} {dep} from {rel} ({section})")
if dry:
continue
path = os.path.join(root, rel)
raw = open(path, encoding="utf-8").read()
data = json.loads(raw)
for section, dep in items:
data.get(section, {}).pop(dep, None)
# Rewritten with json.dumps rather than by deleting lines, so the file
# stays valid whatever shape the entry had. The indent is read back off
# the file so re-serializing touches only the removed entry -- a manifest
# reformatted wholesale would bury the change in noise and fight the
# repo's own formatter.
mm = re.search(r"^(\s+)\"", raw, re.M)
indent = len(mm.group(1)) if mm else 4
open(path, "w", encoding="utf-8").write(
json.dumps(data, indent=indent) + ("\n" if raw.endswith("\n") else "")
)
return len(dangling)
# A name that identifies something outside this tree is not ours to spell. A
# Postgres table, a Datastore table and a route a client calls are all read back
# by something that was not rebuilt from this checkout, so renaming one to match
# a file move is a data incident rather than a refactor. The `hog_flow` rules
# would do exactly that on the next run, so those identities are held out of the
# rewrite and put back verbatim.
#
# Each is anchored on what makes it an identity and not a label: the table's
# `db_table`, the Datastore table's dotted id, the column's `_id`, and -- for the
# route -- the trailing slash, which is what separates `/hog_flows/` on the wire
# from the `hog_flows.py` module that PATH_RULES may rename freely.
#
# The storage engine is the same distinction drawn one layer down. Our fork of it
# is Datastore and every label we chose says so, but the engine we forked still
# answers to its own name in the places another program does the reading: a word
# in the server's DDL grammar, a symbol a package exports, an option key a driver
# puts on the wire, a path a macro expands to. Those are held, and `check_engine`
# reads this same list to decide which residual spellings are legitimate -- one
# table, so the rewrite and the refusal cannot drift apart.
PRESERVE_IDENT: list[re.Pattern[str]] = [
# Held for the migrations that renamed it, which have to name both sides.
# It is no longer a live table: the rename landed, the compatibility view
# that stood in for it was dropped, and `to_regclass('insights_hogflow')`
# answers NULL. Anything outside a migration that still selected from it
# was selecting from nothing -- the whole nodejs flow and function manager
# did, which is what this entry was quietly protecting.
re.compile(r"insights_hogflow[a-z]*"),
re.compile(r"system\.hog_flows"),
re.compile(r"\bhog_flow_id\b"),
re.compile(r"/hog_flows/"),
# The dictionary source type, resolved against a fixed registry the server
# compiles in. `SOURCE(DATASTORE(...))` is answered with "unknown dictionary
# source type", so the dictionary is never created and the failure surfaces
# far away, as a read failing on dictGet. Anchored on the absence of a
# trailing name character, which is what separates the bare grammar word from
# a `CLICKHOUSE_HOST`-shaped setting -- those are ours and still get renamed.
re.compile(r"CLICKHOUSE(?![A-Za-z0-9_])"),
# The class `@clickhouse/client` exports, which pnpm-workspace.yaml serves
# under our `@datastore/client` alias. Every import already aliases it to
# `DatastoreClient` on the way in; renaming the export itself asks the package
# for a name it does not have, and the import resolves to undefined.
re.compile(r"\bClickHouseClient\b"),
# The exception `datastore_connect` exports, aliased to `DatastoreError` at
# each import for the same reason. The name lives in that package at a pinned
# revision, so spelling it differently here is an ImportError, not a rename.
re.compile(r"\bClickHouseError\b"),
# The per-query option key the driver serializes onto the wire. It is read by
# the server, not by us, so it travels under the engine's own name.
re.compile(r"\bclickhouse_settings\b"),
# The crate path `#[derive(Row)]` expands to. The macro emits an unqualified
# `clickhouse::Row` resolved in the calling crate's namespace, so that name has
# to be bound there even though the crate is linked as `datastore`.
re.compile(r"\bas clickhouse\b|::clickhouse\b|\bclickhouse::|\bclickhouse-derive\b"),
# The crate and the npm package as their registries publish them, named in
# the prose beside the `package =` alias that takes them. The specifier is
# already held as a manifest identity; the sentence next to it saying WHICH
# artifact the alias resolves to has to survive too, or the comment ends up
# denying the one thing it exists to document -- and the next reader deletes
# the alias it was warning them to keep.
re.compile(r"`clickhouse(?:-types)?`|@clickhouse/[\w.\-]+"),
]
def _hold(s: str, pats: Iterable[re.Pattern[str]]) -> tuple[str, list[str]]:
held: list[str] = []
def take(mm: re.Match[str]) -> str:
held.append(mm.group(0))
return f"\0{len(held) - 1}\0"
for pat in pats:
s = pat.sub(take, s)
return s, held
def rewrite_text(s: str, path: str = "") -> str:
s = SELF_URL.sub(SELF_URL_TO, s)
# Manifests additionally hold the package identities they have to resolve.
holds = [*PRESERVE_IDENT]
if os.path.basename(path) in MANIFESTS:
holds += PRESERVE
s, held = _hold(s, holds)
for old, new in CONTENT_RULES:
s = s.replace(old, new)
for pat, rep in CONTENT_REGEX:
s = pat.sub(rep, s)
for i, original in enumerate(held):
s = s.replace(f"\0{i}\0", original)
return s
# A migration's filename IS its identity. Django records `0002_hogflowbatchjob_
# scheduled_at` in the `django_migrations` table when it runs, and finds it again
# by that exact string. Rename the file and Django sees the recorded migration
# gone and an unapplied one in its place, so it re-runs DDL against a schema that
# already has it -- and every `dependencies` entry naming the old filename becomes
# an unresolvable graph edge (NodeNotFoundError), which takes down `migrate` and
# every management command that builds the graph.
#
# Measured against production, five names already in `django_migrations` are
# renamed by the rules above, `workflows.0002_hogflowbatchjob_scheduled_at`
# among them. So a migration name is refused for the same reason a physical
# column name is: it is an identity that lives in the database, not a label.
MIGRATION_RE = re.compile(r"(?:^|/)migrations/\d{4}_[A-Za-z0-9_]+\.py$")
def is_migration(p: str) -> bool:
return bool(MIGRATION_RE.search(p))
def rewrite_path(p: str) -> str:
if is_migration(p):
return p
for old, new in PATH_DIR_RULES:
if p.startswith(old):
p = new + p[len(old):]
base = os.path.basename(p)
if base in PATH_EXACT_RULES:
p = os.path.join(os.path.dirname(p), PATH_EXACT_RULES[base])
for old, new in PATH_RULES:
p = p.replace(old, new)
for pat, rep in PATH_REGEX:
p = pat.sub(rep, p)
return p
def skip_content(path: str) -> bool:
return (
is_licence(path)
or path.endswith(SKIP_CONTENT_SUFFIX)
or os.path.basename(path) in SKIP_CONTENT_NAMES
)
# A product is held by any file we keep that still imports it. The tree has two
# module systems and they spell an import differently, so the closure has to
# read both or half the dependency graph is invisible to it.
PY_IMPORT = re.compile(r"(?:from|import)\s+products\.([a-z_]+)")
# TypeScript resolves `products/x/...` from the tsconfig root and
# `../../products/x/...` relatively, and a bundler follows `import()` and
# `require()` as readily as a static import. Anchored on the keyword rather
# than on the path alone, because a quoted path in a config names a product
# precisely because it is NOT built: jest's testPathIgnorePatterns and
# oxlint's ignore list would otherwise read as dependencies.
TS_IMPORT = re.compile(r"""(?:from|import|require)\s*\(?\s*['"](?:\.\./)*products/([a-z_]+)/""")
IMPORT_SOURCES: tuple[tuple[tuple[str, ...], re.Pattern[str]], ...] = (
((".py",), PY_IMPORT),
((".ts", ".tsx", ".js", ".jsx", ".mts", ".cts"), TS_IMPORT),
)
def drop_closure(root: str, files: list[str]) -> tuple[set[str], dict[str, int]]:
"""Narrow the product drop list to what nothing we keep still imports.
Upstream keeps weaving products back into the core app, so a drop list
written once goes stale silently and the first symptom is a
ModuleNotFoundError at Django startup, or a bundler resolving nothing.
Deleting a product that kept code imports is a refactor, not a delete, so
this refuses to do it and says which product held on and how hard.
"""
wanted = {d.split("/", 1)[1] for d, _ in DROP_DIRS if d.startswith("products/")}
imports: list[tuple[str, set[str]]] = []
for f in files:
pattern = next((p for exts, p in IMPORT_SOURCES if f.endswith(exts)), None)
if pattern is None:
continue
try:
src = open(os.path.join(root, f), encoding="utf-8", errors="ignore").read()
except OSError:
continue
found = {mm.group(1) for mm in pattern.finditer(src)} & wanted
if found:
imports.append((f, found))
drop = set(wanted)
while True:
held: dict[str, int] = {}
for f, used in imports:
owner = f.split("/")[1] if f.startswith("products/") and "/" in f[9:] else None
if owner in drop: # a doomed product may cite another
continue
for p in used & drop:
held[p] = held.get(p, 0) + 1
if not held:
return drop, {}
drop -= set(held)
if not held:
break
return drop, held
def prune(root: str, dry: bool) -> int:
"""Drop products we do not run, and unwire them from Django.
Order matters: this runs while paths still carry upstream names, so the
drop list reads the way upstream ships it and needs no translation.
"""
files = tracked_files(root)
drop_products, _ = drop_closure(root, files)
wanted = {d.split("/", 1)[1] for d, _ in DROP_DIRS if d.startswith("products/")}
retained = sorted(wanted - drop_products)
if retained:
print(f"kept {len(retained)} products the core still imports: {', '.join(retained)}")
active = [
d for d, _ in DROP_DIRS
if not d.startswith("products/") or d.split("/", 1)[1] in drop_products
]
doomed = [
f for f in files
if any(f == d or f.startswith(d + "/") for d in active)
or any(g in f for g, _ in DROP_GLOBS)
]
# Report what actually went, not what matched: on a converged tree the
# list still matches and a count of matches would read as work done.
doomed = [f for f in doomed if os.path.lexists(os.path.join(root, f))]
if not dry and doomed:
for i in range(0, len(doomed), 2000):
subprocess.run(["git", "rm", "-q", "--cached", "--"] + doomed[i:i + 2000],
cwd=root, check=True, capture_output=True)
for f in doomed:
p = os.path.join(root, f)
if os.path.lexists(p):
os.remove(p)
# Django holds a hand-written list of product apps. A dropped product left
# in INSTALLED_APPS is an ImportError at boot, so the two move together.
settings = os.path.join(root, "posthog/settings/web.py")
removed_apps = 0
if os.path.exists(settings):
dropped = drop_products
keep, lines = [], open(settings, encoding="utf-8").read().splitlines(keepends=True)
for line in lines:
mm = re.search(r'"products\.([a-z_]+)\.', line)
if mm and mm.group(1) in dropped:
removed_apps += 1
continue
keep.append(line)
if removed_apps and not dry:
open(settings, "w", encoding="utf-8").write("".join(keep))
# A dropped product is also a pnpm workspace package, and the manifests
# that depended on it are as much a part of the wiring as INSTALLED_APPS.
# This runs after the files are gone, because absence is what it reads.
removed_deps = prune_manifests(root, dry)
print(f"{'would drop' if dry else 'dropped'} {len(doomed)} files, "
f"{removed_apps} INSTALLED_APPS entries, {removed_deps} workspace deps")
return len(doomed)
def apply(root: str, dry: bool) -> tuple[int, int]:
files = tracked_files(root)
edited = renamed = 0
for rel in files:
src = os.path.join(root, rel)
if not os.path.isfile(src) or os.path.islink(src):
continue
if not skip_content(rel) and not is_licence(rel) and rel not in CONTENT_ALLOWLIST:
try:
raw = open(src, "rb").read()
text = raw.decode("utf-8")
except (UnicodeDecodeError, OSError):
text = None
if text is not None:
new = rewrite_text(text, rel)
if new != text:
edited += 1
if not dry:
open(src, "w", encoding="utf-8").write(new)
moves = [(r, rewrite_path(r)) for r in files]
moves = [(a, b) for a, b in moves if a != b]
renamed = len(moves)
if not dry:
# Rename in-process and stage once at the end. Shelling out per file
# costs a process per rename, which on a full-tree convergence is
# thousands of them for no gain.
for a, b in moves:
src, dst = os.path.join(root, a), os.path.join(root, b)
# lexists, not exists: a symlink whose target this pass has already
# renamed is dangling, and exists() follows the link and says no.
if not os.path.lexists(src):
continue
os.makedirs(os.path.dirname(dst), exist_ok=True)
os.replace(src, dst)
stage(root, [rewrite_path(r) for r in files])
return edited, renamed
def stage(root: str, expected: list[str]) -> None:
"""Stage the tree without letting an ignore rule quietly eat a file.
`git add -A` skips ignored paths, so a file whose name happens to match a
pattern lands on disk, stages nothing, and leaves the repository with no
diff to notice. The user's global excludes file makes that worse than a
local mistake: it is machine-specific, so the same command produces a
different tree on a different laptop, and `tags` is a common enough entry
to have already cost us a directory here.
So the fork's contents are decided by the fork: the repo's own .gitignore
is shared and reviewed and still applies, while machine-local config is
switched off for this one command. The sweep afterwards catches whatever
an in-repo rule would still have dropped, because a path this program
owns is ours to track by definition.
"""
add = ["git", "-c", "core.excludesFile=/dev/null", "add"]
subprocess.run([*add, "-A", "."], cwd=root, check=True, capture_output=True)
tracked = set(tracked_files(root))
lost = [p for p in expected
if p not in tracked and os.path.lexists(os.path.join(root, p))]
if lost:
print(f"rescued {len(lost)} files an in-repo ignore rule would have dropped")
for p in lost[:20]:
print(f" {p}")
for i in range(0, len(lost), 2000):
subprocess.run([*add, "-f", "--", *lost[i:i + 2000]],
cwd=root, check=True, capture_output=True)
LAUNDERED = re.compile(r"^.*copyright.*\binsights\b.*$", re.IGNORECASE | re.MULTILINE)
def licences_unlaundered(root: str) -> int:
"""Refuse a licence that attributes someone else's code to our brand.
This is the exact shape of the bug the refusal above prevents, asserted
from the other side: a rename that reaches a copyright line reads as a
valid licence afterwards, so nothing else in the tree would notice.
"""
hit = []
for rel in tracked_files(root):
if not is_licence(rel):
continue
try:
text = open(os.path.join(root, rel), encoding="utf-8").read()
except (UnicodeDecodeError, OSError):
continue
hit += [(rel, m.group(0).strip()) for m in LAUNDERED.finditer(text)]
if hit:
print(f"FAIL: {len(hit)} licence copyright lines name our brand over someone else's code")
for rel, line in hit[:20]:
print(f" {rel}: {line}")
return 1 if hit else 0
# The notice MIT obliges us to carry, and the file that has to carry it.
REQUIRED_NOTICE = "PostHog Inc."
LICENCE_PATH = "LICENSE"
def licence_notice_survives(root: str) -> int:
"""Refuse a LICENSE that has dropped the notice MIT obliges us to pass on.
The other half of the invariant above: laundering rewrites the holder's
name, this catches it being removed outright, and a rewrite that collapses
two copyright lines into one does both at once. Either way the file still
reads as a valid licence, so it is asserted positively rather than left to
the absence of a rule.
"""
path = os.path.join(root, LICENCE_PATH)
if not os.path.exists(path):
print(f"FAIL: {LICENCE_PATH} is missing")
return 1
if REQUIRED_NOTICE not in open(path, encoding="utf-8").read():
print(
f"FAIL: {LICENCE_PATH} no longer carries the upstream copyright notice "
f"({REQUIRED_NOTICE!r}). MIT grants us the code on condition it travels with it."
)
return 1
return 0
def path_rules_have_content_counterparts() -> int:
"""Refuse a path rename that content rewriting does not make.
A name is one value, so renaming it in only one of the two places it is
written leaves every reference to it pointing at something that is no
longer there. It fails twice over and both a long way from the rule: every
`from .x.x import` of the renamed module dangles, and so does every
migration dependency naming a renamed migration file, which Django reports
as NodeNotFoundError from `migrate` — at container start, under `set -e`.
Checked against the rules rather than the tree, because the tree only shows
the instances that exist today and the rule keeps producing more.
Both whole pipelines are run, not just the one rule: a path rule's output
goes on to meet PATH_REGEX, and a rule that agreed in isolation can still
disagree once the regexes have had it. `.hog` was that case -- the suffix
table said `.iql` and the shared regex said `.script`, and the rule-only
comparison saw nothing wrong.
"""
bad = [
(old, rewrite_path(old), rewrite_text(old))
for old, _ in PATH_RULES
if rewrite_path(old) != rewrite_text(old)
]
if bad:
print(f"FAIL: {len(bad)} path rules rename something content rewriting leaves alone")
for old, as_path, as_text in bad:
print(f" {old!r}: path -> {as_path!r}, but content -> {as_text!r}")
return 1 if bad else 0
def brand_survives_adjacent_rules() -> int:
"""Refuse a rule that splits the brand before the brand rule can match it.
Asserted against the rules rather than the tree, for the same reason the
check above is: the tree shows the names upstream happens to use today, and
the rule keeps producing more of them on every convergence.
The probe is the brand plus one identifier character, because that is the
shape the bug needs -- a bare `POSTHOG` survives being followed by nothing,
and `POSTHOG_` does not.
"""
first: dict[str, str] = {}
for old, new in CONTENT_RULES:
first.setdefault(old, new) # the first match is the one that fires
missing = [t for t in BRAND_TOKENS if t not in first]
if missing:
print(f"FAIL: {len(missing)} brand spellings have no rule, so nothing rewrites them")
for token in missing:
print(f" {token!r}")
return 1
bad = [
(token + tail, first[token] + tail, rewrite_text(token + tail))
for token in BRAND_TOKENS
for tail in IDENT_CONTINUATIONS
if rewrite_text(token + tail) != first[token] + tail
]
if bad:
print(f"FAIL: {len(bad)} brand spellings are eaten by a rule that runs before them")
print(" Move the brand rule above the rule that overlaps it.")
for probe, want, got in bad:
print(f" {probe!r} -> {got!r}, want {want!r}")
return 1 if bad else 0
def check_mangled(root: str) -> int:
"""Refuse the debris of the ordering bug above.
`POSTFN` is not a name anyone chose. It is `POSTHOG` with `HOG_` rewritten
out from under it, so every occurrence names something that was never
defined and no deployment ever sets -- `POSTFN_TEAM_ID` imported from a
module that exports `INSIGHTS_TEAM_ID`, an env var read under its real
spelling somewhere else. It fails at the import, or silently, and it did
both in production.
Kept as its own refusal rather than folded into the invariant above,
because that one guards the rules and this one guards the tree: fixing the
order stops the next convergence from minting more, and leaves every
reference the last one minted exactly where it was.
"""
hits = subprocess.run(
["git", "grep", "-ilI", "postfn"], cwd=root, capture_output=True, text=True
).stdout.split()
hits = [h for h in hits if h not in CONTENT_ALLOWLIST]
if not hits:
return 0
print(f"FAIL: {len(hits)} files carry POSTFN, which is POSTHOG split by a rule that ran too early")
print(" It is never a real name. Each one is the INSIGHTS_ name the brand rule would have produced.")
for p in hits[:20]:
print(f" {p}")
return 1
# --- marks --------------------------------------------------------------
#
# Everything above moves NAMES. A brand is also its geometry and its palette,
# and neither of those is a name, so a rename pass walks straight past them:
# `--check` was green while the upstream wordmark sat on both OAuth consent
# screens drawn as vector paths, on the toolbar error page, on the 2FA
# template, and inside frontend/src/index.html as a data URI that printed the
# logo into every visitor's browser console. Nothing in any of those files
# spelled the company. So the marks are refused directly, by what they are.
# Ordered as the upstream sheet lists them: blue, orange, yellow.
UPSTREAM_HUES = ("1d4aff", "f54e00", "f9bd2b")
# The hue in any wrapper it is written in here: `#1d4aff`, `%231D4AFF` inside a
# data URI, `bg-[#1d4aff]`, and the 8-digit `#1d4aff80` that carries an alpha.
# Bounded by non-hex on both sides, so a checksum that happens to contain the
# run is not a false failure -- lockfiles are full of 64-char hex.
#
# `%23` is the exception that has to be spelled out: it is an encoded `#`, and
# it ENDS in a hex digit, so the plain bound rejects the one spelling the logo
# actually shipped in -- percent-encoded inside a data URI, which is how it got
# into frontend/src/index.html and into the Stripe app's promo banner.
HUE_RE = re.compile(
r"(?:(?<=%23)|(?<![0-9a-f]))("
+ "|".join(UPSTREAM_HUES)
+ r")([0-9a-f]{2})?(?![0-9a-f])",
re.IGNORECASE,
)
# The same three colours written in decimal, which a hex grep cannot see. Not
# hypothetical: the QA evidence annotator held `(245, 78, 0)` and `(29, 74,
# 255)` as PIL tuples, and read as clean until someone converted them.
def channels(h: str) -> tuple[int, int, int]:
return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore[return-value]
HUE_RGB_RE = re.compile(
"|".join(r"\b%d\s*,\s*%d\s*,\s*%d\b" % channels(h) for h in UPSTREAM_HUES)
)
# git grep speaks POSIX ERE -- no \b, no \s -- so the narrowing pattern is
# spelled separately from the Python ones above and derived from the same
# numbers, rather than kept in step by hand.
GREP_PATTERN = "|".join(
UPSTREAM_HUES
+ tuple(
"%d,[[:space:]]*%d,[[:space:]]*%d" % channels(h) for h in UPSTREAM_HUES
)
)
# Segments of the upstream artwork's path data -- the wordmark and the mascot.
# Coordinates identify a drawing the way its fills do not: recolouring the
# wordmark to ink leaves it the upstream wordmark, and a hue check alone would
# call that fixed. Two of the five surfaces LLM.md records drew the mark with
# `var(--ph-brand-*)` and no literal hex at all, so on those the path data is
# the only tell there is.
MARK_SIGNATURES = (
"M55.383 75.225",
"m216.24 107.388",
"5.77226 8.02931",
"M303.32 114.86",
"M29.375 11.6667",
)
def check_marks(root: str) -> int:
"""Refuse the upstream brand's palette and the geometry of its marks.
Two stages on purpose. `git grep` narrows 35k tracked files to a handful
using the index; the regexes then say precisely which token matched and
where, because a guard that only says "this file is dirty" makes the next
person grep for the answer themselves.
"""
def candidates(*args: str) -> list[str]:
out = subprocess.run(
["git", "grep", "-ilI", *args], cwd=root, capture_output=True, text=True
).stdout.split()
return [p for p in out if p not in CONTENT_ALLOWLIST]
hits: list[tuple[str, int, str]] = []
for rel in sorted(set(candidates("-E", GREP_PATTERN))):
with open(os.path.join(root, rel), encoding="utf-8", errors="replace") as fh:
for n, line in enumerate(fh, 1):
m = HUE_RE.search(line) or HUE_RGB_RE.search(line)
if m:
hits.append((rel, n, m.group(0)))
marks: list[tuple[str, int, str]] = []
sig_args = [a for s in MARK_SIGNATURES for a in ("-e", s)]
for rel in sorted(set(candidates("-F", *sig_args))):
with open(os.path.join(root, rel), encoding="utf-8", errors="replace") as fh:
for n, line in enumerate(fh, 1):
for s in MARK_SIGNATURES:
if s.lower() in line.lower():
marks.append((rel, n, s))
if hits:
print(f"FAIL: {len(hits)} lines carry an upstream brand hue")
print(" The mark is monochrome. Collapse decoration to ink -- prefer")
print(" `currentColor` so it tracks the theme -- and leave someone")
print(" else's product mark alone by naming it in CONTENT_ALLOWLIST.")
for rel, n, tok in hits[:20]:
print(f" {rel}:{n}: {tok}")
if marks:
print(f"FAIL: {len(marks)} lines carry the upstream artwork's path data")
print(" Recolouring it does not debrand it. Replace the artwork with")
print(" the mark in frontend/public/icons/safari-pinned-tab.svg.")
for rel, n, sig in marks[:20]:
print(f" {rel}:{n}: {sig}")
return 1 if (hits or marks) else 0
# --- retired spellings ---------------------------------------------------
#
# The rules above rename on the way IN, from a fresh upstream import. Nothing
# stopped a retired name walking back into the tree afterwards -- by hand, in a
# cherry-pick, in generated output -- and `--check` was green throughout,
# because it only ever looked for the company's name and these are the mascot's.
#
# Each entry is a spelling this fork does not use and the name it goes by
# instead. They are refused rather than rewritten because a rewrite happens once
# and a refusal happens on every commit.
#
# Deliberately NOT the whole of `hog`. What is left is what a rename cannot
# reach: nine rows of `django_migrations` whose `name` column carries the old
# spelling (`reviewhog_`, `stamphog`, `hogfunction`, `hogflow`, `hedgehog`,
# `posthog_`) -- a migration's filename is its primary key, and renaming the
# file orphans the row that says it ran; `hogland` is someone else's service;
# `@hanzo/brand/hoggies` is a subpath of a package we do not publish; and
# PostHog in LLM.md is the upstream's name, which is attribution, not branding.
#
# `tophog` and `hog_invocation_results` used to be listed here as physical
# names. Both are renamed -- the tables were empty, which is the only thing
# that ever made them hard -- and both are refused below.
#
# Two are outstanding rather than refused, and for opposite reasons.
# `hogdesk` survives only inside `choices=` lists in already-applied tasks
# migrations: the member is `Desk = "desk"` and has been since the rename, and
# all three tables that store it were measured at zero rows, so those lists
# are a snapshot of a field that no longer looks like that. Rewriting an
# applied migration to change a string Django compares in Python and never
# writes to disk buys nothing. `sherlockhog` is a key in the llm-gateway's
# PRODUCTS map, and the thing it keys is a service in another repository that
# still calls itself that; renaming our half would classify its traffic as a
# product that does not exist. Neither is exempt -- both are named here.
# Named once because two rules need the same object: the refusal below, and the
# one exemption to it in check_hog.
RENAMED_RELATIONS = re.compile(r"insights_hogfunctiontemplate|insights_hogfunction|insights_hogflow")
# A migration records the schema as it WAS; every other file has to name it as
# it IS. `0004` renamed these two tables, `0005` renamed the constraints
# Postgres had auto-named after them, and workflows `0007`/`0016`/`0017` name
# the flow table in the DDL they ran -- a body that cannot spell
# `insights_hogfunction` cannot say what it did. An applied migration is not
# rewritable anyway: Django replays it to rebuild model state, so editing one
# desyncs the state it reconstructs from the ledger that says it ran.
#
# This exempts the relation rule inside migrations and nowhere else, which is
# the whole point of it. The bug it was written for was the nodejs function,
# template and flow managers selecting from relations the database had already
# dropped -- runtime code, where the refusal still bites.
RECORDED_BY_MIGRATIONS = frozenset({RENAMED_RELATIONS})
RETIRED: tuple[tuple[re.Pattern[str], str], ...] = (
(re.compile(r"hog_invocation_results"), "invocations -- the Datastore table family"),
(re.compile(r"[Tt]op[Hh]og"), "topfn -- the heavy-hitter store, and TopFn in code"),
(re.compile(r"PersonHog|Personhog|person_hog"), "Person -- the types in the personinsights package"),
(re.compile(r"StreamHog|streamhog|STREAMHOG"), "Hedgebox -- this fork has one fictional company"),
(re.compile(r"REVIEW_HOG"), "REVIEW -- the enum member; its value was already `review`"),
(re.compile(r"HOGLI\\?_|\bHOGLI\b|\bHogli\b"), "INSIGHTSCLI (the program's own name, and INSIGHTSCLI_ the env prefix)"),
(re.compile(r"\bhoge\b|\bHoge\b"), "scriptc -- the compiler, and .scriptc bytecode"),
(re.compile(r"\bhog\b|\bHog\b"), "script -- the language, and .script source"),
(re.compile(r"[Hh]og[Qq][Ll]|HOGQL"), "InsightsQL"),
(re.compile(r"[Hh]og[Vv][Mm]|HOGVM"), "ScriptVM"),
(re.compile(r"(?<![A-Za-z0-9_])hogfunction"), "insightsfunction"),
# The three relations the CDP data plane used to read. The rule above cannot
# reach them: its lookbehind excludes a leading `_`, and `\bhog\b` cannot
# either, because `_` and `f` are both word characters -- so `hog` never
# starts a word here. That gap is not academic. The tables were renamed and
# the compatibility views dropped while the nodejs function, template and
# flow managers still selected from the old names, and `to_regclass` had
# been answering NULL for all three; the templates table holds 79 rows the
# manager could no longer read. It was found by hand and fixed by 46 edits,
# which is the outcome this file exists to prevent -- an edit happens once,
# a refusal happens on every commit.
(
RENAMED_RELATIONS,
"insights_function_template / insights_function / insights_flow -- the relations, renamed",
),
# The extensions a previous pass invented for the script language, which
# named it after the SQL dialect instead. Refused so the language cannot go
# back to having two names.
(re.compile(r"\.iqle?(?![A-Za-z0-9])"), ".script and .scriptc"),
# The runtime the compiler emits and both VMs define, by name, which makes
# the three of them one contract. A name coming back in one engine is not a
# typo, it is the compiler emitting a call nothing defines -- and the last
# time these disagreed the suite could not say so, because its snapshots
# were stale in the same direction. Unbranded because inside the language's
# own runtime every value is a Script value, so the infix distinguished
# nothing.
(
re.compile(r"__(?:is|print|to|new)Hog[A-Za-z]*|__hog[A-Za-z]*__"),
"__printValue / __isDate / __newDate -- the emitted runtime, unbranded",
),
# The STL constructor for an error of an arbitrary type, which is why it
# cannot simply be `Error`: that one exists and fixes the type. Two test
# programs went on calling the old name for two renames after the STL
# stopped defining it.
(re.compile(r"\bHogError\b"), "ScriptError -- the STL error constructor"),
# The AI agent package. `ee/` is empty, so every one of these is a path
# that resolves to nothing -- which is how nineteen semgrep excludes, a
# dagster path filter and a whole eval workflow went on naming it.
(
re.compile(r"\bhogai\b|\bee[./]hogai"),
"products.insights_ai -- the module, and products/insights_ai the path",
),
# The enum member, in the casing the whole-word rules above cannot see:
# they are `hog` and `Hog`, and this is neither.
(re.compile(r"\bHOG\b"), "SCRIPT -- the member; its value was already `script`"),
# The credential marks. `phc_ phx_ phs_ pha_ phr_ phvt_` all opened with the
# upstream's initials, and they were the one piece of its brand a customer
# read back to us in every SDK snippet -- unreachable by any word rule,
# because `ph` is not a word. A key now opens with what it may touch: `pk-`
# if it is safe in a browser, `sk-` if it is not. Refused as a shape rather
# than as six strings, so a seventh cannot be coined.
#
# Declined, and why each is not a credential: `php_` and `phf_` open a
# third-party package name (PHP events, the `phf` crate), and `pha_client`
# is the analytics client handle.
(
re.compile(r"(?<![A-Za-z0-9_])ph(?!p_)(?!f_)(?!a_client)[a-z]{1,2}_[A-Za-z0-9]"),
"a pk-/sk- mark, minted by insights.models.utils.mint "
"(at-/rt-/vt- carry the protocol tokens)",
),
)
# Coarse enough for POSIX ERE, which has no word boundary. `git grep` narrows
# 35k tracked files to the few hundred worth opening; RETIRED then says which
# token matched and where, because a guard that only says "this file is dirty"
# makes the next person grep for the answer themselves.
RETIRED_GREP = "hog|HOG|Hog|[.]iql|ph[a-z][a-z]?_"
def is_manifest(p: str) -> bool:
b = os.path.basename(p)
return b in MANIFESTS or b in SKIP_CONTENT_NAMES
# Output, not source. A retired name in one of these says the program that
# writes it has not been re-run since the rename -- which is a finding, and a
# useful one, but never an edit to make by hand: a snapshot edited to agree
# with a rename is exactly how a half-rename passes its own tests. Reported
# rather than allowlisted, for the same reason manifests are: an exemption
# would hide it, and these come clean by regenerating.
GENERATED_NAMES = {".test_durations", "frontend/snapshots.yml"}
GENERATED_MARKS = ("__snapshots__/", ".ambr", ".snap")
def is_generated(p: str) -> bool:
return p in GENERATED_NAMES or any(m in p for m in GENERATED_MARKS)
# A captured diff and the review comments captured beside it are evidence of
# what another tree looked like on the day it was reviewed. They are inputs to
# an eval that scores a reviewer against a known outcome, so rewriting one does
# not rename anything -- it falsifies the capture and moves the answer the eval
# grades against. Skipped outright rather than reported, on the same footing as
# a licence: not ours to rewrite, and not outstanding work.
CAPTURE_RE = re.compile(r"/eval/experiments/[^/]+/fixtures/")
def is_capture(p: str) -> bool:
return bool(CAPTURE_RE.search(p))
def partition(hits: list[str]) -> tuple[list[str], list[str], list[str]]:
"""Split files carrying a foreign name into attribution, pending and leaked.
Manifests and lockfiles are reported, never allowlisted. They name packages
that have to resolve, so each one is either a repo we host under our own
name or a dependency we still take from upstream -- a decision per entry,
and outstanding work until someone makes it. Calling that an exemption would
hide it.
"""
exempt = set(CONTENT_ALLOWLIST)
rest = [h for h in hits if h not in exempt and not is_licence(h)]
return (
sorted(h for h in hits if is_licence(h)),
sorted(h for h in rest if is_manifest(h)),
[h for h in rest if not is_manifest(h)],
)
# --- the storage engine --------------------------------------------------
#
# Our fork of the engine is Datastore, and the running pod agrees: the image is
# ghcr.io/hanzoai/datastore, the config is /etc/hanzo-datastore-server, the
# Python client is datastore_orm, the management command is migrate_datastore
# and the settings are DATASTORE_*. There is no clickhouse-client binary in it,
# because ours is named ours.
#
# So a residual upstream spelling in our own code is not a naming preference; it
# is the one place a reader learns the engine has two names. Refused here, with
# exactly one way to be legitimate: PRESERVE_IDENT already lists the spellings
# another program resolves, and a line whose engine name survives being held is
# a line naming something we do not own. Reading that same list rather than a
# second one is what stops the rewrite and the refusal from disagreeing.
ENGINE = re.compile(r"clickhouse", re.IGNORECASE)
def check_engine(root: str) -> int:
"""Refuse the upstream engine's name in first-party code."""
text = set(
subprocess.run(
["git", "grep", "-lI", "-i", "clickhouse"], cwd=root, capture_output=True, text=True
).stdout.split()
)
leaked: list[tuple[str, int, str]] = []
pending: set[str] = set()
for rel in sorted(text - set(CONTENT_ALLOWLIST)):
if is_licence(rel):
continue
with open(os.path.join(root, rel), encoding="utf-8", errors="replace") as fh:
for n, line in enumerate(fh, 1):
# Hold the identities, then look at what is left. A spelling that
# survives is one no other program reads back.
if not ENGINE.search(_hold(line, PRESERVE_IDENT)[0]):
continue
if is_manifest(rel):
pending.add(rel)
break
leaked.append((rel, n, line.strip()[:100]))
# A compiled artefact carries the name in its bytes, where no rule reaches.
# `git grep -I` skips it for being binary, so it would leave the guard green
# by being unreadable. Listed as owed rather than exempt: only a rebuild
# changes it, and an allowlist entry would file that under "decided".
binary = [
p
for p in subprocess.run(
["git", "grep", "-l", "-i", "clickhouse"], cwd=root, capture_output=True, text=True
).stdout.split()
if p not in text
]
if pending:
print(f"PENDING: {len(pending)} dependency manifests still name the upstream engine.")
print(" The specifier is the finding; check_upstream_deps carries the decision.")
for p in sorted(pending):
print(f" {p}")
if binary:
print(f"OWED: {len(binary)} compiled artefacts carry the engine's name in their bytes")
print(" No rule reaches a binary. Rebuilding is the only edit that changes them.")
for p in sorted(binary):
print(f" {p}")
if leaked:
print(f"FAIL: {len(leaked)} lines name the upstream engine where we own the name")
print(" We say datastore. If the name is one another program reads back --")
print(" a word in its DDL grammar, a symbol a package exports, an option key")
print(" on the wire -- hold it in PRESERVE_IDENT and say which program reads it.")
for rel, n, line in leaked[:20]:
print(f" {rel}:{n}: {line}")
return 1 if leaked else 0
def check_hog(root: str) -> int:
"""Refuse the spellings this fork has retired, in content and in paths."""
# Classify on what RETIRED actually matches, not on what the coarse grep
# let through: `sha512-...KBBHOGSV...` is a lockfile integrity hash, and
# reporting a manifest because a base64 digest happens to contain the
# letters is how a report stops being read.
coarse = subprocess.run(
["git", "grep", "-lIE", RETIRED_GREP], cwd=root, capture_output=True, text=True
).stdout.split()
found: dict[str, list[tuple[int, str, str]]] = {}
for rel in sorted(set(coarse) - set(CONTENT_ALLOWLIST)):
if is_licence(rel) or is_capture(rel):
continue
recorded = RECORDED_BY_MIGRATIONS if is_migration(rel) else frozenset()
with open(os.path.join(root, rel), encoding="utf-8", errors="replace") as fh:
for n, line in enumerate(fh, 1):
for pat, instead in RETIRED:
if pat in recorded:
continue
m = pat.search(line)
if m:
found.setdefault(rel, []).append((n, m.group(0), instead))
stale = sorted(r for r in found if is_generated(r))
pending = sorted(r for r in found if is_manifest(r) and not is_generated(r))
leaked = [
(rel, n, tok, instead)
for rel, lines in sorted(found.items())
if not is_manifest(rel) and not is_generated(rel)
for n, tok, instead in lines
]
# A migration's filename is its identity in `django_migrations`, so it is
# frozen for the same reason MIGRATION_RE freezes it against renaming.
paths = [
p
for p in tracked_files(root)
if not is_migration(p) and any(pat.search(p) for pat, _ in RETIRED)
]
if stale:
print(f"STALE: {len(stale)} generated files still spell a retired name.")
print(" Their sources do not. Regenerate them; never edit one to agree.")
for p in stale:
print(f" {p}: {len(found[p])} lines")
if pending:
print(f"PENDING: {len(pending)} dependency manifests still name a mascot package.")
for p in pending:
print(f" {p}")
if leaked:
print(f"FAIL: {len(leaked)} lines spell a name this fork retired")
print(" The rename runs through bin/debrand, so the fix is a rule, not an edit.")
for rel, n, tok, instead in leaked[:20]:
print(f" {rel}:{n}: {tok!r} -- say {instead}")
if paths:
print(f"FAIL: {len(paths)} tracked paths spell a name this fork retired")
for p in paths[:20]:
print(f" {p}")
return 1 if (leaked or paths) else 0
# --- upstream dependency specifiers --------------------------------------
#
# Everything above reads source. This reads *specifiers*, because a manifest
# entry is the one place a rename cannot reach:
#
# insights-rs = { package = "posthog-rs", version = "0.21.0" }
#
# reads as ours in our source and installs theirs on the machine. The alias
# renames the symbol; the registry still serves the upstream crate, and the
# upstream name is what lands in the lockfile, the vendored path and the stack
# trace. `--check` was green over that line for as long as it has existed.
#
# So each entry below is an upstream package we still take, keyed by the token
# that gives it away, and carrying the decision that retires it. A specifier
# that matches and is NOT listed fails: taking a new dependency from upstream
# is a choice someone should have to write down, not one that lands quietly.
OUTSTANDING: dict[str, str] = {
"pixelhog": "fork: 2,720 lines of Rust, a port of mapbox/pixelmatch plus SSIM and "
"clustering, proven against upstream's canonical fixtures. Reimplementing re-derives "
"someone else's port; the wheel build is the real cost either way",
"posthog-rs": "fork at 0.21.0: one consumer (cli/), and the alias hides a User-Agent, a "
"$lib property and a default ingestion host that all still say posthog",
"posthog-symbol-data": "fork: 386 LOC, but it fixes a container MAGIC that a reader in "
"this tree already disagrees with",
"pytest-split": "fork or drop: a git source that fetches from the upstream org, under a "
"comment that calls it ours",
"@posthog/hogql-parser": "publish ours: the source is already in this tree "
"(common/insightsql_parser, renamed grammar and all) and already carries our name and "
"version. Only the WebAssembly artefact is still upstream's, and building it needs emscripten",
"@posthog/mcp": "repoint: services/mcp already asks for a Hanzo package, and this override "
"is the only thing dragging it back upstream",
"@posthog/siphash": "fork: BSD-2-Clause, 57 kB, its own upstream repository -- small enough "
"that it is only waiting on somewhere to put it",
"posthog-node": "repoint: @hanzo/insights-node is published on the same major line, so this "
"is a version pin away, once a server test has run against it",
# The engine's client libraries. Ordered before the bare `clickhouse` key
# below, because a specifier is attributed to the FIRST entry whose token it
# contains and every one of these contains that word -- listed generic-first,
# all three would report as the crate and the other two would read as retired.
"@clickhouse/client": "fork under our org, as the Python client already is: pyproject takes "
"datastore-connect from a repository we host, and this is the same client one language over, "
"still taken from the registry under an @datastore alias that points at their scope",
"clickhouse-types": "fork with the crate below -- it is the one that reaches the shipped "
"artefact. The name is compiled into insights/user_scripts/aggregate_funnel_*, so this entry "
"and the OWED binaries in check_engine retire together or not at all",
"clickhouse": "fork and publish ours: a bare `datastore` rename is already known not to work "
"(a different crates.io package answers to it, which failed every cargo invocation in the "
"workspace), and a crate we publish under our own name is what makes the alias unnecessary "
"rather than load-bearing",
}
# `posthog` outright, or the mascot as a suffix -- the shape every one of these
# packages takes (`statshog`, `pixelhog`, `drf-exceptions-hog`). Deliberately
# not bare `hog`: `hogland` is someone else's service, named as outstanding
# elsewhere in this file rather than refused here.
#
# The storage engine is here for the reason the comment above this ledger gives:
# an alias renames the symbol, and the registry still serves the upstream
# artifact. `datastore = { package = "clickhouse" }` reads as ours in every file
# that uses it, and the crate that lands in the lockfile, the vendored path and
# the stack trace is theirs.
UPSTREAM_NAME = re.compile(r"posthog|hog(?![A-Za-z])|clickhouse", re.IGNORECASE)
# The tables whose entries resolve to something a package manager downloads.
PY_DEP_TABLES = ("dependencies", "optional-dependencies", "dependency-groups")
CARGO_DEP_TABLES = ("dependencies", "dev-dependencies", "build-dependencies")
def _dep_strings(root: str, rel: str) -> list[tuple[str, str, str]]:
"""Every (dependency, what-it-resolves-to, where-it-was-written) in a manifest.
Parsed rather than grepped: the name that resolves is the alias *target*,
the git URL or the tag, and only a parser tells those apart from a comment
that happens to sit on the same line. The dependency's own key travels
alongside, so a finding is attributed to the dependency rather than to
whichever of its fields happened to spell the brand.
"""
try:
with open(os.path.join(root, rel), "rb") as fh:
doc = tomllib.load(fh)
except (OSError, tomllib.TOMLDecodeError):
return []
out: list[tuple[str, str, str]] = []
def requirement(spec: str, where: str) -> None:
# "drf-exceptions-hog==0.4.0" -> "drf-exceptions-hog"
name = re.split(r"[\[<>=!~; ]", spec, maxsplit=1)[0]
out.append((name, name, where))
def table(dep: object, key: str, where: str) -> None:
out.append((key, key, where))
if isinstance(dep, dict):
for field in ("package", "git", "url", "tag", "rev", "branch", "path"):
if isinstance(dep.get(field), str):
out.append((key, dep[field], f"{where}.{key}.{field}"))
if os.path.basename(rel) == "pyproject.toml":
project = doc.get("project", {})
for spec in project.get("dependencies", []):
requirement(spec, "project.dependencies")
for group, specs in project.get("optional-dependencies", {}).items():
for spec in specs:
requirement(spec, f"project.optional-dependencies.{group}")
for group, specs in doc.get("dependency-groups", {}).items():
for spec in specs:
if isinstance(spec, str):
requirement(spec, f"dependency-groups.{group}")
for key, dep in doc.get("tool", {}).get("uv", {}).get("sources", {}).items():
table(dep, key, "tool.uv.sources")
else: # Cargo.toml
for name in CARGO_DEP_TABLES:
for key, dep in doc.get(name, {}).items():
table(dep, key, name)
for name in CARGO_DEP_TABLES:
for key, dep in doc.get("workspace", {}).get(name, {}).items():
table(dep, key, f"workspace.{name}")
return out
# npm spells the same aliasing two ways, and both end in a string starting
# `npm:` -- a dependency whose value is `npm:@posthog/[email protected]`, or a
# workspace override that rewrites one for the whole tree. The prefix is what
# makes the target unambiguous, so it is what we read.
NPM_ALIAS = re.compile(r"""['"]?(?P<key>@?[\w.-]+(?:/[\w.-]+)?)['"]?\s*:\s*['"]?npm:(?P<target>@?[\w.-]+(?:/[\w.-]+)?)@""")
def _npm_dep_strings(root: str, rel: str) -> list[tuple[str, str, str]]:
"""The same reading for package.json and pnpm-workspace.yaml.
A dependency resolves to its own key unless an `npm:` alias redirects it, so
both are yielded and the alias target is what gets matched against upstream.
"""
path = os.path.join(root, rel)
out: list[tuple[str, str, str]] = []
if os.path.basename(rel) == "package.json":
try:
doc = json.load(open(path, encoding="utf-8"))
except (OSError, ValueError):
return []
for section in DEP_SECTIONS:
for key, spec in (doc.get(section) or {}).items():
# `workspace:*` resolves inside this tree, so no registry serves
# it and no upstream name is involved -- which is the whole
# subject here. Our own `@hanzo/products-stamp` is the case
# that proves it: the app keeps a name it cannot be renamed out
# of, and it is ours.
if isinstance(spec, str) and spec.startswith("workspace:"):
continue
out.append((key, key, section))
if isinstance(spec, str) and spec.startswith("npm:"):
out.append((key, spec[len("npm:") :].rsplit("@", 1)[0], f"{section}.{key}"))
return out
# pnpm-workspace.yaml: only the alias lines resolve to a package, and the
# `npm:` prefix is what tells them from every other key in the file.
try:
text = open(path, encoding="utf-8").read()
except OSError:
return []
for m in NPM_ALIAS.finditer(text):
out.append((m.group("key"), m.group("target"), "overrides"))
return out
def check_upstream_deps(root: str) -> int:
"""Refuse a dependency specifier that resolves to upstream and is unrecorded."""
unrecorded: list[tuple[str, str, str]] = []
seen: set[str] = set()
for rel in tracked_files(root):
base = os.path.basename(rel)
if base in ("package.json", "pnpm-workspace.yaml"):
strings = _npm_dep_strings(root, rel)
elif base in ("pyproject.toml", "Cargo.toml"):
strings = _dep_strings(root, rel)
else:
continue
for key, value, where in strings:
if not UPSTREAM_NAME.search(value):
continue
entry = next(
(k for k in OUTSTANDING if k.lower() in value.lower() or k.lower() in key.lower()),
None,
)
if entry is None:
unrecorded.append((rel, where, value))
else:
seen.add(entry)
if seen:
print(f"PENDING: {len(seen)} upstream packages are still specified, each with a decision:")
for name in sorted(seen):
print(f" {name} -- {OUTSTANDING[name]}")
if retired := sorted(set(OUTSTANDING) - seen):
# A ledger that outlives the dependency starts granting permission for a
# name nothing takes any more, so removing the entry is part of the work.
print(f"FAIL: {len(retired)} ledger entries name a dependency no manifest takes")
for name in retired:
print(f" {name} -- retired; drop it from OUTSTANDING")
if unrecorded:
print(f"FAIL: {len(unrecorded)} dependency specifiers resolve to upstream and are unrecorded")
print(" A rename in our manifest does not change what the registry serves.")
for rel, where, value in unrecorded:
print(f" {rel}: {where} -> {value!r}")
return 1 if (unrecorded or retired) else 0
def check_license(root: str) -> int:
"""The licence must still carry the notice MIT obliges us to pass on.
A debranding pass that reaches the licence produces a file that reads as
valid while claiming our copyright over upstream's code, so the invariant
is asserted positively rather than left to the absence of a rule.
"""
path = os.path.join(root, LICENSE_PATH)
if not os.path.exists(path):
print(f"FAIL: {LICENSE_PATH} is missing")
return 1
text = open(path, encoding="utf-8").read()
if REQUIRED_NOTICE not in text:
print(
f"FAIL: {LICENSE_PATH} no longer carries the upstream copyright notice "
f"({REQUIRED_NOTICE!r}). MIT requires it to travel with the code."
)
return 1
return 0
# --- module references --------------------------------------------------
#
# A path rule renames a file; nothing renames the code that imports it. Where a
# content rule happens to match too the two stay in step, and where it does not
# the import silently points at a module that is no longer there. That is not a
# hypothetical: `hog_flow` is in PATH_RULES and in no content rule, so the whole
# workflows product moved and every `from products.workflows.backend.api.hog_flow`
# stayed put -- an ImportError at Django startup.
#
# The obvious repair, adding the missing content rule, is wrong here. `hog_flow`
# is also a physical column (`hog_flow_id`), a table (`insights_hogflow`), a
# search-result type in the API response, an access-control scope and a URL
# segment the nodejs CDP service calls. Rewriting the token everywhere would
# rename live database and wire identifiers to match a file move.
#
# So the reference is repaired in its own right, and derived from the tree: take
# the module a reference names, put it through the same path rules the file went
# through, and if THAT resolves, the file moved and the reference is stale.
# `from .hog_flow import X` is the same staleness wearing a relative spelling,
# and it is the spelling a package's own __init__ uses -- so the leading dots
# are part of the match, resolved against the package the file sits in.
MODULE_REF_RE = re.compile(
r"(?P<pre>(?:^|\n)[ \t]*(?P<kw>from|import)[ \t]+)(?P<dots>\.*)(?P<mod>[A-Za-z_][\w.]*)"
)
# The tree has two module systems and only one of them was ever checked, so the
# same staleness sat in TypeScript unseen: 190 specifiers named a file that had
# been renamed out from under them -- the whole CDP consumer graph, which is the
# event worker. Python was checked because `--check` grew up around a Django
# boot; the worker fails the same way and says so in a different syntax.
TS_REF_RE = re.compile(
r"""(?P<pre>(?:from|import|require)\s*\(?\s*(?P<q>['"]))(?P<spec>[^'"\n]+)(?P=q)"""
)
TS_EXT = (".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".d.ts", ".json", ".node")
def ts_file(base: str, tracked: set[str], root: str) -> bool:
"""Whether a TypeScript specifier, already made repo-relative, names a file."""
return (
base in tracked
or any(base + e in tracked for e in TS_EXT)
or any(f"{base}/index{e}" in tracked for e in TS_EXT)
or os.path.isdir(os.path.join(root, base))
)
def ts_path(spec: str, rel: str) -> str | None:
"""A specifier as a repo-relative path, or None if it names a package.
Two spellings resolve to a file rather than to node_modules: the relative
one, and `~/`, which every tsconfig in the tree points at its own package.
The `~/` root is read off the importing file rather than out of a tsconfig,
because `~/tests/x` and `~/cdp/x` resolve against different roots in the
SAME package and both spellings are in use.
"""
if spec.startswith("."):
return os.path.normpath(os.path.join(os.path.dirname(rel), spec))
if spec.startswith("~/"):
pkg = rel.split("/", 1)[0]
tail = spec[2:]
return f"{pkg}/{'' if tail.startswith('tests/') else 'src/'}{tail}"
return None
def ts_ref_target(spec: str, rel: str, tracked: set[str], root: str) -> str | None:
"""The specifier this one should be, or None if it is fine.
Derived from the tree exactly as the Python side is: put the path the
specifier names through the same rules the file went through, and if THAT
resolves while the original does not, the file moved and the reference is
stale. Nothing is guessed -- a specifier that resolves to nothing under both
spellings is left alone and reported, because renaming it would be a guess.
"""
base = ts_path(spec, rel)
if base is None or ts_file(base, tracked, root):
return None
moved = rewrite_path(base)
if moved == base or not ts_file(moved, tracked, root):
return None
if spec.startswith("~/"):
pkg = rel.split("/", 1)[0]
tail = moved[len(pkg) + 1:]
return "~/" + (tail[len("src/"):] if tail.startswith("src/") else tail)
out = os.path.relpath(moved, os.path.dirname(rel))
return out if out.startswith(".") else "./" + out
def package_of(rel: str) -> str:
"""The package a .py file's relative imports are resolved against."""
mod = rel[:-3].replace("/", ".")
return mod.rsplit(".", 1)[0] if not rel.endswith("__init__.py") else mod[: -len(".__init__")]
def first_party(tracked: set[str]) -> set[str]:
return {p.split("/")[0] for p in tracked if p.endswith(".py") and "/" in p}
def module_resolves(mod: str, tracked: set[str], root: str) -> bool:
base = mod.replace(".", "/")
return (
f"{base}.py" in tracked
or f"{base}/__init__.py" in tracked
or os.path.isdir(os.path.join(root, base))
)
def module_target(mod: str, tracked: set[str], root: str) -> str | None:
"""Where a stale module reference should point, or None if it is fine.
Two ways, tried in order and both derived rather than declared: the path
rules (the file was renamed in place), then a unique basename match (the
file was moved to another package, which no rename rule can express).
"""
if module_resolves(mod, tracked, root):
return None
moved = rewrite_path(mod.replace(".", "/")).replace("/", ".")
if moved != mod and module_resolves(moved, tracked, root):
return moved
leaf = mod.rsplit(".", 1)[-1]
hits = {
p[:-3].replace("/", ".")
for p in tracked
if p.endswith(f"/{leaf}.py")
} | {
p[: -len("/__init__.py")].replace("/", ".")
for p in tracked
if p.endswith(f"/{leaf}/__init__.py")
}
# Only when it is unambiguous. Two candidates means a human has to choose,
# and guessing would wire the app to the wrong module without saying so.
return hits.pop() if len(hits) == 1 else None
def ref_target(mm: re.Match[str], rel: str, tracked: set[str], fp: set[str],
root: str) -> str | None:
"""The replacement text for one import's module part, or None to leave it.
Relative references keep their dots: only the module part after them moves,
and only when the file is still reachable from the same package -- a move to
a different package cannot be spelled relatively and is left for --check to
report rather than silently rewritten into the wrong shape.
"""
dots, mod = mm.group("dots"), mm.group("mod")
if dots and mm.group("kw") != "from":
return None
if not dots:
if mod.split(".")[0] not in fp:
return None
return module_target(mod, tracked, root)
base = package_of(rel)
for _ in range(len(dots) - 1):
base = base.rsplit(".", 1)[0] if "." in base else base
absolute = f"{base}.{mod}"
target = module_target(absolute, tracked, root)
if not target:
return None
prefix = f"{base}."
return target[len(prefix):] if target.startswith(prefix) else None
TS_SOURCE = (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx")
def repoint(rel: str, src: str, tracked: set[str], fp: set[str], root: str) -> tuple[str, dict[str, str]]:
"""One file's source with its stale references repaired, and what moved.
Both module systems go through here, because "a reference names a file that
is not there" is one defect wearing two syntaxes, and splitting it in two is
how TypeScript ended up unchecked.
"""
moved: dict[str, str] = {}
if rel.endswith(".py"):
def sub(mm: re.Match[str]) -> str:
target = ref_target(mm, rel, tracked, fp, root)
if not target:
return mm.group(0)
moved[mm.group("dots") + mm.group("mod")] = mm.group("dots") + target
return mm.group("pre") + mm.group("dots") + target
return MODULE_REF_RE.sub(sub, src), moved
def sub_ts(mm: re.Match[str]) -> str:
target = ts_ref_target(mm.group("spec"), rel, tracked, root)
if not target:
return mm.group(0)
moved[mm.group("spec")] = target
return mm.group("pre") + target + mm.group("q")
return TS_REF_RE.sub(sub_ts, src), moved
def referencing_files(tracked: set[str]) -> list[str]:
return sorted(p for p in tracked if p.endswith(".py") or p.endswith(TS_SOURCE))
def stale_module_refs(root: str) -> dict[str, list[str]]:
"""Stale first-party module references, mapped to the files that carry them."""
tracked = set(tracked_files(root))
fp = first_party(tracked)
out: dict[str, list[str]] = {}
for rel in referencing_files(tracked):
try:
src = open(os.path.join(root, rel), encoding="utf-8", errors="ignore").read()
except OSError:
continue
for ref in repoint(rel, src, tracked, fp, root)[1]:
out.setdefault(ref, []).append(rel)
return out
def rewrite_module_refs(root: str, dry: bool) -> int:
tracked = set(tracked_files(root))
fp = first_party(tracked)
fixed = 0
for rel in referencing_files(tracked):
path = os.path.join(root, rel)
try:
src = open(path, encoding="utf-8", errors="ignore").read()
except OSError:
continue
new, moved = repoint(rel, src, tracked, fp, root)
if new != src:
for old, target in sorted(moved.items()):
print(f"{'would point' if dry else 'pointed'} {rel}: {old} -> {target}")
fixed += len(moved)
if not dry:
open(path, "w", encoding="utf-8").write(new)
return fixed
def check_modules(root: str) -> int:
stale = stale_module_refs(root)
if not stale:
return 0
sites = sum(len(v) for v in stale.values())
print(f"FAIL: {len(stale)} module references name a module that is not on disk ({sites} sites)")
for mod, files in sorted(stale.items(), key=lambda kv: -len(kv[1]))[:15]:
print(f" {mod} ({len(files)} sites, e.g. {files[0]})")
return 1
# A migration's name is its identity in django_migrations, which is why
# MIGRATION_RE freezes the FILENAME. The string that NAMES that migration from
# another one's `dependencies` is not a filename, so nothing froze it, and a
# content rule rewrote one: `0026` came to depend on a `0025` spelled a way no
# file is. Django answers that while BUILDING the graph, so it is not a
# migrate-time failure -- every management command and every test that loads
# the graph stops with NodeNotFoundError.
#
# The same shape as check_modules one level over, and refused for the same
# reason: a reference that names something not on disk.
def migration_deps(root: str) -> list[tuple[str, str, str]]:
"""Every (file, app_label, migration) dependency this tree declares."""
out: list[tuple[str, str, str]] = []
for rel in tracked_files(root):
if not is_migration(rel):
continue
try:
tree = ast.parse(open(os.path.join(root, rel), encoding="utf-8").read())
except (OSError, SyntaxError):
continue
for node in ast.walk(tree):
if not (isinstance(node, ast.Tuple) and len(node.elts) == 2):
continue
if not all(isinstance(e, ast.Constant) and isinstance(e.value, str) for e in node.elts):
continue
app, name = node.elts[0].value, node.elts[1].value
if len(name) > 4 and name[:4].isdigit() and "_" in name:
out.append((rel, app, name))
return out
APP_LABEL = re.compile(r'^\s*label\s*=\s*"([^"]+)"', re.M)
def migrations_by_label(root: str) -> dict[str, set[str]]:
"""The migrations each app label actually has on disk.
Keyed by LABEL rather than by directory, because a dependency names an app
the way django_migrations does -- and the two part company exactly when a
rename moves the module and leaves the label, which is the case this guards.
"""
tracked = tracked_files(root)
dirs: dict[str, str] = {"insights": "insights/migrations"}
for rel in tracked:
if not rel.endswith("/apps.py"):
continue
mm = APP_LABEL.search(open(os.path.join(root, rel), encoding="utf-8", errors="ignore").read())
if mm:
dirs[mm.group(1)] = os.path.dirname(rel) + "/migrations"
have: dict[str, set[str]] = {}
for label, d in dirs.items():
have[label] = {
os.path.basename(p)[:-3]
for p in tracked
if p.startswith(d + "/") and os.path.basename(p)[0].isdigit()
}
return have
def check_migrations(root: str) -> int:
have = migrations_by_label(root)
# Only labels this tree owns: a dependency on a third-party app names
# migrations that live in site-packages, and this cannot see those.
missing = [
(rel, app, name)
for rel, app, name in migration_deps(root)
if app in have and name not in have[app]
]
if not missing:
return 0
print(f"FAIL: {len(missing)} migration dependencies name a migration that is not on disk")
print(" A migration's name is its identity, so the rules freeze the FILENAME.")
print(" The string naming it from another migration has to be frozen with it.")
for rel, app, name in missing[:20]:
print(f" {rel}: ({app!r}, {name!r})")
return 1
def check_workspace(root: str) -> int:
"""Every `workspace:` dep must name a package the workspace actually has.
pnpm has no registry fallback for the protocol, so one dangling entry fails
the whole install -- and the install is the first step of the image build,
far from the drop that caused it. Asserting it here names the manifest.
"""
dangling = dangling_workspace_deps(root)
if not dangling:
return 0
print(f"FAIL: {len(dangling)} workspace deps name packages that are not in the workspace")
for rel, section, dep in dangling:
print(f" {rel}: {section}.{dep}")
return 1
def check(root: str) -> int:
bad = path_rules_have_content_counterparts()
bad += brand_survives_adjacent_rules()
bad += check_license(root)
bad += check_workspace(root)
bad += check_modules(root)
bad += check_migrations(root)
bad += check_mangled(root)
bad += check_marks(root)
bad += check_hog(root)
bad += check_engine(root)
bad += check_upstream_deps(root)
paths = [p for p in tracked_files(root) if "posthog" in p.lower()]
if paths:
bad += 1
print(f"FAIL: {len(paths)} tracked paths still name the upstream brand")
for p in paths[:20]:
print(f" {p}")
hits = subprocess.run(
["git", "grep", "-ilI", "posthog"], cwd=root, capture_output=True, text=True
).stdout.split()
attribution, pending, leaked = partition(hits)
if attribution:
print(f"attribution: {len(attribution)} licence files name the upstream, as MIT requires")
bad += licences_unlaundered(root)
bad += licence_notice_survives(root)
if pending:
print(f"PENDING: {len(pending)} dependency manifests still name upstream packages.")
print(" Each needs a decision: take it from upstream, or host it under our name.")
for p in pending:
print(f" {p}")
if leaked:
bad += 1
print(f"FAIL: {len(leaked)} files carry the upstream brand and are not allowlisted")
for p in leaked[:20]:
print(f" {p}")
stale = [p for p in CONTENT_ALLOWLIST if p not in set(tracked_files(root))]
if stale:
bad += 1
print("FAIL: allowlist names files that no longer exist (it must not rot)")
for p in stale:
print(f" {p}")
if not bad:
print(f"ok: no upstream brand in paths; {len(CONTENT_ALLOWLIST)} allowlisted files, each justified")
return 1 if bad else 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--check", action="store_true", help="verify invariants only")
ap.add_argument("--stat", action="store_true", help="report, change nothing")
ap.add_argument("--rename-only", action="store_true", help="skip the prune phase")
ap.add_argument("--root", default=".", help="repo root")
a = ap.parse_args()
root = os.path.abspath(a.root)
if a.check:
return check(root)
if not a.rename_only:
prune(root, dry=a.stat)
edited, renamed = apply(root, dry=a.stat)
# After the renames, because it asks the filesystem where a module ended up.
pointed = rewrite_module_refs(root, dry=a.stat)
verb = "would rewrite" if a.stat else "rewrote"
print(f"{verb} {edited} files, {'would rename' if a.stat else 'renamed'} {renamed} paths, "
f"{'would repoint' if a.stat else 'repointed'} {pointed} module references")
return 0
if __name__ == "__main__":
sys.exit(main())