Skip to content

Commit 6e47e7f

Browse files
Merge branch 'main' into claude/licence-consistency-check
2 parents ddfe9e0 + 7c2b815 commit 6e47e7f

64 files changed

Lines changed: 2872 additions & 3893 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# elixir-ci-reusable.yml — Reusable Elixir CI bundle (RSR).
3+
#
4+
# Replaces the per-repo `elixir-ci.yml` template that copy-drifted (and
5+
# in several cases got corrupted) across the estate. Estate audit
6+
# (2026-05-26) found 9 repos shipping their own copy, with 9 unique
7+
# SHAs — 100% drift. One copy (`bofig`) was YAML-broken with literal
8+
# `npermissions:` lines from a botched permissions injection.
9+
#
10+
# Recurring failure modes observed across the estate:
11+
#
12+
# * Elixir version pinned to 1.15 while mix.exs declared ~> 1.17,
13+
# producing `(Mix) … but it has declared in its mix.exs file
14+
# it supports only Elixir ~> 1.17` (tma-mark2 #41 lived this).
15+
# * `mix compile --warnings-as-errors` applied to the whole
16+
# build, so transitive-dep warnings (e.g. rustler's
17+
# `:json.decode` reference needing Elixir 1.18) failed CI even
18+
# when the project's own code was clean.
19+
# * Inconsistent `permissions:` placement (some at top, some
20+
# mid-file, some missing).
21+
#
22+
# This reusable:
23+
# * Pins to the tma-mark2 #41-validated canonical shape.
24+
# * Compiles deps WITHOUT --warnings-as-errors first, then app code
25+
# with the strict flag — so deps warnings don't fail us but our
26+
# own code still gets the hygiene gate.
27+
# * Gates dialyzer behind an opt-in input (~5-minute cold-cache run
28+
# on most repos).
29+
# * Guards every job on `mix.exs` presence so consumers can add
30+
# the wrapper unconditionally.
31+
#
32+
# Caller example:
33+
#
34+
# jobs:
35+
# elixir-ci:
36+
# uses: hyperpolymath/standards/.github/workflows/elixir-ci-reusable.yml@main
37+
#
38+
# With dialyzer + customised versions:
39+
#
40+
# jobs:
41+
# elixir-ci:
42+
# uses: hyperpolymath/standards/.github/workflows/elixir-ci-reusable.yml@main
43+
# with:
44+
# elixir-version: "1.18"
45+
# enable_dialyzer: true
46+
47+
name: Elixir CI (reusable)
48+
49+
on:
50+
workflow_call:
51+
inputs:
52+
runs-on:
53+
description: Runner label for the Elixir CI job
54+
type: string
55+
required: false
56+
default: ubuntu-latest
57+
otp-version:
58+
description: OTP version selector for erlef/setup-beam
59+
type: string
60+
required: false
61+
default: "26"
62+
elixir-version:
63+
description: Elixir version selector for erlef/setup-beam
64+
type: string
65+
required: false
66+
default: "1.17"
67+
enable_dialyzer:
68+
description: Run `mix dialyzer` (slow cold-cache; off by default)
69+
type: boolean
70+
required: false
71+
default: false
72+
enable_credo:
73+
description: Run `mix credo --strict`
74+
type: boolean
75+
required: false
76+
default: true
77+
78+
permissions:
79+
contents: read
80+
81+
jobs:
82+
test:
83+
name: Compile + test
84+
runs-on: ${{ inputs.runs-on }}
85+
# Guard on mix.exs so the wrapper is safe to add unconditionally.
86+
if: hashFiles('mix.exs') != ''
87+
permissions:
88+
contents: read
89+
env:
90+
MIX_ENV: test
91+
steps:
92+
- name: Checkout repository
93+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
94+
with:
95+
repository: ${{ github.repository }}
96+
ref: ${{ github.ref }}
97+
98+
- name: Set up BEAM (OTP + Elixir)
99+
uses: erlef/setup-beam@5304e04ea2b355f03681464e683d92e3b2f18451 # v1.18.2
100+
with:
101+
otp-version: ${{ inputs.otp-version }}
102+
elixir-version: ${{ inputs.elixir-version }}
103+
104+
- name: Cache deps
105+
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
106+
with:
107+
path: deps
108+
key: deps-${{ inputs.elixir-version }}-${{ hashFiles('mix.lock') }}
109+
110+
- name: Cache _build
111+
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
112+
with:
113+
path: _build
114+
key: build-${{ inputs.elixir-version }}-${{ hashFiles('mix.lock') }}
115+
116+
- name: Install deps
117+
run: mix deps.get
118+
119+
# Compile deps WITHOUT --warnings-as-errors so upstream warnings
120+
# (rustler's :json.decode needing Elixir 1.18, deprecated
121+
# `use Bitwise`, etc.) don't fail the build. Strict mode then
122+
# applies only to the project's own modules in the next step.
123+
- name: Compile dependencies
124+
run: mix deps.compile
125+
126+
- name: Compile project (strict)
127+
run: mix compile --warnings-as-errors
128+
129+
- name: Credo lint
130+
if: ${{ inputs.enable_credo }}
131+
run: mix credo --strict
132+
133+
- name: Dialyzer
134+
if: ${{ inputs.enable_dialyzer }}
135+
run: mix dialyzer
136+
137+
- name: Run tests
138+
run: mix test --cover

.github/workflows/governance-reusable.yml

Lines changed: 57 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -131,94 +131,37 @@ jobs:
131131
repository: ${{ github.repository }}
132132
ref: ${{ github.ref }}
133133

134-
- name: Check for TypeScript
135-
run: |
136-
python3 << 'PYEOF'
137-
import re, sys, pathlib
138-
139-
DIR_NAMES_ALLOWED = {
140-
'bindings', 'tests', 'test', 'scripts',
141-
'mcp-adapter', 'cli', 'vendor', 'examples', 'ffi',
142-
'node_modules', 'benchmarks',
143-
}
144-
145-
def builtin_allowed(p):
146-
if p.endswith('.d.ts'):
147-
return True
148-
base = p.rsplit('/', 1)[-1]
149-
if base == 'mod.ts':
150-
return True
151-
if base in ('lsp-server.ts', 'lsp_server.ts', 'lsp.ts') or base.endswith('-lsp.ts'):
152-
return True
153-
if base.endswith('.bench.ts') or base.endswith('_bench.ts'):
154-
return True
155-
segs = p.split('/')
156-
for s in segs[:-1]:
157-
if s in DIR_NAMES_ALLOWED:
158-
return True
159-
if 'vscode' in s:
160-
return True
161-
if s.startswith('deno-'):
162-
return True
163-
return False
164-
165-
def glob_to_regex(g):
166-
out = []
167-
for c in g.lstrip('./'):
168-
if c == '*': out.append('.*')
169-
elif c == '?': out.append('.')
170-
elif c in '.+(){}[]|^$\\': out.append(re.escape(c))
171-
else: out.append(c)
172-
return re.compile('^' + ''.join(out) + '$')
173-
174-
exemption_patterns = []
175-
claude_md = pathlib.Path('.claude/CLAUDE.md')
176-
if claude_md.exists():
177-
in_table = False
178-
for line in claude_md.read_text(encoding='utf-8').splitlines():
179-
if re.search(r'TypeScript [Ee]xemptions', line):
180-
in_table = True
181-
continue
182-
if in_table and line.startswith(('### ', '## ', '# ')):
183-
break
184-
if in_table and line.startswith('|'):
185-
m = re.match(r'\|\s*`([^`]+)`', line)
186-
if m:
187-
exemption_patterns.append((m.group(1), glob_to_regex(m.group(1))))
188-
189-
def exempt(p):
190-
for raw, regex in exemption_patterns:
191-
if regex.match(p):
192-
return True
193-
if p == raw.lstrip('./'):
194-
return True
195-
if raw.endswith('/') and p.startswith(raw.lstrip('./')):
196-
return True
197-
return False
134+
# Estate language policy bans Python with no exceptions (CLAUDE.md
135+
# Language Policy; SaltStack exception removed 2026-01-03). The
136+
# previous in-line `python3 << PYEOF` heredoc made this very gate a
137+
# self-referential violation — same structural class as the CSA001
138+
# self-loop fixed in hypatia#328. Eradicated by porting the logic
139+
# to a Deno script that lives in this standards repo.
140+
#
141+
# Implementation note: a reusable workflow only auto-checks-out its
142+
# YAML, not sibling files in its repo. So we explicitly check out
143+
# this repo at the same ref the caller picked (via
144+
# `github.workflow_sha`, the SHA actually loaded by the runner)
145+
# into `.standards-checkout/`, then run the script from there.
146+
- name: Set up Deno
147+
uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3
148+
with:
149+
deno-version: v2.x
198150

199-
found = []
200-
for ext in ('ts', 'tsx'):
201-
for p in pathlib.Path('.').rglob(f'*.{ext}'):
202-
parts = p.parts
203-
if any(part.startswith('.') and part not in ('.', '..') for part in parts):
204-
continue
205-
found.append(p.as_posix().lstrip('./'))
151+
- name: Check out standards repo for shared scripts
152+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
153+
with:
154+
repository: hyperpolymath/standards
155+
ref: ${{ github.workflow_sha }}
156+
path: .standards-checkout
157+
# Sparse-checkout only the scripts dir to keep this fast.
158+
sparse-checkout: |
159+
scripts
160+
sparse-checkout-cone-mode: false
206161

207-
bad = sorted(f for f in found if not (builtin_allowed(f) or exempt(f)))
208-
if bad:
209-
print("❌ TypeScript files detected outside the allowlist.\n")
210-
for f in bad:
211-
print(f" {f}")
212-
print()
213-
print("To resolve, choose one:")
214-
print(" (a) migrate the file to AffineScript")
215-
print(" (b) move to an allowlisted bridge path")
216-
print(" (c) add an entry to the 'TypeScript Exemptions' table in .claude/CLAUDE.md")
217-
if exemption_patterns:
218-
print(f"\n(Currently {len(exemption_patterns)} exemption(s) parsed from .claude/CLAUDE.md.)")
219-
sys.exit(1)
220-
print(f"✅ No TypeScript files outside allowlist ({len(exemption_patterns)} per-repo exemption(s) parsed).")
221-
PYEOF
162+
- name: Check for TypeScript
163+
# Read-only execution; never writes outside the runner workspace.
164+
run: deno run --allow-read .standards-checkout/scripts/check-ts-allowlist.ts
222165

223166
# Shared escape hatch for the banned-language-file checks below.
224167
# Honours three exemption mechanisms (see
@@ -244,20 +187,42 @@ jobs:
244187
245188
# Baseline lookup: returns 0 (exempt) if the file appears in
246189
# .hypatia-baseline.json with a matching rule_module + type.
247-
# `file_pattern` glob match is intentionally NOT implemented
248-
# here; the advisory-rollout window only honours exact `file`
249-
# matches. Pattern support arrives with the blocking-mode
250-
# flip and the apply-baseline.sh upgrade (see
251-
# standards/scripts/apply-baseline.sh).
190+
# Honours both `file` (exact) and `file_pattern` (glob) entries.
191+
# The glob → regex translation mirrors apply-baseline.sh exactly:
192+
# `**` matches any depth (incl. `/`); `*` matches one segment.
193+
# Pattern support unblocks language-demo repos (absolute-zero
194+
# carries ~30 banned-language example files under `examples/`)
195+
# and any repo that vendors such subtrees, replacing per-file
196+
# `.hypatia-ignore` enumeration with one `file_pattern` entry.
252197
in_baseline() {
253198
local target="$1"
254199
[ -f .hypatia-baseline.json ] || return 1
255200
command -v jq >/dev/null 2>&1 || return 1
201+
# Note: the `as $pat` capture is essential — inside `test(...)`
202+
# the dot rebinds to test's input ($f, a string), so
203+
# `.file_pattern` would error with "Cannot index string". We
204+
# capture file_pattern in $pat first, then reference it inside
205+
# the test() argument.
256206
jq -e \
257207
--arg rm "$rule_module" \
258208
--arg rt "$rule_type" \
259209
--arg f "$target" \
260-
'any(.[]; .rule_module == $rm and .type == $rt and .file == $f)' \
210+
'any(.[];
211+
.rule_module == $rm and .type == $rt
212+
and (
213+
(.file? // null) == $f
214+
or (
215+
(.file_pattern? // null) as $pat
216+
| $pat != null
217+
and ($f | test(
218+
$pat
219+
| gsub("\\*\\*"; "DOUBLESTAR")
220+
| gsub("\\*"; "[^/]*")
221+
| gsub("DOUBLESTAR"; ".*")
222+
| "^" + . + "$"
223+
))
224+
)
225+
))' \
261226
.hypatia-baseline.json >/dev/null 2>&1
262227
}
263228

0 commit comments

Comments
 (0)