11 Commits
Author SHA1 Message Date
jcoffey-dev b6660554e6 Merge pull request 'CI: open an issue when upstream publishes a release not yet imported' (#15) from ci/upstream-watch into main
ci / name-check (push) Successful in 17s
ci / build (push) Successful in 7m14s
Reviewed-on: #15
2026-09-22 23:23:02 +00:00
jcoffey-dev 7bda874230 Merge pull request 'CI: fail when the upstream name appears in a new string literal' (#16) from ci/name-check into main
ci / name-check (push) Successful in 1m11s
ci / build (push) Canceled after 3m26s
Reviewed-on: #16
2026-09-22 23:19:37 +00:00
jcoffey-dev a4b091578d CI: fail when the upstream name appears in a new string literal
ci / name-check (pull_request) Successful in 1m15s
ci / build (pull_request) Successful in 5m2s
tools/fork/name-check.py reads every string literal in crates/ (comments
and test directories skipped) and fails on any that carries the upstream
name without an entry in name-allowlist.txt. An upstream merge can bring
such strings in without a conflict, so it runs on every push and PR.

The first run found three the earlier sweeps missed, fixed here: the SMTP
HELP reply pointed at upstream's website (now brand_url!), the event
collector thread was named after upstream, and the FreeBSD default data
path still said /var/db/stalwart/ where Linux already had /var/lib/inbuxa/.

Two operator-visible defaults are allowlisted as open, pending a decision:
the log file prefix and the SQL stores' default database and user.
2026-09-22 16:12:47 -07:00
jcoffey-dev 39df888412 CI: open an issue when upstream publishes a release not yet imported
ci / build (pull_request) Successful in 7m22s
Reads metadata only: upstream's releases list from GitHub's API and the
head of the upstream branch from Gitea's. Nothing of upstream's is
fetched, so its history can't land here. Daily at 06:17 UTC.
2026-09-22 15:37:34 -07:00
jcoffey-dev 697f647f8b Merge pull request 'Release 2026.9.23' (#14) from release/2026.9.23 into main
publish / version (push) Successful in 14s
ci / build (push) Successful in 7m8s
publish / publish (push) Successful in 47m16s
publish / release (push) Successful in 2s
2026-09-22 20:34:46 +00:00
jcoffey-dev 14250cee03 Release 2026.9.23
ci / build (pull_request) Successful in 7m10s
Carries the version string and user-visible string fixes: nothing a user or
operator sees names the upstream project any more.
2026-09-22 13:27:05 -07:00
jcoffey-dev cea3d53eb0 Delete .gitlab-ci.yml
ci / build (push) Successful in 4m18s
2026-09-22 20:26:17 +00:00
jcoffey-dev 335281f1de Merge pull request 'Keep the upstream name out of user-visible strings' (#13) from fix/user-visible-name-strings into main
ci / build (push) Canceled after 45s
2026-09-22 20:25:30 +00:00
jcoffey-dev 7f14992e81 Keep the upstream project's name out of user-visible strings
ci / build (pull_request) Successful in 3m46s
The first-party application descriptions and the telemetry service name and
instrumentation scope are shown to operators, and the unpacked-application
temp directory carried the name too.

Left alone deliberately: the OAuth key-derivation contexts (renaming them
would invalidate every sealed token and client id), the migration defaults
that read an upstream installation, links to upstream's upgrade guide, the
wire-protocol identifiers, and upstream's own license and templates.
2026-09-22 13:21:15 -07:00
jcoffey-dev 1f963a9a1c Merge pull request 'Keep the upstream name out of the version string' (#12) from fix/version-string-name into main
ci / build (push) Successful in 4m7s
2026-09-22 20:07:52 +00:00
jcoffey-dev b353f4ad2a Keep the upstream project's name out of the version string
ci / build (pull_request) Successful in 7m9s
brand_version_full! is user-visible -- --version, the startup banner, the
console, telemetry and the JMAP session's implementation field -- and the
name belongs only in copyright notices and the lineage line.
2026-09-22 13:00:13 -07:00
14 changed files with 334 additions and 62 deletions
+10
View File
@@ -20,6 +20,16 @@ concurrency:
cancel-in-progress: true
jobs:
# The upstream name in a new string literal, typically brought in by an
# upstream merge. Seconds, and needs no toolchain. tools/fork/name-check.py.
name-check:
runs-on: light
container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
- run: python3 tools/fork/name-check.py
build:
# Either runner (host1 or host2): the build needs no docker socket.
runs-on: light
+88
View File
@@ -0,0 +1,88 @@
# Watch upstream for releases the fork hasn't imported yet, and open an issue
# for each one so it waits in the tracker until someone strips it in.
#
# Reads metadata only -- the releases list from GitHub's API and the head of
# this repo's `upstream` branch from Gitea's. Nothing of upstream's is fetched,
# so none of its history (which carries the Enterprise code) can land here.
# Importing is still by hand: tools/fork/strip.py onto `upstream`, then merge,
# as docs/spec/SPEC.md §2.2 and §2.2a describe.
#
# The imported base is the tag in the `upstream` branch's head commit subject
# ("Import upstream v0.16.22, stripped"). Drafts and pre-releases are ignored.
# An issue is opened once per release: an existing one with the same title,
# open or closed, stops a second.
#
# Daily 06:17 UTC; run it by hand with workflow_dispatch.
name: upstream-watch
on:
schedule:
- cron: '17 6 * * *'
workflow_dispatch:
concurrency:
group: upstream-watch
cancel-in-progress: false
jobs:
upstream-watch:
runs-on: light
container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
steps:
- shell: bash
run: |
python3 - <<'PY'
import json, os, re, sys, urllib.request
api = f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['REPO']}"
def call(method, url, body=None, token=os.environ["TOKEN"]):
headers = {"Content-Type": "application/json", "User-Agent": "inbuxa-upstream-watch"}
if token:
headers["Authorization"] = f"token {token}"
req = urllib.request.Request(url, method=method, headers=headers,
data=json.dumps(body).encode() if body is not None else None)
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
SEMVER = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
def key(tag):
return tuple(int(x) for x in SEMVER.match(tag).groups())
subject = call("GET", f"{api}/branches/upstream")["commit"]["message"].splitlines()[0]
m = re.search(r"\bupstream (v\d+\.\d+\.\d+)\b", subject)
if not m:
print(f"Can't read the imported base from the upstream branch: {subject!r}", file=sys.stderr); sys.exit(1)
base = m.group(1)
# Unauthenticated: a public repo, once a day, well inside the limit.
rels = call("GET", "https://api.github.com/repos/stalwartlabs/stalwart/releases?per_page=30", token=None)
newer = sorted((r for r in rels
if not r["draft"] and not r["prerelease"] and SEMVER.match(r["tag_name"])
and key(r["tag_name"]) > key(base)),
key=lambda r: key(r["tag_name"]))
if not newer:
print(f"Up to date: {base} is the newest upstream release."); sys.exit(0)
# Titles and bodies stay free of the upstream project's name, as the
# rest of the fork's user-visible text does.
existing = {i["title"] for i in call("GET", f"{api}/issues?state=all&type=issues&q=Import+upstream&limit=50")}
for r in newer:
tag = r["tag_name"]
title = f"Import upstream {tag}"
if title in existing:
print(f"{tag}: issue already exists."); continue
body = (f"Upstream published {tag} on {r['published_at'][:10]}. "
f"The fork's imported base is {base}.\n\n"
"Import it as tools/fork/README.md describes:\n\n"
"```bash\n"
"git -C \"$UPSTREAM_CLONE\" fetch --tags\n"
f"tools/fork/strip.py --upstream \"$UPSTREAM_CLONE\" --ref {tag} --out /tmp/strip-{tag}\n"
"```\n\n"
"Commit the stripped tree to `upstream` with the strip report in the message, "
"add any new third-party notices to `THIRD-PARTY.md`, then merge `upstream` into `main`.")
issue = call("POST", f"{api}/issues", {"title": title, "body": body})
print(f"{tag}: opened #{issue['number']}.")
PY
-50
View File
@@ -1,50 +0,0 @@
# CI on the self-hosted GitLab, ported from .github/workflows/ci.yml when the
# GitHub account was suspended on 2026-09-20. The Actions file stays in the
# tree: it is the reference this was written from and works unchanged if the
# appeal succeeds.
#
# The image is pinned by digest, with its tag in the trailing comment. That
# replaces the SHA-pinned `uses:` in the workflow -- GitLab has no action
# allowlist, so the digest is the only thing fixing what actually runs.
#
# Not ported here:
# * cleanup.yml pruned GHCR with dataaxiom/ghcr-cleanup-action. GitLab has
# no equivalent action because it does not need one: the container
# registry has a cleanup policy on the project itself, which is where that
# job's settings now live.
# * publish.yml and release.yml still need doing; they are larger and are
# being handled separately.
stages: [build]
default:
interruptible: true
build:
stage: build
image: rust:1-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e # 1-bookworm
# This is a big workspace and a cold build is expensive, so the registry and
# the target directory are cached between runs. Both are kept inside the
# project directory because that is the only path the runner will cache --
# and deliberately not on /tmp, which on this host is a tmpfs that a Rust
# build of this size has filled before.
variables:
CARGO_HOME: "$CI_PROJECT_DIR/.cargo"
CARGO_TARGET_DIR: "$CI_PROJECT_DIR/target"
CARGO_INCREMENTAL: "0"
cache:
key:
files: [Cargo.lock]
paths:
- .cargo/registry/
- target/
before_script:
- apt-get update -qq && apt-get install -y -qq --no-install-recommends clang >/dev/null
script:
- cargo build -p inbuxa --locked
# --no-run: the workflow compiled every test target without running them,
# which catches a test that no longer builds without paying for the suite.
- cargo test --workspace --locked --no-run
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+2 -2
View File
@@ -583,10 +583,10 @@ impl Metrics {
pub async fn parse(bp: &mut Bootstrap) -> Self {
let metrics = bp.setting_infallible::<structs::Metrics>().await;
let resource = Resource::builder()
.with_service_name("stalwart")
.with_service_name("inbuxa")
.with_attribute(KeyValue::new(SERVICE_VERSION, types::brand_version_full!()))
.build();
let instrumentation = InstrumentationScope::builder("stalwart")
let instrumentation = InstrumentationScope::builder("inbuxa")
.with_version(types::brand_version_full!())
.build();
+1 -1
View File
@@ -522,7 +522,7 @@ mod tests {
}
async fn fixture(name: &str, client_id: Option<&str>) -> (WebApplications, TempDir) {
let dir = TempDir::new(std::env::temp_dir().join(format!("stalwart-app-{name}")));
let dir = TempDir::new(std::env::temp_dir().join(format!("inbuxa-app-{name}")));
dir.clean().await.unwrap();
tokio::fs::write(dir.path.join("index.html"), INDEX)
.await
+2 -2
View File
@@ -298,7 +298,7 @@ mod tests {
fn web_interface() -> Application {
Application {
description: "Stalwart Web Interface".to_string(),
description: "INBUXA Web Interface".to_string(),
enabled: true,
url_prefix: Map::new(vec!["/admin".into(), "/account".into()]),
..Default::default()
@@ -312,7 +312,7 @@ mod tests {
clients,
vec![FirstPartyClient {
client_id: WEB_INTERFACE_CLIENT_ID.to_string(),
description: "Stalwart Web Interface (served by this server)".to_string(),
description: "INBUXA Web Interface (served by this server)".to_string(),
redirect_uris: vec![
"https://mail.example.org/admin/oauth/callback".to_string(),
"https://mail.example.org/account/oauth/callback".to_string(),
+2 -2
View File
@@ -29,11 +29,11 @@ pub(crate) fn spawn_otel_tracer(builder: SubscriberBuilder, mut otel: OtelTracer
let (_, mut rx) = builder.register();
tokio::spawn(async move {
let resource = Resource::builder()
.with_service_name("stalwart")
.with_service_name("inbuxa")
.with_attribute(KeyValue::new(SERVICE_VERSION, types::brand_version_full!()))
.build();
let instrumentation = InstrumentationScope::builder("stalwart")
let instrumentation = InstrumentationScope::builder("inbuxa")
.with_version(types::brand_version_full!())
.build();
@@ -657,7 +657,7 @@ fn map_dns_server(dns_server: &DnsServerBootstrap) -> Option<registry::schema::s
// FreeBSD keeps variable application data under /var/db (hier(7))
// rather than FHS /var/lib.
const DEFAULT_DATA_PATH: &str = if cfg!(target_os = "freebsd") {
"/var/db/stalwart/"
"/var/db/inbuxa/"
} else {
"/var/lib/inbuxa/"
};
+1 -1
View File
@@ -211,7 +211,7 @@ impl<T: SessionStream> Session<T> {
Request::Help { .. } => {
trc::event!(Smtp(SmtpEvent::Help), SpanId = self.data.session_id,);
self.write(b"250 2.0.0 Help can be found at https://stalw.art\r\n")
self.write(concat!("250 2.0.0 Help can be found at ", types::brand_url!(), "\r\n").as_bytes())
.await?;
}
Request::Helo { host } => {
+1 -1
View File
@@ -80,7 +80,7 @@ const SPAN_MAX_HOLD: u64 = 60 * 60 * 24; // 1 day
pub(crate) static COLLECTOR_THREAD: LazyLock<Arc<CollectorThread>> = LazyLock::new(|| {
Arc::new(
Builder::new()
.name("stalwart-collector".to_string())
.name("inbuxa-collector".to_string())
.spawn(move || {
Collector::default().collect();
})
+6 -2
View File
@@ -71,7 +71,7 @@ pub fn env_var(name: &str) -> Result<String, std::env::VarError> {
#[macro_export]
macro_rules! brand_version {
() => {
"2026.9.22"
"2026.9.23"
};
}
@@ -82,7 +82,11 @@ macro_rules! brand_version {
/// this becomes just the version.
#[macro_export]
macro_rules! brand_version_full {
// The upstream crate version, without naming the upstream project: this
// string is user-visible (--version, the startup banner, the console,
// telemetry and the JMAP session's "implementation" field), and the name
// belongs only in copyright notices and the lineage line.
() => {
concat!($crate::brand_version!(), " (Stalwart ", env!("CARGO_PKG_VERSION"), ")")
concat!($crate::brand_version!(), " (upstream ", env!("CARGO_PKG_VERSION"), ")")
};
}
+16
View File
@@ -21,6 +21,22 @@ at the repository root before the import is merged.
It needs Python 3.12+ (for `tarfile`'s `data` filter) and git.
## name-check.py
Fails when the upstream project's name appears in a Rust string literal that
`name-allowlist.txt` doesn't list. CI runs it on every push and pull request,
so an upstream merge can't bring the name back into what users and operators
see. Comments, copyright headers and test directories aren't checked.
```bash
tools/fork/name-check.py # exit 1 on anything new
tools/fork/name-check.py --list # every finding, in allowlist format
```
Rename what it reports. If a string has to stay, such as a key-derivation
context or a wire-protocol identifier, add its `--list` line to the allowlist
under the reason it stays.
## record-compat.py
Records what the `*_compat` tests compare against, from the Enterprise
+59
View File
@@ -0,0 +1,59 @@
# String literals allowed to keep the upstream project's name.
# Read by tools/fork/name-check.py. One per line: path<TAB>literal as a JSON
# string, as `name-check.py --list` prints it. Each group says why it stays;
# add a line only under a reason, or with a new one.
# Key-derivation contexts. Renaming them invalidates every sealed OAuth token
# and client id already issued.
crates/common/src/auth/oauth/client_id.rs "stalwart-oauth-client-id-sw1"
crates/common/src/auth/oauth/token.rs "stalwart-oauth-token-sw1"
# Keys and prefixes of data already in the store.
crates/common/src/manager/application.rs "STALWART_APP_"
crates/common/src/manager/mod.rs "STALWART_SPAM_CLASSIFIER_MODEL.lz4"
crates/common/src/manager/mod.rs "STALWART_SPAM_TRAIN_DATA.lz4"
# The web interface's OAuth client id, which existing installs and the admin
# front end already use. The id itself, and the tests that check it.
crates/common/src/manager/first_party.rs "stalwart-webui"
crates/common/src/manager/application.rs "stalwart-webui"
crates/common/src/manager/application.rs "<meta name=\\\"oauth-client-id\\\" content=\\\"stalwart-webui\\\" />"
# Wire-protocol identifiers clients already hold or negotiate: WebDAV lock and
# sync tokens, the JMAP capability, Sieve extensions.
crates/dav/src/common/lock.rs "urn:stalwart:davsync:"
crates/dav/src/common/uri.rs "urn:stalwart:"
crates/dav/src/common/uri.rs "urn:stalwart:davlock:{id:x}"
crates/dav/src/common/uri.rs "urn:stalwart:davsync:"
crates/dav/src/common/uri.rs "urn:stalwart:davsync:{id:x}"
crates/dav/src/common/uri.rs "urn:stalwart:davsync:{id:x}:{seq:x}"
crates/jmap-proto/src/request/capability.rs "urn:stalwart:jmap"
crates/registry/src/schema/enums_impl.rs "vnd.stalwart.expressions"
crates/registry/src/schema/enums_impl.rs "vnd.stalwart.while"
# Moving an existing upstream installation over: its environment variables,
# and upstream's upgrade guide for the store conversion.
crates/types/src/branding.rs "STALWART_{name}"
crates/types/src/branding.rs "Warning: STALWART_{name} is deprecated; set INBUXA_{name} instead."
crates/store/src/build/registry.rs "⚠️ INBUXA_RECOVERY_ADMIN (or STALWART_RECOVERY_ADMIN) is set, but the"
crates/jmap/src/registry/mapping/bootstrap.rs "https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md"
crates/migration/src/lib.rs "https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md"
# Upstream's published spam-filter rules, fetched at runtime.
crates/registry/src/schema/structs_impl.rs "https://github.com/stalwartlabs/spam-filter/releases/latest/download/spam-filter-rules.json.gz"
# Test fixtures: web-push contact address parsing.
crates/common/src/network/webpush.rs " [email protected] "
crates/common/src/network/webpush.rs "MAILTO:[email protected]"
crates/common/src/network/webpush.rs "[email protected]"
crates/common/src/network/webpush.rs "http://stalw.art"
crates/common/src/network/webpush.rs "https://stalw.art/contact"
crates/common/src/network/webpush.rs "mailto:[email protected]"
crates/common/src/network/webpush.rs "stalw.art"
# OPEN, not yet decided (2026-09-22): operator-visible defaults. The log file
# prefix (TracerLog, and the bootstrap's tracer) names files stalwart.* in
# /var/log/inbuxa/, and the SQL stores default their database and user to
# "stalwart". Changing the SQL defaults would break an install relying on them.
crates/jmap/src/registry/mapping/bootstrap.rs "stalwart"
crates/registry/src/schema/structs_impl.rs "stalwart"
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Coffey Labs
# SPDX-License-Identifier: AGPL-3.0-only
"""
Fail when the upstream project's name turns up in a new Rust string literal.
tools/fork/name-check.py # check; exit 1 on anything new
tools/fork/name-check.py --list # print every finding, allowlist format
The name belongs only in copyright notices and the lineage line. Everything
else a user or operator can see -- messages, the version string, service
names, descriptions -- carries INBUXA's. Merging an upstream release brings
new strings in with the name, and the merge itself can't tell, so this runs
in CI on every push and pull request.
Scope: string literals in `crates/**/*.rs`, test directories excluded.
Comments are skipped, so copyright headers and doc comments never match.
Some literals have to keep the name -- key-derivation contexts, wire-protocol
identifiers, defaults that read an upstream installation -- and those are
listed in `name-allowlist.txt` beside this script, each under the reason it
stays. A finding is matched by file and literal text, not line number, so
the allowlist survives code moving around.
When the check fails, rename the string. If it genuinely has to stay, add
the line `--list` prints for it to the allowlist under a reason.
"""
import argparse
import json
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
ALLOWLIST = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'name-allowlist.txt')
NAME = re.compile(r'stalwart|stalw\.art', re.IGNORECASE)
SKIP_DIRS = {'tests', 'benches', 'target', '.git'}
CHAR = re.compile(r"'(?:\\u\{[0-9a-fA-F]+\}|\\x[0-9a-fA-F]{2}|\\.|[^\\'\n])'")
RAW = re.compile(r'b?r(#*)"')
def literals(src):
"""Yield the text of every string literal in `src`, comments skipped."""
i, n = 0, len(src)
while i < n:
c = src[i]
if src.startswith('//', i):
i = src.find('\n', i)
if i < 0:
return
elif src.startswith('/*', i):
depth, i = 1, i + 2
while i < n and depth:
if src.startswith('/*', i):
depth, i = depth + 1, i + 2
elif src.startswith('*/', i):
depth, i = depth - 1, i + 2
else:
i += 1
elif c in 'br' and (m := RAW.match(src, i)) and (i == 0 or not (src[i - 1].isalnum() or src[i - 1] == '_')):
end = '"' + m.group(1)
j = src.find(end, m.end())
if j < 0:
return
yield src[m.end():j]
i = j + len(end)
elif c == '"':
j = i + 1
while j < n and src[j] != '"':
j += 2 if src[j] == '\\' else 1
yield src[i + 1:j]
i = j + 1
elif c == "'":
# A char literal, or else a lifetime / label, which is skipped.
m = CHAR.match(src, i)
i = m.end() if m else i + 1
else:
i += 1
def findings():
found = set()
crates = os.path.join(ROOT, 'crates')
for dirpath, dirnames, filenames in os.walk(crates):
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
for f in sorted(filenames):
if not f.endswith('.rs'):
continue
path = os.path.join(dirpath, f)
with open(path, encoding='utf-8', errors='replace') as fh:
for lit in literals(fh.read()):
if NAME.search(lit):
found.add((os.path.relpath(path, ROOT), lit))
return found
def fmt(entry):
return f'{entry[0]}\t{json.dumps(entry[1], ensure_ascii=False)}'
def allowlist():
allowed = set()
with open(ALLOWLIST, encoding='utf-8') as fh:
for n, line in enumerate(fh, 1):
line = line.rstrip('\n')
if not line.strip() or line.lstrip().startswith('#'):
continue
path, sep, lit = line.partition('\t')
try:
allowed.add((path, json.loads(lit)))
except (ValueError, TypeError):
sys.exit(f'{ALLOWLIST}:{n}: expected "path<TAB>json string", got {line!r}')
if not sep:
sys.exit(f'{ALLOWLIST}:{n}: expected "path<TAB>json string", got {line!r}')
return allowed
def main():
ap = argparse.ArgumentParser(description=__doc__.split('\n\n')[0].strip())
ap.add_argument('--list', action='store_true', help='print every finding in allowlist format and exit')
args = ap.parse_args()
found = findings()
if args.list:
for entry in sorted(found):
print(fmt(entry))
return 0
allowed = allowlist()
new = sorted(found - allowed)
stale = sorted(allowed - found)
for entry in stale:
# Gone from the code: harmless, but the list should shrink with it.
print(f'stale allowlist entry, no longer in the code: {fmt(entry)}')
if new:
print(f'\n{len(new)} string literal(s) carry the upstream name. Rename them, or if one must stay,')
print(f'add its line to {os.path.relpath(ALLOWLIST, ROOT)} under the reason:\n')
for entry in new:
print(fmt(entry))
return 1
print(f'name check: clean ({len(found)} allowlisted literal(s)).')
return 0
if __name__ == '__main__':
sys.exit(main())