Adopt the four markdown namespaces and gate them

Renames this repo's markdown onto README.md / CLAUDE*.md / DOCS*.md /
TODO*.md so a future session can tell a live task from a finished
record without reading the file to find out.

TODO files carry the literal "REMOVE ME AFTER FINISH" and are deleted
when the work lands. Enforced by the md-naming pre-commit hook and CI
workflow, both delegating to the shared gate in kuhyx/utils so this
repo's idea of a valid name cannot drift from every other repo's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A47aX5DJwmA3QFn3pkEWi7
This commit is contained in:
Krzysztof Rudnicki 2026-08-24 15:53:51 +02:00
parent 0c348a5ee9
commit 21a989ce21
9 changed files with 118 additions and 370 deletions

38
.github/workflows/md-naming.yml vendored Normal file
View File

@ -0,0 +1,38 @@
name: md-naming
# The four markdown namespaces, enforced on push as well as at commit time.
# The local pre-commit hook can be skipped (a fresh clone that never ran
# `pre-commit install` has no hooks at all), so the convention needs a second
# gate that no one can forget to install.
#
# Deliberately a SEPARATE workflow from any release job: a badly named doc
# should fail lint, not block publishing an artifact.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
md-naming:
name: markdown naming convention
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# The gate and its exemption list live in the shared utils repo so every
# repo enforces the same namespaces. Checking it out beats vendoring a
# copy here, which is exactly how a gate and a survey start disagreeing.
- name: Check out the shared gate
uses: actions/checkout@v4
with:
repository: kuhyx/utils
path: .utils
- name: Enforce the naming convention
run: bash .utils/scripts/check_md_naming.sh --all
# Warns only: no machine can prove a task is finished, so an old TODO
# gets a nudge, never a failure.
- name: Warn about stale TODOs
run: PYTHONPATH=.utils python3 .utils/md_naming/stale.py

View File

@ -154,8 +154,20 @@ repos:
files: '^wake_alarm/.*\.py$'
pass_filenames: false
require_serial: true
- id: max-file-length
name: Max file length (500 lines)
entry: python3 scripts/check_file_length.py
# The shared 250-line cap. This repo used to run a local script with a
# higher limit under this same hook id, so it looked gated while letting
# through files every other repo rejects.
- id: file-length
name: file length <= 250 lines
entry: scripts/check_file_length.sh
language: system
types_or: [python, shell]
# Four markdown namespaces (README / CLAUDE* / DOCS* / TODO*), plus the
# rule that a TODO carries "REMOVE ME AFTER FINISH" and nothing else may.
# Shared gate in ~/utils owns the namespaces and the exemption list, so
# one repo's idea of a valid name cannot drift from every other repo's.
- id: md-naming
name: markdown naming convention
entry: scripts/check_md_naming.sh
language: system
files: \.(md|markdown)$

View File

@ -1,251 +0,0 @@
# Design audit — wake-alarm
Generated against safe-design-rules (anthonyhobday.com/sideprojects/saferules).
Report only — nothing in this repo was changed by the audit itself.
This repo has two distinct UI surfaces, audited separately below:
1. Flutter app — `phone_app/lib/` (theme in `main.dart`, screens in `screens/`)
2. Python/Tkinter alarm dismiss-challenge UI — `wake_alarm/_alarm.py`
Passing/violation/N-A counts per surface are given at the top of each section
so all 28 rules are accounted for even though only violations get expanded.
---
## Flutter app (`phone_app/`)
Theme entry point: `phone_app/lib/main.dart:17-21` —
`ThemeData.dark(useMaterial3: true).copyWith(colorScheme:
ColorScheme.fromSeed(seedColor: Colors.orange, brightness: Brightness.dark))`.
Material 3's `fromSeed` auto-generates near-black surfaces, tinted neutrals,
and contrast-checked roles, so rules 1/2/3/9/10/18 pass "for free" via the
theme — the actual findings are places where code reaches for a literal
`Colors.*` constant instead of a role from that scheme, or a shadow/padding
value the theme doesn't control.
Verdict roster: Pass — 1,2,3,8,10,17,18,23,26. Violation — 4,9(partial,
see notes),11,20,26(Card),28. N/A — 5,6,7,12,13,14,15,16,19,21,22,24,25,27.
(9 and 26 appear in both lists — see the per-rule notes.)
### Violations
- **Rule 4 (everything deliberate)** — `phone_app/lib/screens/home_screen.dart:198-199` and
`settings_screen.dart:126-128,161,245` use raw `Colors.redAccent` /
`Colors.greenAccent` / `Colors.red` for status/error text instead of
`Theme.of(context).colorScheme.error` (or a `tertiary`/success role);
`home_screen.dart:207` uses raw `Colors.orange` and `settings_screen.dart:138`
uses raw `Colors.grey` for the same reason. None of these are derived from
the seeded `ColorScheme`, so they won't stay in sync if the seed color ever
changes, and they bypass whatever contrast guarantee `fromSeed` gives the
rest of the app. → Replace with `colorScheme.error` / a defined success
color / `colorScheme.onSurfaceVariant`.
- **Rule 9 (distinct brightness / no competing colors)** — `home_screen.dart:198-199`
vs `settings_screen.dart:126-128,161`: `Colors.redAccent`/`Colors.greenAccent`
are reused ad hoc as the only "status" signal color pair across two separate
screens with no shared definition — not a brightness clash per se, but an
undeliberate ad-hoc pairing (same root cause as rule 4).
- **Rule 11 (mathematically related measurements)** — the app mostly uses an
8px scale (`home_screen.dart:149,154,165,170,191,203` = 32/16/8/32/24/16;
`settings_screen.dart:93,98,121,131` = 16/16/16/32) but breaks it twice:
`settings_screen.dart:111` (`padding: EdgeInsets.symmetric(vertical: 14)`)
and `settings_screen.dart:163` (`SizedBox(width: 12)`) — 14 and 12 aren't on
the 8px grid the rest of the file uses. → change to 16/8 or otherwise round
to the established scale.
- **Rule 20 (body text ≥16px)** — `settings_screen.dart:138`:
`TextStyle(fontSize: 12, color: Colors.grey)` for the PC-config help text is
below the 16px minimum. `home_screen.dart:153` (`fontSize: 16`) and other
`fontSize: 18` usages are fine.
- **Rule 26 (no shadows in dark interfaces)** — `settings_screen.dart:154`
(`return Card(...)`) is an unmodified Material 3 `Card`, which renders with
default elevation (a soft shadow/tonal overlay) inside a `ThemeData.dark()`
app (`main.dart:17`). Per the rule, dark UIs should avoid shadows entirely.
→ Either set `elevation: 0` and rely on a filled/tonal surface color for
the card, or accept the default and drop this from future review — flagging
now since it's the only place shadows appear at all (see rule 27 below).
- **Rule 28 (lower icon contrast next to text)** — `settings_screen.dart:159-162`
(`Icon(connected ? Icons.check_circle : Icons.cancel, color: connected ?
Colors.greenAccent : Colors.redAccent)`) sits directly next to `Text(...)`
at full theme-text contrast — the icon isn't dimmed relative to the
adjacent label. Same issue for the settings-gear `IconButton` at
`home_screen.dart:133-134` (default `Icons.settings` color, undimmed, next
to the "Wake Alarm Sync" AppBar title).
### Not applicable
- Rule 5 (optical alignment) — only stock Material icons/text used, no custom
shapes needing eyeballed alignment.
- Rule 6 (letter-spacing/line-height by size) — no explicit `letterSpacing`/
`height` overrides anywhere in `phone_app/lib/` (grep confirmed); relies on
Material defaults, nothing to audit.
- Rule 7 (border contrast) — no `Border`/`BorderRadius`/`border:` usage found
anywhere under `phone_app/lib/` (grep confirmed); no borders exist.
- Rule 12 (order by visual weight) — single linear `Column` per screen, no
competing-weight layout to order.
- Rule 13 (12-column grid) — single-column mobile screens, no grid layout.
- Rule 14 (space between high-contrast points) — can't be assessed from
source; spacing is via `SizedBox`/`Padding` bounding boxes, not measured
edge-to-edge.
- Rule 15 (closer = lighter) — the one elevated element (`Card`,
`settings_screen.dart:154`) follows M3's automatic surface-tint elevation
overlay, which already lightens on elevation — no manual z-stack to check.
- Rule 16 (shadow blur = 2x distance) — shadow is Material's default (see
rule 26), not a custom `BoxShadow`, so there's no explicit offset/blur pair
to check a ratio on.
- Rule 19 (outer padding ≥ inner) — `settings_screen.dart:93` outer body
padding (16) vs `settings_screen.dart:156` `_StatusCard`'s own inner
padding (16): equal, satisfies "≥". `home_screen.dart:149` outer padding
(32) vs the button's inner vertical padding (16, `home_screen.dart:186`):
outer > inner. Both pass, no violation to report.
- Rule 21 (line length ~70 chars) — only short status strings/labels; the one
paragraph (`settings_screen.dart:132-137`) is already manually broken into
short lines, no long unbroken runs to measure.
- Rule 22 (button padding horizontal = 2x vertical) — `home_screen.dart:185-188`
and `settings_screen.dart:110-113` only override `vertical` padding
(16 and 14) and leave horizontal at the `ElevatedButton` theme default —
the actual rendered ratio can't be read from source alone.
- Rule 24 (nest corners) — no custom `BorderRadius` anywhere (grep
confirmed), only default Material shapes.
- Rule 25 (adjacent hard divides) — only one `Divider` in the whole app
(`settings_screen.dart:131`), not adjacent to another border/transition.
- Rule 27 (don't mix depth techniques) — only one depth technique appears at
all (the `Card`'s default elevation, rule 26) — nothing to mix.
### Notes
- No dedicated `theme.dart` — the `ColorScheme`/`ThemeData` is inline in
`main.dart:17-21`. That's fine at this size (2 screens), but every raw
`Colors.*` literal found above is a spot that would have been caught by a
lint/review pass if a shared palette module existed to import from instead.
---
## Python/Tkinter alarm UI (`wake_alarm/_alarm.py`)
No shared style/constants module exists for color or font — confirmed via
`find` (no `theme`/`style`/`colors` module under `wake_alarm/`) and via
`_constants.py`, which defines timing/day constants but nothing visual.
Every `tk.Label`/`tk.Frame`/`tk.Entry` call in `_alarm.py` repeats its own
hex-color and `font=(...)` tuple literal inline (confirmed: `_alarm.py:166,
172-174,181-183,190-192,200-206,209-211,219-224,228-232,326-331,409`). This
structural absence is itself the rule 4 finding — it also means the same
rule (e.g. rule 2, rule 10) is violated independently at every call site
rather than in one place, so a future fix should introduce a small
`_style.py`/constants block rather than patch each `tk.Label(...)` call.
Verdict roster: Pass — 3,8,17,20,21,23,26,27. Violation — 1(partial),
2,4,9(mixed reuse—see below),10,11,12. N/A — 5,6,7,13,14,15,16,18,19,22,24,25,28.
### Violations
- **Rule 1 (near-black/near-white, not pure)** — `_alarm.py:166` (`bg="#1a1a1a"`)
correctly uses near-black, not pure black — that part passes. But
`_alarm.py:191-192` (`info_label`, `fg="white"`) uses **pure white** text,
which the rule explicitly calls out to avoid. → use an off-white like
`#e8e8e8`/`#f0f0ee` instead of the literal `"white"`.
- **Rule 2 (saturate your neutrals)** — the only "neutral" grey values used
are perfectly desaturated (zero-chroma) greys: `_alarm.py:232`
(`timer_label`, `fg="#aaaaaa"`) and `_alarm.py:305` (hidden-code flash
state, `fg="#555555"`). Neither has any color tint, so they read as
generic gray rather than part of the warm/cool palette the rest of the
screen uses (see rule 10). → tint both toward the same hue family as the
accent colors (e.g. a warm dark gray like `#4a3f3f` / `#8a7d7d`).
- **Rule 4 (everything deliberate / centralize styling)** — see the surface
intro above; every color/font is a repeated inline literal across
`_alarm.py:166-234` and `_alarm.py:326-331`, with no single source of
truth. → extract a small `_COLORS`/`_FONTS` mapping (mirroring the
existing `_constants.py` pattern for non-visual constants) and reference
it from every widget call.
- **Rule 9 (distinct brightness / deliberate reuse)** — `title_label`
(`_alarm.py:173`, `fg="#ff4444"`) and `status_label`
(`_alarm.py:223`, `fg="#ff4444"`) reuse the exact same color for two
different semantic roles ("WAKE UP!" heading vs. a transient error/status
message) with no evidence it was a deliberate choice rather than
copy-paste — flagging alongside rule 4 since fixing the missing style
module would force this decision to be made explicitly.
- **Rule 10 (warm OR cool, not both)** — the screen simultaneously uses warm
reds/ambers (`_alarm.py:173` `#ff4444`, `:182` `#ffaa00`, `:409`
`#ff0000` flash) and a fully saturated cool green (`_alarm.py:204,263,274,
347` `#00ff00`) on the same screen at the same time. → pick one
temperature family for the "safe/positive" accent (e.g. a warm amber-green
or switch the alert red to a cooler red) so the palette reads as one
coherent set rather than a stoplight.
- **Rule 11 (mathematically related measurements)** — `pady` values across
the widget stack are `20, 5, 10, 30, 10, 10, 5` (`_alarm.py:176, 185, 194,
207, 215, 226, 235`) — mostly multiples of 5/10, but `pady=5` at lines 185
and 235 breaks the 10/20/30 pattern used by the surrounding widgets.
→ round the two `pady=5` calls to `10` for a consistent scale.
- **Rule 12 (order by visual weight)** — the largest, boldest element on
screen is `code_label` at up to 72pt bold (`_alarm.py:198-206`,
`code_font_size = 48 if ... else 72`), sitting in the visual middle of the
stack, while the actual title (`_alarm.py:172`, 48pt bold) anchors the top
edge and the lightest elements (`timer_label`, 14pt, `_alarm.py:231`) sit
at the bottom edge. The rule wants the heaviest element toward an outer
edge, not buried in the middle. → this is likely intentional here (the
code you must type back is the whole point of the screen), so treat as a
judgment-call violation to confirm rather than an unambiguous bug.
### Not applicable
- Rule 5 (optical alignment) — only text `Label`s stacked via `.pack()`, no
asymmetric shapes/icons to align by eye.
- Rule 6 (letter-spacing/line-height by size) — Tkinter's `font=(family,
size, weight)` tuple has no letter-spacing/line-height control at all;
not expressible in this toolkit.
- Rule 7 (border contrast) — no `relief`/`highlightthickness`/`borderwidth`
set on any widget (grep confirmed); nothing has a visible border.
- Rule 13 (12-column grid) — single fullscreen widget centered via
`.place(relx=0.5, rely=0.5)` (`_alarm.py:167`), no grid layout in use.
- Rule 14 (space between high-contrast points) — `pack(pady=...)` controls
bounding-box spacing, not measured edge-to-edge visual contrast; not
determinable from source.
- Rule 15 (closer = lighter) — Tkinter has no z-stack/elevation concept;
this is a flat 2D layout on one background plane.
- Rule 16 (shadow blur = 2x distance) — no shadow effects implemented
anywhere in the file.
- Rule 18 (container brightness limits) — only one background plane exists
(`#1a1a1a`, toggled with a full-screen alert flash to `#ff0000` at
`_alarm.py:409` — not a stacked container), so there's no second surface
brightness to compare it against.
- Rule 19 (outer padding ≥ inner) — `container` (`_alarm.py:166`) has no
configured padding of its own; only individual children set `pady`. No
outer/inner pair exists to compare.
- Rule 22 (button padding) — no `tk.Button` widget exists anywhere in the
file (grep confirmed) — dismissal is via `tk.Entry` + `<Return>` bind
(`_alarm.py:209-217`), not a button.
- Rule 24 (nest corners) — Tkinter widgets are square by default with no
corner-radius concept; not implemented here.
- Rule 25 (adjacent hard divides) — no border/divider lines used anywhere.
- Rule 28 (icon contrast next to text) — no icons used anywhere in this UI;
text-only.
### Passes (brief)
- Rule 3 (high contrast for important elements) — `_alarm.py:172-174`
"WAKE UP!" title in `#ff4444` on `#1a1a1a` background is clearly the
highest-contrast element on screen.
- Rule 8 (align with something) — `container.place(relx=0.5, rely=0.5,
anchor="center")` (`_alarm.py:167`) plus uniform `.pack()` centering means
every widget shares the same center-alignment axis.
- Rule 17 (simple on complex) — flat solid `#1a1a1a` background behind flat
text (`_alarm.py:166`) is plain-on-plain throughout; no visual noise.
- Rule 20 (body text ≥16px) — smallest font used is 14pt
(`_alarm.py:231`, `timer_label`) ≈ 18-19px at typical DPI, above the 16px
floor; all other labels are 18pt+ (`_alarm.py:190,222`) or larger.
- Rule 21 (line length ~70 chars) — only short single-line status strings
are used (longest is ~55 characters, `_alarm.py:372`); no wrapped
paragraph text to overflow the readable range.
- Rule 23 (two typefaces max) — exactly two families used throughout,
`"Arial"` and `"Courier"` (`_alarm.py:172,181,190,203,211,222,231,329`) —
at the limit, not over it.
- Rule 26 (no shadows in dark interfaces) — trivially satisfied: no shadow
effects are implemented anywhere in this file.
- Rule 27 (don't mix depth techniques) — trivially satisfied: zero depth
techniques are used, so none can conflict.
### Notes
- The most impactful single fix for this surface is structural, not any one
color/font value: introduce one `_style.py` (or a `_COLORS`/`_FONTS` dict
in `_constants.py`) and reference it from every `tk.Label`/`tk.Frame` call
in `_alarm.py`. That alone would make rules 2, 9, 10, and 11 fixable in one
place instead of ~15 separate call sites.

View File

@ -1,89 +0,0 @@
# Migration: GitHub Gist → shared `crdt_sync`
`phone_app`'s alarm sync was moved off a bespoke GitHub Gist onto the shared
[`crdt_sync`](https://github.com/kuhyx/utils/tree/main/crdt_sync_dart) library
(pinned tag `crdt_sync_dart-v0.2.0` — see `phone_app/pubspec.yaml`, which is
the authority; `pubspec.lock` resolves it to `5be99ab`), matching how the other
companion apps sync. A `crdt_sync_dart-v0.3.0` tag also exists upstream and is
*not* in use here — bumping to it is an untaken decision, not a drift to fix.
## What changed
- **phone_app (Dart):** `gist_service.dart` is gone. `AlarmSyncService` stores
the alarm locally in a `crdt_sync` `LogStore` (one HLC-stamped `Record`) and
pushes it to `wake-alarm-sync/devices/phone/alarm.json` in `kuhyx/syncs` via
`crdt_sync`'s `GitHubClient`. The device-flow OAuth scope moved `gist` →
`repo` (the Contents API needs private-repo access).
- **shutdown-wrapper.sh (PC, Bash):** now reads that alarm from the Contents
API (`Accept: application/vnd.github.raw`, so no base64) and parses the
`crdt_sync` `Record` log with `jq` (`.alarm.fields.alarm_hour[0]`). Config
moved from `~/.config/wake_alarm/gist_sync.json` `{token, gist_id}` to
`~/.config/wake_alarm/sync.json` `{token}` (repo-scoped).
## Setup after pulling this
1. On the phone: Settings → **Connect GitHub** (now requests `repo` scope; the
old gist-scoped token won't reach the Contents API).
2. On the PC: `~/.config/wake_alarm/sync.json` → `{ "token": "<repo-scoped>" }`,
then re-run `install.sh` so `/usr/local/bin/shutdown` picks up the new
reader.
## Surprises / non-obvious decisions
- **The consumer is a Python-less Bash reader** (`shutdown-wrapper.sh` runs at
shutdown, when a Python runtime may not be available), so it can't use the
`crdt_sync` library — it parses the log with `jq`. What makes that tractable:
`Hlc.toStr()` is **lexicographically sortable == chronological**, so
"latest wins" across device dirs is a plain string max, no date math.
- **The phone is push-only** (sole writer); there is no pull/merge. Its local
`LogStore` is always the source of truth.
- Any parse miss still falls back to `WAKE_AFTER_HOURS`, so a bad read can
never brick the morning wake.
# Migration: hibernate-scheduled → per-minute trigger timer
The PC is no longer shut down at night, which broke the whole scheduling
model: the wake time used to be decided once, at hibernate, and baked into the
RTC. With no shutdown there is no resume, so nothing fired the alarm at all —
and `wake-alarm.service` was `WantedBy=graphical-session.target`, so on an
always-on machine it fired at *login* time instead (`_should_run_alarm()` has
no time gate).
## What changed
- **New `wake-alarm-trigger.timer`** ticks every minute and runs
`wake_alarm._trigger`, which fires `morning-routine.service` when the
current local time is inside
`[phone alarm, phone alarm + TRIGGER_CATCHUP_MINUTES)`. This is now the
primary path; the phone can move the alarm at any hour and the PC follows.
- **New `wake_alarm/_sync.py`** — a Python reader for the same alarm file
`shutdown-wrapper.sh` parses with `jq`, plus an on-disk cache
(`~/.config/wake_alarm/alarm_cache.json`).
- **`wake-alarm.service` lost its `[Install]` section** and is no longer
enabled; `install.sh` disables it to clear stale symlinks.
- **Token resolution gained a fallback** to the sibling apps'
`~/.config/{diet_guard,screen_locker}/sync_token`, in both readers.
- `shutdown-wrapper.sh` and `sleep-hook.sh` keep working unchanged, now as the
PC-is-powered-down fallback rather than the main mechanism.
## Surprises / non-obvious decisions
- **A timer, not a resident scheduler.** Each tick is a fresh process, so
there is no daemon to die silently and no wall-clock jump to reason about
across suspend/resume. `Persistent=true` plus the catch-up window is what
makes a suspended PC still fire on resume.
- **Per-minute ticks, but a 5-minute cache.** Ticking every 5 minutes would
also quantise *firing* to 5 minutes (07:03 → 07:05). Ticking every minute
and only hitting the network when the cache is stale gives exact-minute
firing at ~288 GitHub reads/day.
- **Degraded reads log at INFO.** At one tick a minute, a WARNING on a
persistent condition is 1440 journal lines a day. The single WARNING that
survives is `_trigger`'s "firing now on a degraded source".
- **The sync had been dead since the crdt_sync migration.** `wake-alarm-sync/`
did not exist in `kuhyx/syncs` at all — the phone had never successfully
pushed, and `~/.config/wake_alarm/sync.json` was never created, so the
hibernate path had been silently using the `+8h` fallback. The phone app
needs one successful "Set Alarm & Sync" to create that path.
- **Still open:** alarms edited directly in the Android Clock app do not reach
the sync. Closing that needs an `ACTION_NEXT_ALARM_CLOCK_CHANGED` receiver
in the phone app — planned, not yet built.

View File

@ -1,26 +0,0 @@
#!/usr/bin/env python3
"""Pre-commit hook: fail if any file exceeds MAX_LINES lines."""
from pathlib import Path
import sys
MAX_LINES = 500
def main() -> int:
"""Return 1 if any file exceeds the line limit, else 0."""
failed = False
for filepath in sys.argv[1:]:
try:
with Path(filepath).open(encoding="utf-8", errors="replace") as fh:
count = sum(1 for _ in fh)
except OSError:
failed = True
continue
if count > MAX_LINES:
failed = True
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())

32
scripts/check_file_length.sh Executable file
View File

@ -0,0 +1,32 @@
#!/bin/bash
# ============================================================================
# Fail if any file in the commit exceeds the shared 250-line cap.
#
# Thin delegate to the shared gate in ~/utils, which owns the cap and the
# exemption list (generated / vendored / data files). This repo previously ran
# a LOCAL check_file_length.py with its own higher limit, so it reported
# "gated" while permitting files every other repo rejects -- which is why its
# violations went untouched. Delegating is what stops that recurring.
#
# Usage:
# scripts/check_file_length.sh <file> [<file> ...] # pre-commit passes these
# scripts/check_file_length.sh --all # whole tree, from cwd
# ============================================================================
set -euo pipefail
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/utils}/scripts/check_file_length.sh"
main() {
if [[ ! -x "$SHARED_GATE" ]]; then
echo "Error: shared file-length gate not found at $SHARED_GATE" >&2
echo " Clone github.com/kuhyx/utils to ~/utils, or set" >&2
echo " UTILS_ROOT to where it lives." >&2
exit 1
fi
exec bash "$SHARED_GATE" "$@"
}
main "$@"

32
scripts/check_md_naming.sh Executable file
View File

@ -0,0 +1,32 @@
#!/bin/bash
# ============================================================================
# Fail if any markdown file breaks the shared naming convention.
#
# Thin delegate to the shared gate in ~/utils, which owns the four namespaces
# (README / CLAUDE* / DOCS* / TODO*), the "REMOVE ME AFTER FINISH" marker rule
# and the exemption list. Copying that logic here is what lets one repo's idea
# of a valid name drift from every other repo's -- so this script only locates
# the shared checker and forwards its arguments.
#
# Usage:
# scripts/check_md_naming.sh <file> [<file> ...] # pre-commit passes these
# scripts/check_md_naming.sh --all # whole tree, from cwd
# ============================================================================
set -euo pipefail
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/utils}/scripts/check_md_naming.sh"
main() {
if [[ ! -x "$SHARED_GATE" ]]; then
echo "Error: shared markdown-naming gate not found at $SHARED_GATE" >&2
echo " Clone github.com/kuhyx/utils to ~/utils, or set" >&2
echo " UTILS_ROOT to where it lives." >&2
exit 1
fi
exec bash "$SHARED_GATE" "$@"
}
main "$@"