diff --git a/specs/automation/owner_voice_reel.t27 b/specs/automation/owner_voice_reel.t27 deleted file mode 100644 index b6e37d3ade..0000000000 --- a/specs/automation/owner_voice_reel.t27 +++ /dev/null @@ -1,304 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -; specs/automation/owner_voice_reel.t27 -- the 15 s vertical reel in the owner's own voice, as data. -; On 2026-10-18 the owner asked for a free, best-quality 15 s reel in his voice, reusing 999's -; logic. It was made for $0 by hand (gHashTag/t27#10279): -; - video: 360 frames of the T27F deck look (t27f.wasm, the demo camera, the data of -; specs/t27f/reel_t27_basics.t27), then the CTA card held for 3 s; -; - voice: the 999 voice-clone service (Qwen3-TTS 1.7B) with the owner's own sample, called from -; inside the vibee-render pod; 10.16 s of speech; -; - captions: word timings from the 999 whisper service, one yellow word at a time; -; - assembly: ffmpeg on the Railway lab (drawtext captions, loudnorm, amix with the site's music). -; The numbers and rules of that recipe lived in lab-only JS glue (caps.mjs, frames.mjs), which the -; only-t27 rule keeps out of the repo. They live here now; the next reel's glue reads them. - -; WHAT IS MEASURED: the reel file is 1080x1920, 30 fps, 450 frames, 15.000 s, -14.1 LUFS integrated. -; WORD_START_MS and WORD_END_MS are the 21 caption timings of that reel as whisper gave them (before -; the voice offset). WORD_CHARS is each caption's length in characters (the words are Russian, so the -; text stays out of this ASCII file; the length is all the layout needs). LAB_FONT_SIZE is the -; fontsize the lab's ffmpeg filter (fc.txt) drew each caption at; the test -; font_sizes_match_the_lab_reel holds this spec's formula to all 21 of them. - -; ONE DEVIATION, RECORDED: the rule is one word per caption, and caption 7 of that reel was two -; words (a preposition and its noun, 12 characters with the space). CAPTION_WORDS keeps it as drawn, -; so the font check still covers it; the test one_word_per_caption counts it and allows no other. - -; Not claimed: the reel is not posted anywhere (posting needs the owner's yes); no host reads this -; file yet -- the lab glue still carries its own copy of these numbers until it is regenerated. -; phi^2 + 1/phi^2 = 3 | TRINITY - -module owner_voice_reel; - -pub const ID : str = "owner-voice-reel"; -pub const MEASURED_ON : str = "2026-10-18"; - -// ---- The frame and the clock ---------------------------------------------------------------- -pub const FRAME_W : i32 = 1080; -pub const FRAME_H : i32 = 1920; -pub const FPS : i32 = 30; -pub const REEL_MS : i32 = 15000; -/// The last 3 s hold the CTA card, silent but for the music: the canon of every reel template -/// (split-reel.t27 CTA_PAUSE_SEC, crm-story-reel.t27 PAUSE_SEC). -pub const CTA_HOLD_MS : i32 = 3000; -/// The voice starts 0.4 s in, so its phrases land on the deck's scene cuts. -pub const VOICE_OFFSET_MS : i32 = 400; - -// ---- The captions --------------------------------------------------------------------------- -/// The deck's slide rows occupy y = 1100 to 1400 in the tall preset, so the caption sits lower, -/// at 1600 (83% of the frame), not at the usual 70%. -pub const ROWS_TOP_Y : i32 = 1100; -pub const ROWS_BOTTOM_Y : i32 = 1400; -pub const CAPTION_Y : i32 = 1600; -pub const CAPTION_COLOR : u32 = 0xFFFF00; -pub const CAPTION_BORDER_PX : i32 = 9; -pub const CAPTION_SHADOW_Y_PX : i32 = 6; -pub const WORDS_PER_CAPTION : i32 = 1; -/// font size = min(124, 940 / (len * 0.72)): the widest caption keeps 70 px clear on each side -/// of the 1080 frame, at an average Inter ExtraBold capital 0.72 of the font size wide. -pub const CAPTION_FONT_MAX : i32 = 124; -pub const CAPTION_WIDTH_PX : i32 = 940; -pub const CHAR_WIDTH_PERMILLE : i32 = 720; -/// A word stays up until the next word starts or 0.35 s after it ends, whichever comes first. -pub const CAPTION_TAIL_MS : i32 = 350; - -// ---- The sound ------------------------------------------------------------------------------ -pub const SAMPLE_RATE : i32 = 48000; -/// The voice is normalised to I = -15 LUFS (true peak -1.5 dBTP, LRA 7) before the mix. -pub const VOICE_LUFS : i32 = -15; -pub const VOICE_TRUE_PEAK_TENTHS_DB : i32 = -15; -pub const VOICE_LRA : i32 = 7; -/// The site's music (app.t27.ai bgmusic.mp3) under the voice at volume 0.10, faded in over -/// 0.6 s and out from 13.6 s to the end. -pub const MUSIC_VOLUME_PERMILLE : i32 = 100; -pub const MUSIC_FADE_IN_MS : i32 = 600; -pub const MUSIC_FADE_OUT_START_MS : i32 = 13600; -/// The mix is limited at 0.89 of full scale. -pub const LIMIT_PERMILLE : i32 = 890; - -// ---- What the 2026-10-18 reel measured ------------------------------------------------------ -pub const WORD_COUNT : i32 = 21; -pub const VOICE_MS : i32 = 10160; -pub const MIX_LUFS_TENTHS : i32 = -141; -pub const WORD_START_MS : [21]i32 = [0, 400, 1440, 1860, 2740, 3080, 3260, 4240, 4800, 5040, 5520, 5960, 6700, 6940, 7460, 7880, 8040, 8760, 9240, 9400, 9660]; -pub const WORD_END_MS : [21]i32 = [400, 1120, 1860, 2440, 3080, 3260, 3980, 4800, 5040, 5520, 5960, 6360, 6940, 7460, 7880, 8040, 8520, 9240, 9400, 9660, 10000]; -pub const WORD_CHARS : [21]i32 = [6, 3, 2, 6, 6, 1, 12, 6, 4, 9, 9, 5, 3, 2, 5, 1, 8, 5, 1, 7, 5]; -pub const CAPTION_WORDS : [21]i32 = [1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]; -pub const LAB_FONT_SIZE : [21]i32 = [124, 124, 124, 124, 124, 124, 108, 124, 124, 124, 124, 124, 124, 124, 124, 124, 124, 124, 124, 124, 124]; -/// The enable windows of fc.txt, in ms of the reel (offset included), for three captions. -pub const LAB_FIRST_SHOWN : [2]i32 = [400, 800]; -pub const LAB_SEVENTH_SHOWN : [2]i32 = [3660, 4640]; -pub const LAB_LAST_SHOWN : [2]i32 = [10060, 10750]; - -// ---- The rules ------------------------------------------------------------------------------ - -/// Where the CTA hold starts: the voice and every caption end at or before it. -pub fn cta_start_ms() -> i32 { - return REEL_MS - CTA_HOLD_MS; -} - -pub fn frames_of(ms: i32) -> i32 { - return @divTrunc(ms * FPS, 1000); -} - -/// min(124, 940 / (len * 0.72)), floored, in integers: 940 * 1000 / (len * 720). -pub fn font_size(chars: i32) -> i32 { - if (chars <= 0) { - return CAPTION_FONT_MAX; - } - const fit : i32 = @divTrunc(CAPTION_WIDTH_PX * 1000, chars * CHAR_WIDTH_PERMILLE); - if (fit < CAPTION_FONT_MAX) { - return fit; - } - return CAPTION_FONT_MAX; -} - -/// When a word's caption appears, in reel time. -pub fn shown_from_ms(start_ms: i32) -> i32 { - return start_ms + VOICE_OFFSET_MS; -} - -/// When a word's caption goes, in reel time: min(next start, end + tail), shifted by the offset. -/// The last word has no next word and keeps its whole tail. -pub fn shown_until_ms(end_ms: i32, has_next: bool, next_start_ms: i32) -> i32 { - var until : i32 = end_ms + CAPTION_TAIL_MS; - if (has_next and next_start_ms < until) { - until = next_start_ms; - } - return until + VOICE_OFFSET_MS; -} - -/// The voice, offset, ends at or before the CTA hold starts. -pub fn voice_fits(voice_ms: i32) -> bool { - return voice_ms > 0 and VOICE_OFFSET_MS + voice_ms <= cta_start_ms(); -} - -/// The music's gain at a moment of the reel, in permille of full scale. -pub fn music_gain_permille(t_ms: i32) -> i32 { - if (t_ms <= 0 or t_ms >= REEL_MS) { - return 0; - } - var g : i32 = MUSIC_VOLUME_PERMILLE; - if (t_ms < MUSIC_FADE_IN_MS) { - g = @divTrunc(g * t_ms, MUSIC_FADE_IN_MS); - } - if (t_ms > MUSIC_FADE_OUT_START_MS) { - g = @divTrunc(g * (REEL_MS - t_ms), REEL_MS - MUSIC_FADE_OUT_START_MS); - } - return g; -} - -/// Every caption is shown for a positive time, and none is still up when the next appears. -pub fn captions_never_overlap(starts: [21]i32, ends: [21]i32) -> bool { - var i : i32 = 0; - while (i < WORD_COUNT) { - const k : usize = @intCast(i); - const has_next : bool = i + 1 < WORD_COUNT; - var next : i32 = 0; - if (has_next) { - next = starts[k + 1]; - } - const from : i32 = shown_from_ms(starts[k]); - const until : i32 = shown_until_ms(ends[k], has_next, next); - if (until <= from) { - return false; - } - if (has_next and until > shown_from_ms(next)) { - return false; - } - i = i + 1; - } - return true; -} - -/// The last caption goes at or before the CTA hold starts. -pub fn captions_end_before_cta(ends: [21]i32) -> bool { - const last : i32 = shown_until_ms(ends[@intCast(WORD_COUNT - 1)], false, 0); - return last <= cta_start_ms(); -} - -// ---- Tests ---------------------------------------------------------------------------------- - -test the_voice_ends_before_the_cta_hold { - assert voice_fits(VOICE_MS); - // 10.16 s of speech from 0.4 s ends at 10.56 s, 1.44 s before the hold - assert VOICE_OFFSET_MS + VOICE_MS == 10560; - // the longest voice the format takes is 11.6 s; one ms more runs into the hold - assert voice_fits(11600); - assert !voice_fits(11601); - assert !voice_fits(12000); - assert !voice_fits(0); - // the last word whisper heard ends inside the voice file - assert WORD_END_MS[20] <= VOICE_MS; -} - -test captions_never_overlap_on_the_lab_reel { - assert captions_never_overlap(WORD_START_MS, WORD_END_MS); - assert captions_end_before_cta(WORD_END_MS); - // the windows the lab drew, from fc.txt - assert shown_from_ms(WORD_START_MS[0]) == LAB_FIRST_SHOWN[0]; - assert shown_until_ms(WORD_END_MS[0], true, WORD_START_MS[1]) == LAB_FIRST_SHOWN[1]; - assert shown_from_ms(WORD_START_MS[6]) == LAB_SEVENTH_SHOWN[0]; - assert shown_until_ms(WORD_END_MS[6], true, WORD_START_MS[7]) == LAB_SEVENTH_SHOWN[1]; - assert shown_from_ms(WORD_START_MS[20]) == LAB_LAST_SHOWN[0]; - assert shown_until_ms(WORD_END_MS[20], false, 0) == LAB_LAST_SHOWN[1]; -} - -test a_word_shows_until_the_next_start_or_its_tail { - // a long pause after the word: the tail ends it (2000 + 350 < 3000) - assert shown_until_ms(2000, true, 3000) == 2350 + VOICE_OFFSET_MS; - // on the lab reel every pause was shorter than the tail (the longest, 340 ms), so only the - // last word kept its whole tail; 2440 + 350 runs past the next start, 2740 - assert shown_until_ms(2440, true, 2740) == 2740 + VOICE_OFFSET_MS; - // the next word comes sooner than the tail: it ends there - assert shown_until_ms(1120, true, 1440) == 1440 + VOICE_OFFSET_MS; - // a tie is the same either way - assert shown_until_ms(1000, true, 1350) == 1350 + VOICE_OFFSET_MS; -} - -test overlapping_timings_are_refused { - // a word that starts before the previous one: its window is not after the last - var starts : [21]i32 = WORD_START_MS; - starts[5] = starts[4] - 100; - assert !captions_never_overlap(starts, WORD_END_MS); - // a word with no time on screen is refused too - var ends : [21]i32 = WORD_END_MS; - starts = WORD_START_MS; - starts[3] = WORD_START_MS[2]; - assert !captions_never_overlap(starts, ends); - // a last word that runs past the hold - ends[20] = 12000; - assert !captions_end_before_cta(ends); -} - -test font_sizes_match_the_lab_reel { - var i : i32 = 0; - while (i < WORD_COUNT) { - const k : usize = @intCast(i); - assert font_size(WORD_CHARS[k]) == LAB_FONT_SIZE[k]; - i = i + 1; - } - // the cap holds to 10 characters (940000 / 7200 = 130); the formula takes over from 11 - assert font_size(10) == CAPTION_FONT_MAX; - assert font_size(11) == 118; - assert font_size(12) == 108; - assert font_size(20) == 65; -} - -test one_word_per_caption { - assert WORDS_PER_CAPTION == 1; - var extra : i32 = 0; - var i : i32 = 0; - while (i < WORD_COUNT) { - const w : i32 = CAPTION_WORDS[@intCast(i)]; - assert w >= 1 and w <= 2; - if (w > WORDS_PER_CAPTION) { - extra = extra + 1; - // the one recorded deviation is caption 7 - assert i == 6; - } - i = i + 1; - } - assert extra == 1; - assert CAPTION_COLOR == 0xFFFF00; -} - -test the_music_sits_under_the_voice_and_fades_out { - assert music_gain_permille(0) == 0; - assert music_gain_permille(300) == 50; - assert music_gain_permille(5000) == MUSIC_VOLUME_PERMILLE; - assert music_gain_permille(MUSIC_FADE_OUT_START_MS) == MUSIC_VOLUME_PERMILLE; - assert music_gain_permille(14300) == 50; - assert music_gain_permille(REEL_MS) == 0; - assert MUSIC_VOLUME_PERMILLE == 100; - assert VOICE_LUFS == -15; -} - -test the_frames_add_up { - // 360 deck frames, then the CTA card held for 90 - assert frames_of(cta_start_ms()) == 360; - assert frames_of(CTA_HOLD_MS) == 90; - assert frames_of(REEL_MS) == 450; -} - -// ---- Invariants ----------------------------------------------------------------------------- - -invariant the_caption_clears_the_slide_rows { - // the tallest caption, border and all, starts below the rows and ends inside the frame - assert CAPTION_Y - @divTrunc(CAPTION_FONT_MAX, 2) - CAPTION_BORDER_PX > ROWS_BOTTOM_Y; - assert CAPTION_Y + @divTrunc(CAPTION_FONT_MAX, 2) + CAPTION_BORDER_PX + CAPTION_SHADOW_Y_PX < FRAME_H; - assert ROWS_TOP_Y < ROWS_BOTTOM_Y; - assert CAPTION_WIDTH_PX < FRAME_W; -} - -invariant the_hold_fits_inside_the_reel { - assert CTA_HOLD_MS > 0 and CTA_HOLD_MS < REEL_MS; - assert VOICE_OFFSET_MS < cta_start_ms(); - assert MUSIC_FADE_OUT_START_MS > cta_start_ms() and MUSIC_FADE_OUT_START_MS < REEL_MS; -} - -invariant the_mix_is_quieter_than_full_scale { - assert VOICE_LUFS < 0 and VOICE_TRUE_PEAK_TENTHS_DB < 0; - assert LIMIT_PERMILLE < 1000 and MUSIC_VOLUME_PERMILLE < LIMIT_PERMILLE; - // the mix measured louder than the voice target, as music added on top of it should be - assert MIX_LUFS_TENTHS > VOICE_LUFS * 10; - assert SAMPLE_RATE == 48000; -} diff --git a/specs/claw/preview.t27 b/specs/claw/preview.t27 new file mode 100644 index 0000000000..3916785c4c --- /dev/null +++ b/specs/claw/preview.t27 @@ -0,0 +1,445 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/claw/preview.t27 -- the "T27F preview" deployment of a pull request, as law (issue #10282, +; lane t27f-preview of specs/claw/github_surface.t27, epic #9271). +; Goal. Every PR that changes a .t27 spec gets an environment on its PR page. The environment's +; link opens the T27F graph view (/t27f/graph/, gHashTag/trinity#1648) of the changed spec at the +; PR head: one spec, one graph, every node labelled exact / derived / suggested / unknown. No new +; renderer exists or is needed; the page already runs spec_graph_export.t27 and +; spec_graph_view.t27 as WebAssembly. +; -- +; What this file is. Pure logic over strings and numbers; it makes no API call. The App's token +; for the job is the row "t27f-preview" of github_surface.t27: deployments write, plus the +; contents read of the "spec-read" row (not imported here: gen inlines an imported module's bodies +; without rewriting their own `recipes::` calls, so a second `use` breaks the build). A deployment is two calls: POST /deployments (environment, +; ref, auto_merge=false, required_contexts=[]) and POST /deployments/{id}/statuses (state, +; environment_url, description). The caller composes the JSON; this file decides WHETHER and WHAT. +; -- +; The rules. +; - Which PRs: open, same-repository head (a fork head is another owner's bytes under our +; name), and at least one changed file that is a .t27 file under specs/. A deleted spec has no +; bytes at the head to draw, so the caller lists only added, modified and renamed files. +; - Name: t27f-preview-pr-. One environment per PR, so GitHub groups every commit's deployment +; of that PR under one entry on the PR page. +; - States (GitHub spells them): queued, in_progress, success, failure, inactive. Illegal moves +; are refused; inactive is final. +; - Supersede: a newer deployment of the same PR retires the older ones to inactive, and closing +; or merging the PR retires all of them. Two "success" lines next to each other would point at +; two different heads. +; - A URL is published only on success, and success only after the URL answered HTTP 200 (a +; probe the caller makes). A preview URL that does not resolve is worse than none. +; - The URL pins the repository, the head sha (40 hex digits) and the spec path, so it keeps +; showing the commit it was made for; a branch name would drift under it. +; -- +; Honest limit. This is the law of the lifecycle. It does not prove the page can draw a spec from +; an arbitrary repository and sha: at the time of writing /t27f/graph/ offers a fixed list of +; vendored specs and reads no query string. Until it does, no workflow may call this law's +; success path. ASCII only (L3). +; phi^2 + 1/phi^2 = 3 | TRINITY + +module ClawPreview; + +use claw::recipes; + +/// Deployment states. S_NONE is "no deployment yet" and also the answer to an illegal move, +/// kept far from the real states so a missed check cannot pass for a state. +pub const S_QUEUED : u8 = 0; +pub const S_IN_PROGRESS : u8 = 1; +pub const S_SUCCESS : u8 = 2; +pub const S_FAILURE : u8 = 3; +pub const S_INACTIVE : u8 = 4; +pub const S_NONE : u8 = 255; + +/// Events that move a deployment. +pub const E_QUEUE : u8 = 0; +pub const E_START : u8 = 1; +pub const E_PROBE_OK : u8 = 2; +pub const E_PROBE_FAIL : u8 = 3; +pub const E_SUPERSEDE : u8 = 4; +pub const E_CLOSE : u8 = 5; + +/// The environment name is this prefix and the PR number in decimal. +pub const ENV_PREFIX : str = "t27f-preview-pr-"; + +/// The page that draws a spec; the query string names the repository, the head sha and the path. +pub const PAGE : str = "https://t27.ai/t27f/graph/?"; + +/// GitHub limits a status description to 140 characters. +pub const DESCRIPTION_MAX : usize = 140; + +/// Length of a git sha in hex. +pub const SHA_LEN : usize = 40; + +/// The surface row this lane serves, in github_surface.t27. +pub const SURFACE : str = "t27f-preview"; + +/// The state's name as the API spells it; "" for anything else. +pub fn state_name(s : u8) -> str { + if (s == S_QUEUED) { return "queued"; } + if (s == S_IN_PROGRESS) { return "in_progress"; } + if (s == S_SUCCESS) { return "success"; } + if (s == S_FAILURE) { return "failure"; } + if (s == S_INACTIVE) { return "inactive"; } + return ""; +} + +/// The state after an event; S_NONE for a move that is not allowed. A new deployment starts from +/// S_NONE only. Retiring (supersede or close) is allowed from every live state, never twice. +pub fn next_state(s : u8, e : u8) -> u8 { + if (s == S_NONE) { + if (e == E_QUEUE) { return S_QUEUED; } + return S_NONE; + } + if (s == S_INACTIVE) { return S_NONE; } + if (e == E_SUPERSEDE) { return S_INACTIVE; } + if (e == E_CLOSE) { return S_INACTIVE; } + if (s == S_QUEUED) { + if (e == E_START) { return S_IN_PROGRESS; } + return S_NONE; + } + if (s == S_IN_PROGRESS) { + if (e == E_PROBE_OK) { return S_SUCCESS; } + if (e == E_PROBE_FAIL) { return S_FAILURE; } + return S_NONE; + } + return S_NONE; +} + +/// A state the PR page still shows as live, so a newer deployment must retire it. +pub fn is_live(s : u8) -> bool { + return s == S_QUEUED || s == S_IN_PROGRESS || s == S_SUCCESS || s == S_FAILURE; +} + +/// Only a probed success carries environment_url. Everything else leaves the field empty. +pub fn status_carries_url(s : u8) -> bool { + return s == S_SUCCESS; +} + +/// The id of a newer deployment of the same PR is larger; a stale webhook replay or a slow job +/// that finishes late is the older id and must not retire the newer one. +pub fn supersedes(newer_id : u64, older_id : u64) -> bool { + return newer_id > older_id; +} + +/// A description fits the API limit and is not empty. +pub fn description_ok(d : str) -> bool { + return d.len > 0 && d.len <= DESCRIPTION_MAX; +} + +/// Does `s` start with `p`? +pub fn starts_with(s : str, p : str) -> bool { + if (s.len < p.len) { return false; } + var i : usize = 0; + while (i < p.len) { + if (s[i] != p[i]) { return false; } + i += 1; + } + return true; +} + +/// Does `s` end with `p`? +pub fn ends_with(s : str, p : str) -> bool { + if (s.len < p.len) { return false; } + const off : usize = s.len - p.len; + var i : usize = 0; + while (i < p.len) { + if (s[off + i] != p[i]) { return false; } + i += 1; + } + return true; +} + +/// Is `path` a .t27 file under specs/? The name part must be non-empty (".t27" alone is not). +pub fn is_spec_path(path : str) -> bool { + return starts_with(path, "specs/") && ends_with(path, ".t27") && path.len > 10; +} + +/// How many lines of `list` are spec paths. `list` is the changed files, one path per line. +pub fn spec_paths_in(list : str) -> u32 { + var n : u32 = 0; + var start : usize = 0; + var i : usize = 0; + while (i <= list.len) { + if (i == list.len || list[i] == 10) { + if (i > start) { + const line : str = list[start..i]; + if (is_spec_path(line)) { n += 1; } + } + start = i + 1; + } + i += 1; + } + return n; +} + +/// Does this PR get a preview? Open, head in this repository, and at least one changed spec. +pub fn wants_preview(is_open : bool, same_repo : bool, changed : str) -> bool { + if (!is_open) { return false; } + if (!same_repo) { return false; } + return spec_paths_in(changed) > 0; +} + +/// Number of decimal digits of n (1 for 0). +pub fn digits_of(n : u32) -> usize { + var d : usize = 1; + var m : u32 = n; + while (m >= 10) { + m = m / 10; + d += 1; + } + return d; +} + +/// Is `name` exactly ENV_PREFIX followed by the decimal of the PR number? PR numbers start at 1. +pub fn env_name_ok(pr : u32, name : str) -> bool { + if (pr == 0) { return false; } + const d : usize = digits_of(pr); + if (name.len != ENV_PREFIX.len + d) { return false; } + if (!starts_with(name, ENV_PREFIX)) { return false; } + var p : u32 = 1; + var k : usize = 1; + while (k < d) { + p = p * 10; + k += 1; + } + var i : usize = 0; + var rest : u32 = pr; + while (i < d) { + const digit : u32 = rest / p; + if ((name[ENV_PREFIX.len + i]) as u32 != 48 + digit) { return false; } + rest = rest - digit * p; + if (p >= 10) { p = p / 10; } + i += 1; + } + return true; +} + +/// Index of the first occurrence of `needle` in `hay` at or after `from`; -1 when absent. +pub fn find_from(hay : str, needle : str, from : usize) -> i32 { + if (needle.len == 0) { return -1; } + if (hay.len < needle.len) { return -1; } + var i : usize = from; + while (i + needle.len <= hay.len) { + var j : usize = 0; + var same : bool = true; + while (j < needle.len) { + if (hay[i + j] != needle[j]) { same = false; } + j += 1; + } + if (same) { return (i) as i32; } + i += 1; + } + return -1; +} + +/// A lowercase hex digit. +pub fn is_hex(c : u8) -> bool { + return (c >= 48 && c <= 57) || (c >= 97 && c <= 102); +} + +/// Are the SHA_LEN bytes of `s` from `at` all lowercase hex, followed by the end or '&'? +pub fn sha_at(s : str, at : usize) -> bool { + if (at + SHA_LEN > s.len) { return false; } + var i : usize = 0; + while (i < SHA_LEN) { + if (!is_hex(s[at + i])) { return false; } + i += 1; + } + if (at + SHA_LEN == s.len) { return true; } + return s[at + SHA_LEN] == 38; +} + +/// A preview URL is the page, with repo=, a 40-hex sha= and path= in the query. A URL that does +/// not pin the commit is refused: a branch name would show tomorrow's spec under today's check. +pub fn url_ok(url : str) -> bool { + if (!starts_with(url, PAGE)) { return false; } + if (find_from(url, "repo=", PAGE.len) < 0) { return false; } + if (find_from(url, "path=specs/", PAGE.len) < 0) { return false; } + const at : i32 = find_from(url, "sha=", PAGE.len); + if (at < 0) { return false; } + return sha_at(url, ((at) as usize) + 4); +} + +/// May this status be sent? A state with a URL needs a URL that passed url_ok and a probe that +/// answered 200; every other state must carry no URL at all. +pub fn status_ok(s : u8, url : str, probe : u32) -> bool { + if (status_carries_url(s)) { + return probe == 200 && url_ok(url); + } + return url.len == 0; +} + +const SHA_A : str = "0123456789abcdef0123456789abcdef01234567"; +const SHA_UPPER : str = "0123456789ABCDEF0123456789abcdef01234567"; + +test environment_name_is_the_prefix_and_the_pr_number { + assert env_name_ok(7, "t27f-preview-pr-7") == true; + assert env_name_ok(10282, "t27f-preview-pr-10282") == true; + assert env_name_ok(100, "t27f-preview-pr-100") == true; + assert env_name_ok(100, "t27f-preview-pr-10") == false; + assert env_name_ok(100, "t27f-preview-pr-1000") == false; + assert env_name_ok(7, "t27f-preview-pr-8") == false; + assert env_name_ok(12, "t27f-preview-pr-21") == false; + assert env_name_ok(7, "t27f-preview-7") == false; + assert env_name_ok(7, "") == false; + assert env_name_ok(0, "t27f-preview-pr-0") == false; +} + +test digits_of_counts_decimal_digits { + assert digits_of(0) == 1; + assert digits_of(9) == 1; + assert digits_of(10) == 2; + assert digits_of(99999) == 5; + assert digits_of(100000) == 6; +} + +test the_lifecycle_walks_queued_to_success { + var s : u8 = next_state(S_NONE, E_QUEUE); + assert s == S_QUEUED; + s = next_state(s, E_START); + assert s == S_IN_PROGRESS; + s = next_state(s, E_PROBE_OK); + assert s == S_SUCCESS; + s = next_state(s, E_SUPERSEDE); + assert s == S_INACTIVE; + assert recipes::str_eq(state_name(s), "inactive") == true; +} + +test a_failed_probe_ends_in_failure_and_failure_can_retire { + var s : u8 = next_state(S_QUEUED, E_START); + s = next_state(s, E_PROBE_FAIL); + assert s == S_FAILURE; + assert next_state(s, E_CLOSE) == S_INACTIVE; +} + +test illegal_moves_are_refused { + // no skipping the probe, no success from queued, no restart of a finished deployment + assert next_state(S_QUEUED, E_PROBE_OK) == S_NONE; + assert next_state(S_QUEUED, E_PROBE_FAIL) == S_NONE; + assert next_state(S_IN_PROGRESS, E_START) == S_NONE; + assert next_state(S_SUCCESS, E_START) == S_NONE; + assert next_state(S_SUCCESS, E_PROBE_FAIL) == S_NONE; + assert next_state(S_FAILURE, E_PROBE_OK) == S_NONE; + assert next_state(S_NONE, E_START) == S_NONE; + assert next_state(S_NONE, E_SUPERSEDE) == S_NONE; +} + +test inactive_is_final { + for e in 0..6 { + assert next_state(S_INACTIVE, (e) as u8) == S_NONE; + } +} + +test every_live_state_can_be_retired_exactly_once { + for s in 0..4 { + assert is_live((s) as u8) == true; + assert next_state((s) as u8, E_SUPERSEDE) == S_INACTIVE; + assert next_state((s) as u8, E_CLOSE) == S_INACTIVE; + } + assert is_live(S_INACTIVE) == false; + assert is_live(S_NONE) == false; +} + +test only_the_newer_deployment_supersedes { + assert supersedes(200, 100) == true; + assert supersedes(100, 200) == false; + assert supersedes(100, 100) == false; +} + +test only_success_carries_a_url { + assert status_carries_url(S_SUCCESS) == true; + assert status_carries_url(S_QUEUED) == false; + assert status_carries_url(S_IN_PROGRESS) == false; + assert status_carries_url(S_FAILURE) == false; + assert status_carries_url(S_INACTIVE) == false; +} + +test spec_path_means_a_t27_file_under_specs { + assert is_spec_path("specs/claw/gates.t27") == true; + assert is_spec_path("specs/a.t27") == true; + assert is_spec_path("specs/.t27") == false; + assert is_spec_path("docs/specs/a.t27") == false; + assert is_spec_path("specs/claw/gates.md") == false; + assert is_spec_path("gen/c/claw/gates.c") == false; + assert is_spec_path("") == false; +} + +test spec_paths_are_counted_per_line { + assert spec_paths_in("") == 0; + assert spec_paths_in("README.md") == 0; + assert spec_paths_in("specs/claw/gates.t27") == 1; + assert spec_paths_in("README.md\nspecs/claw/gates.t27\nspecs/t27f/ui_plan.t27\n") == 2; + assert spec_paths_in("a.rs\n\n\nspecs/a/b.t27") == 1; +} + +test a_pr_gets_a_preview_only_when_open_same_repo_and_touching_a_spec { + assert wants_preview(true, true, "specs/claw/gates.t27\nREADME.md") == true; + assert wants_preview(false, true, "specs/claw/gates.t27") == false; + assert wants_preview(true, false, "specs/claw/gates.t27") == false; + assert wants_preview(true, true, "docs/a.md\nsrc/b.rs") == false; + assert wants_preview(true, true, "") == false; +} + +test the_url_must_pin_the_commit { + const good : str = "https://t27.ai/t27f/graph/?repo=gHashTag/t27&sha=0123456789abcdef0123456789abcdef01234567&path=specs/claw/gates.t27"; + assert url_ok(good) == true; + // a branch name instead of a sha + assert url_ok("https://t27.ai/t27f/graph/?repo=gHashTag/t27&sha=master&path=specs/claw/gates.t27") == false; + // 39 hex digits + assert url_ok("https://t27.ai/t27f/graph/?repo=gHashTag/t27&sha=0123456789abcdef0123456789abcdef0123456&path=specs/claw/gates.t27") == false; + // 41 hex digits run on + assert url_ok("https://t27.ai/t27f/graph/?repo=gHashTag/t27&sha=0123456789abcdef0123456789abcdef012345670&path=specs/claw/gates.t27") == false; + // upper-case hex is not what git prints + assert url_ok("https://t27.ai/t27f/graph/?repo=gHashTag/t27&sha=0123456789ABCDEF0123456789abcdef01234567&path=specs/claw/gates.t27") == false; + // missing repo, missing path, another host + assert url_ok("https://t27.ai/t27f/graph/?sha=0123456789abcdef0123456789abcdef01234567&path=specs/claw/gates.t27") == false; + assert url_ok("https://t27.ai/t27f/graph/?repo=gHashTag/t27&sha=0123456789abcdef0123456789abcdef01234567") == false; + assert url_ok("https://example.com/t27f/graph/?repo=gHashTag/t27&sha=0123456789abcdef0123456789abcdef01234567&path=specs/a.t27") == false; + assert url_ok("") == false; +} + +test sha_at_reads_exactly_forty_hex_digits { + assert sha_at(SHA_A, 0) == true; + assert sha_at(SHA_UPPER, 0) == false; + assert sha_at("x0123456789abcdef0123456789abcdef01234567", 1) == true; + assert sha_at("0123456789abcdef0123456789abcdef0123456", 0) == false; +} + +test find_from_finds_the_first_hit_after_the_start { + assert find_from("abcabc", "bc", 0) == 1; + assert find_from("abcabc", "bc", 2) == 4; + assert find_from("abcabc", "xx", 0) == -1; + assert find_from("ab", "abc", 0) == -1; + assert find_from("abc", "", 0) == -1; +} + +test a_status_is_sent_only_when_its_url_rule_holds { + const good : str = "https://t27.ai/t27f/graph/?repo=gHashTag/t27&sha=0123456789abcdef0123456789abcdef01234567&path=specs/claw/gates.t27"; + // success needs both a 200 probe and a pinned URL + assert status_ok(S_SUCCESS, good, 200) == true; + assert status_ok(S_SUCCESS, good, 404) == false; + assert status_ok(S_SUCCESS, good, 0) == false; + assert status_ok(S_SUCCESS, "https://t27.ai/t27f/graph/?repo=gHashTag/t27&sha=master&path=specs/a.t27", 200) == false; + assert status_ok(S_SUCCESS, "", 200) == false; + // every other state carries no URL + assert status_ok(S_IN_PROGRESS, "", 0) == true; + assert status_ok(S_IN_PROGRESS, good, 200) == false; + assert status_ok(S_FAILURE, "", 404) == true; + assert status_ok(S_FAILURE, good, 404) == false; + assert status_ok(S_INACTIVE, "", 0) == true; +} + +test descriptions_fit_the_api_limit { + assert description_ok("T27F graph of 2 changed specs") == true; + assert description_ok("") == false; + assert description_ok("01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789") == true; + assert description_ok("012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890") == false; +} + +test the_surface_slug_and_state_names_are_the_api_spelling { + assert recipes::str_eq(SURFACE, "t27f-preview") == true; + assert recipes::str_eq(state_name(S_QUEUED), "queued") == true; + assert recipes::str_eq(state_name(S_IN_PROGRESS), "in_progress") == true; + assert recipes::str_eq(state_name(S_SUCCESS), "success") == true; + assert recipes::str_eq(state_name(S_FAILURE), "failure") == true; + assert recipes::str_eq(state_name(S_NONE), "") == true; +}