From 238cd5e48e468476bc81725810fe2c7753d56e4f Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sun, 18 Oct 2026 18:27:47 +0700 Subject: [PATCH] claw: control grammar for @tri-claw comment commands (Closes #10395) specs/claw/control.t27 reads a comment: command_of takes the word after the first @tri-claw (status, land, batch, refresh, rerun, pause, resume, budget, help; anything else is none), association_of maps the GitHub author_association string to a u8 code, allowed() gives a bot nothing, OWNER/MEMBER/COLLABORATOR everything, anyone else help and status, and surface_of names the github_surface row each command mints. batch_numbers reads up to 100 PR numbers and stops at the first non-number. specs/claw/control_pins.t27 is the test-only cross-check against the github_surface table (gen-c lowers tests, and the table does not lower). 14 + 2 tests, 0 vacuous; mutation 94/96 killed, 2 equivalent survivors explained in place. gen/c/claw/control.c from t27c gen-c; both sealed. Co-Authored-By: Claude Opus 5.5 --- .trinity/seals/claw_ClawControl.json | 33 ++ .trinity/seals/claw_ClawControlPins.json | 41 ++ gen/c/claw/control.c | 701 +++++++++++++++++++++++ specs/claw/control.t27 | 505 ++++++++++++++++ specs/claw/control_pins.t27 | 71 +++ 5 files changed, 1351 insertions(+) create mode 100644 .trinity/seals/claw_ClawControl.json create mode 100644 .trinity/seals/claw_ClawControlPins.json create mode 100644 gen/c/claw/control.c create mode 100644 specs/claw/control.t27 create mode 100644 specs/claw/control_pins.t27 diff --git a/.trinity/seals/claw_ClawControl.json b/.trinity/seals/claw_ClawControl.json new file mode 100644 index 0000000000..c84b682651 --- /dev/null +++ b/.trinity/seals/claw_ClawControl.json @@ -0,0 +1,33 @@ +{ + "built_by": "t27c-bootstrap@0.5.2+9448ab4be", + "closure": [ + { + "spec": "specs/policy/l2_generation.t27", + "spec_hash": "sha256:bc3a2e8f4c939ade9d951c77e0736775efe57500746bcc4cc2496ca48985a64d" + } + ], + "config": "gen=c,rust,verilog,zig;tests=zig", + "gen_hash_c": "sha256:44ebc47523da6dfbe40d3d6a3c00e58acb4da7529e2554e14eba300d493ed4c3", + "gen_hash_rust": "sha256:814c1b70a48f02ae7b3685ebc79848e64189820f8ac5d53d63bdf20e5b32eed3", + "gen_hash_verilog": "sha256:2cc6c889d526c3348d365f767a2b6790d9e58adef6442fac220f60c8661fdc94", + "gen_hash_zig": "sha256:b1fbab4b74c7f2fb50898f4a1245049e331d5f4bcca115b0246b5457b5969b85", + "module": "ClawControl", + "ring": 12, + "seal_schema": 2, + "sealed_at": "2026-10-18T11:27:28Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:c44e7d71ed436da62a881ed047965a779a4bc05efd2aa6b86302f722e4914848", + "spec_path": "specs/claw/control.t27", + "tests": { + "failed": 0, + "failing": [], + "forced": false, + "passed": 14, + "total": 14, + "vacuous": 0 + }, + "toolchain": { + "test_runner": "sha256:1982ae948e24d00c5d221ccec12dfce1eb01204b53e37b0f857f93b16d64629d", + "zig": "0.16.0" + } +} \ No newline at end of file diff --git a/.trinity/seals/claw_ClawControlPins.json b/.trinity/seals/claw_ClawControlPins.json new file mode 100644 index 0000000000..de74ec330d --- /dev/null +++ b/.trinity/seals/claw_ClawControlPins.json @@ -0,0 +1,41 @@ +{ + "built_by": "t27c-bootstrap@0.5.2+9448ab4be", + "closure": [ + { + "spec": "specs/claw/control.t27", + "spec_hash": "sha256:c44e7d71ed436da62a881ed047965a779a4bc05efd2aa6b86302f722e4914848" + }, + { + "spec": "specs/claw/github_surface.t27", + "spec_hash": "sha256:6c4b153e698cfe91e964e3bd05ffb7f8c611ff5b4eb9535dff11e878303856ae" + }, + { + "spec": "specs/policy/l2_generation.t27", + "spec_hash": "sha256:bc3a2e8f4c939ade9d951c77e0736775efe57500746bcc4cc2496ca48985a64d" + } + ], + "config": "gen=c,rust,verilog,zig;tests=zig", + "gen_hash_c": "sha256:2a2d8a9b44f11f98e19bad35afd026623d3ec9102442d34e2f7555ec47acdaea", + "gen_hash_rust": "sha256:97bd4a291098516a5c34ef923b3c4a489d08938029dd9662f6f10a36217c0d33", + "gen_hash_verilog": "sha256:cedafc5a9a7c2b38b6affbb35a332f342090a245c22d6e45a062686452fdff94", + "gen_hash_zig": "sha256:b566e33ace5ba327418b2dea57ac4e14420edc5ff98b2f110709f726aaa95f32", + "module": "ClawControlPins", + "ring": 12, + "seal_schema": 2, + "sealed_at": "2026-10-18T11:27:31Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:7b19226974fb35e6027569a9f3dba4e100af252f0e76e47919c7776a01be016d", + "spec_path": "specs/claw/control_pins.t27", + "tests": { + "failed": 0, + "failing": [], + "forced": false, + "passed": 2, + "total": 2, + "vacuous": 0 + }, + "toolchain": { + "test_runner": "sha256:1982ae948e24d00c5d221ccec12dfce1eb01204b53e37b0f857f93b16d64629d", + "zig": "0.16.0" + } +} \ No newline at end of file diff --git a/gen/c/claw/control.c b/gen/c/claw/control.c new file mode 100644 index 0000000000..c52f1a3c41 --- /dev/null +++ b/gen/c/claw/control.c @@ -0,0 +1,701 @@ +/* ============================================================================ + Generated from t27 spec: ClawControl + DO NOT EDIT - generated by t27c gen-c + phi^2 + 1/phi^2 = 3 | TRINITY + ============================================================================ */ + +#include +#include +#include +#include +#define t27_assert(c, m) do { if (!(c)) { __builtin_trap(); } } while (0) +#define assert_eq(a, b) do { if ((a) != (b)) { __builtin_trap(); } } while (0) + +#ifndef CLAWCONTROL_H +#define CLAWCONTROL_H + +/* ------------------------------------------------------- + Constants + ------------------------------------------------------- */ + +#define CMD_NONE 0 +#define CMD_STATUS 1 +#define CMD_LAND 2 +#define CMD_BATCH 3 +#define CMD_REFRESH 4 +#define CMD_RERUN 5 +#define CMD_PAUSE 6 +#define CMD_RESUME 7 +#define CMD_BUDGET 8 +#define CMD_HELP 9 +#define COMMANDS 10 +#define MENTION "@tri-claw$" +#define COMMAND_WORDS "status,land,batch,refresh,rerun,pause,resume,budget,help,$" +#define AS_UNKNOWN 0 +#define AS_OWNER 1 +#define AS_MEMBER 2 +#define AS_COLLABORATOR 3 +#define AS_CONTRIBUTOR 4 +#define AS_FIRST_TIME_CONTRIBUTOR 5 +#define AS_FIRST_TIMER 6 +#define AS_MANNEQUIN 7 +#define AS_NONE 8 +#define ASSOCIATIONS "OWNER,MEMBER,COLLABORATOR,CONTRIBUTOR,FIRST_TIME_CONTRIBUTOR,FIRST_TIMER,MANNEQUIN,NONE,$" +#define ANS_IGNORE 0 +#define ANS_ACT 1 +#define ANS_HELP 2 +#define SF_NONE 0 +#define SF_MERGE_PR 1 +#define SF_UPDATE_BRANCH 2 +#define SF_RERUN_FAILED 3 +#define SF_CONTROL_COMMENTS 4 +#define SF_QUEUE_DISPATCH 5 +#define SF_COUNT 6 +#define SURFACE_ROWS "merge-pr,update-branch,rerun-failed,control-comments,queue-dispatch,$" +static const uint8_t COMMAND_SURFACE[10] = { SF_NONE, SF_NONE, SF_MERGE_PR, SF_MERGE_PR, SF_UPDATE_BRANCH, SF_RERUN_FAILED, SF_CONTROL_COMMENTS, SF_CONTROL_COMMENTS, SF_NONE, SF_NONE }; +static const bool ON_THIS_PR[10] = { false, false, true, false, true, true, false, false, false, false }; +#define BATCH_MAX 100 +#define NUMBER_DIGITS 9 +#define BY_USE 0 +#define NOT_FOUND 65535 +#define SPEC_EXT ".t27$" +#define SPEC_EXT_LEN 4 +#define SPEC_ROOT "specs/$" +#define SPEC_ROOT_LEN 6 +#define LIST_END 36 +#define LIST_SEP 44 + +/* ------------------------------------------------------- + Function prototypes + ------------------------------------------------------- */ + +size_t mention_end(uint8_t* text, size_t len); +size_t skip_spaces(uint8_t* text, size_t len, size_t from); +size_t word_end(uint8_t* text, size_t len, size_t from); +uint8_t command_of(uint8_t* text, size_t len); +uint8_t association_of(uint8_t* s, size_t len); +bool allowed(uint8_t command, uint8_t association, bool from_a_bot); +uint8_t answer_of(uint8_t command, uint8_t association, bool from_a_bot); +uint8_t surface_of(uint8_t command); +bool acts_on_this_pr(uint8_t command); +size_t put_surface(uint8_t sf, uint8_t* out, size_t cap); +size_t skip_gaps(uint8_t* text, size_t len, size_t from); +size_t batch_numbers(uint8_t* text, size_t len, uint32_t* out, size_t cap); +bool ends_with(uint8_t* s, size_t from, size_t to, uint8_t* m); +size_t entry_start(uint8_t* list, size_t idx); +bool is_piece(uint8_t* s, size_t from, size_t to, uint8_t* m); +bool is_spec_of(uint8_t* buf, size_t lf, size_t lt, size_t sf, size_t st); +size_t list_index(uint8_t* list, uint8_t* s, size_t from, size_t to); +size_t list_len(uint8_t* list); +bool names(uint8_t* buf, uint8_t how, size_t pf, size_t pt, size_t xf, size_t xt); +size_t piece_len(uint8_t* m); +size_t put_entry(uint8_t* out, size_t w, size_t cap, uint8_t* list, size_t idx); +bool starts_with(uint8_t* s, size_t from, size_t to, uint8_t* m); +bool use_names(uint8_t* buf, size_t ef, size_t et, size_t pf, size_t pt); +uint8_t lower(uint8_t c); +size_t put(uint8_t* out, size_t w, size_t cap, uint8_t c); + +/* ------------------------------------------------------- + Function implementations + ------------------------------------------------------- */ + +size_t mention_end(uint8_t* text, size_t len) { + size_t at = 0; + while ((at < len)) { + if (starts_with(text, at, len, MENTION)) { + return (at + piece_len(MENTION)); + } + at += 1; + } + return 0; +} + +size_t skip_spaces(uint8_t* text, size_t len, size_t from) { + size_t k = from; + while (((k < len) && (text[k] == ' '))) { + k += 1; + } + return k; +} + +size_t word_end(uint8_t* text, size_t len, size_t from) { + size_t e = from; + while (((((e < len) && (text[e] != ' ')) && (text[e] != 10)) && (text[e] != 13))) { + e += 1; + } + return e; +} + +uint8_t command_of(uint8_t* text, size_t len) { + size_t m = mention_end(text, len); + if ((m == 0)) { + return CMD_NONE; + } + size_t k = skip_spaces(text, len, m); + size_t i = list_index(COMMAND_WORDS, text, k, word_end(text, len, k)); + if ((i == NOT_FOUND)) { + return CMD_NONE; + } + return ((uint8_t)((i + 1))); +} + +uint8_t association_of(uint8_t* s, size_t len) { + size_t i = list_index(ASSOCIATIONS, s, 0, len); + if ((i == NOT_FOUND)) { + return AS_UNKNOWN; + } + return ((uint8_t)((i + 1))); +} + +bool allowed(uint8_t command, uint8_t association, bool from_a_bot) { + if (from_a_bot) { + return false; + } + if (((command == CMD_NONE) || (command >= COMMANDS))) { + return false; + } + if (((association >= AS_OWNER) && (association <= AS_COLLABORATOR))) { + return true; + } + return ((command == CMD_HELP) || (command == CMD_STATUS)); +} + +uint8_t answer_of(uint8_t command, uint8_t association, bool from_a_bot) { + if (((from_a_bot || (command == CMD_NONE)) || (command >= COMMANDS))) { + return ANS_IGNORE; + } + if (allowed(command, association, from_a_bot)) { + return ANS_ACT; + } + return ANS_HELP; +} + +uint8_t surface_of(uint8_t command) { + if ((command >= COMMANDS)) { + return SF_NONE; + } + return COMMAND_SURFACE[((size_t)(command))]; +} + +bool acts_on_this_pr(uint8_t command) { + if ((command >= COMMANDS)) { + return false; + } + return ON_THIS_PR[((size_t)(command))]; +} + +size_t put_surface(uint8_t sf, uint8_t* out, size_t cap) { + if ((sf == SF_NONE)) { + return 0; + } + return put_entry(out, 0, cap, SURFACE_ROWS, ((size_t)((sf - 1)))); +} + +size_t skip_gaps(uint8_t* text, size_t len, size_t from) { + size_t k = from; + while (((k < len) && ((text[k] == ' ') || (text[k] == ',')))) { + k += 1; + } + return k; +} + +size_t batch_numbers(uint8_t* text, size_t len, uint32_t* out, size_t cap) { + if ((command_of(text, len) != CMD_BATCH)) { + return 0; + } + size_t most = BATCH_MAX; + if ((cap < most)) { + most = cap; + } + size_t p = word_end(text, len, skip_spaces(text, len, mention_end(text, len))); + size_t n = 0; + while ((n < most)) { + p = skip_gaps(text, len, p); + if (((p < len) && (text[p] == '#'))) { + p += 1; + } + uint32_t v = 0; + size_t d = 0; + while (((((p < len) && (d < NUMBER_DIGITS)) && (text[p] >= '0')) && (text[p] <= '9'))) { + v = ((v * 10) + ((uint32_t)((text[p] - '0')))); + p += 1; + d += 1; + } + if (((d == 0) || (v == 0))) { + return n; + } + if ((((((p < len) && (text[p] != ' ')) && (text[p] != ',')) && (text[p] != 10)) && (text[p] != 13))) { + return n; + } + out[n] = v; + n += 1; + } + return n; +} + +bool ends_with(uint8_t* s, size_t from, size_t to, uint8_t* m) { + size_t k = piece_len(m); + if (((to - from) < k)) { + return false; + } + return starts_with(s, (to - k), to, m); +} + +size_t entry_start(uint8_t* list, size_t idx) { + size_t i = 0; + size_t n = 0; + while (((n < idx) && (list[i] != LIST_END))) { + if ((list[i] == LIST_SEP)) { + n += 1; + } + i += 1; + } + return i; +} + +bool is_piece(uint8_t* s, size_t from, size_t to, uint8_t* m) { + return (((to - from) == piece_len(m)) && starts_with(s, from, to, m)); +} + +bool is_spec_of(uint8_t* buf, size_t lf, size_t lt, size_t sf, size_t st) { + if (((lt - lf) != (((st - sf) + SPEC_ROOT_LEN) + SPEC_EXT_LEN))) { + return false; + } + if ((starts_with(buf, lf, lt, SPEC_ROOT) == false)) { + return false; + } + if ((ends_with(buf, lf, lt, SPEC_EXT) == false)) { + return false; + } + size_t k = 0; + while ((k < (st - sf))) { + if ((buf[((lf + SPEC_ROOT_LEN) + k)] != buf[(sf + k)])) { + return false; + } + k += 1; + } + return true; +} + +size_t list_index(uint8_t* list, uint8_t* s, size_t from, size_t to) { + size_t i = 0; + size_t idx = 0; + while ((list[i] != LIST_END)) { + size_t k = from; + bool ok = true; + while (((list[i] != LIST_SEP) && (list[i] != LIST_END))) { + if ((k >= to)) { + ok = false; + } else if ((s[k] != list[i])) { + ok = false; + } + k += 1; + i += 1; + } + if ((ok && (k == to))) { + return idx; + } + if ((list[i] == LIST_SEP)) { + i += 1; + } + idx += 1; + } + return NOT_FOUND; +} + +size_t list_len(uint8_t* list) { + size_t i = 0; + size_t n = 0; + while ((list[i] != LIST_END)) { + if ((list[i] == LIST_SEP)) { + n += 1; + } + i += 1; + } + return n; +} + +bool names(uint8_t* buf, uint8_t how, size_t pf, size_t pt, size_t xf, size_t xt) { + if ((how == BY_USE)) { + return use_names(buf, xf, xt, pf, pt); + } + return is_spec_of(buf, pf, pt, xf, xt); +} + +size_t piece_len(uint8_t* m) { + size_t i = 0; + while ((m[i] != LIST_END)) { + i += 1; + } + return i; +} + +size_t put_entry(uint8_t* out, size_t w, size_t cap, uint8_t* list, size_t idx) { + size_t p = w; + size_t i = entry_start(list, idx); + while (((list[i] != LIST_SEP) && (list[i] != LIST_END))) { + p = put(out, p, cap, list[i]); + i += 1; + } + return p; +} + +bool starts_with(uint8_t* s, size_t from, size_t to, uint8_t* m) { + size_t i = 0; + while ((m[i] != LIST_END)) { + if (((from + i) >= to)) { + return false; + } + if ((s[(from + i)] != m[i])) { + return false; + } + i += 1; + } + return true; +} + +bool use_names(uint8_t* buf, size_t ef, size_t et, size_t pf, size_t pt) { + if (((pt - pf) <= (SPEC_ROOT_LEN + SPEC_EXT_LEN))) { + return false; + } + if ((starts_with(buf, pf, pt, SPEC_ROOT) == false)) { + return false; + } + if ((ends_with(buf, pf, pt, SPEC_EXT) == false)) { + return false; + } + size_t me = (pt - SPEC_EXT_LEN); + size_t k = (pf + SPEC_ROOT_LEN); + size_t i = ef; + bool gap = false; + while ((i < et)) { + uint8_t c = buf[i]; + if (((c == '.') || (((c == ':') && ((i + 1) < et)) && (buf[(i + 1)] == ':')))) { + if ((k > (pf + SPEC_ROOT_LEN))) { + gap = true; + } + if ((c == ':')) { + i += 1; + } + i += 1; + } else { + if (gap) { + if ((buf[k] != '/')) { + return false; + } + k += 1; + gap = false; + } + if ((buf[k] != c)) { + return false; + } + k += 1; + i += 1; + } + } + return (k == me); +} + +uint8_t lower(uint8_t c) { + if (((c >= 'A') && (c <= 'Z'))) { + return (c + 32); + } + return c; +} + +size_t put(uint8_t* out, size_t w, size_t cap, uint8_t c) { + if ((w < cap)) { + out[w] = c; + } + return (w + 1); +} + +/* ------------------------------------------------------- + Tests + ------------------------------------------------------- */ + +void test_every_command_is_read_after_the_mention(void) { + assert((command_of("@tri-claw status", 16) == CMD_STATUS)); + assert((command_of("@tri-claw land", 14) == CMD_LAND)); + assert((command_of("@tri-claw batch", 15) == CMD_BATCH)); + assert((command_of("@tri-claw refresh", 17) == CMD_REFRESH)); + assert((command_of("@tri-claw rerun", 15) == CMD_RERUN)); + assert((command_of("@tri-claw pause", 15) == CMD_PAUSE)); + assert((command_of("@tri-claw resume", 16) == CMD_RESUME)); + assert((command_of("@tri-claw budget", 16) == CMD_BUDGET)); + assert((command_of("@tri-claw help", 14) == CMD_HELP)); + assert((command_of("please @tri-claw land now", 27) == CMD_LAND)); + assert((command_of("@tri-claw help\nthanks", 21) == CMD_HELP)); + uint8_t b[16] = {0}; + const char* head = "@tri-claw rerun"; + size_t w = 0; + while ((w < 15)) { + b[w] = head[w]; + w += 1; + } + b[15] = 13; + assert((command_of(&b, 16) == CMD_RERUN)); +} + +void test_no_mention_or_an_unknown_word_is_no_command(void) { + assert((command_of("land", 4) == CMD_NONE)); + assert((command_of("", 0) == CMD_NONE)); + assert((command_of("@tri-claw dance", 15) == CMD_NONE)); + assert((command_of("@tri-claw lands", 15) == CMD_NONE)); + assert((command_of("@tri-claw land.", 15) == CMD_NONE)); + assert((command_of("@tri-claw\tland", 14) == CMD_NONE)); + assert((command_of("@tri-clow land", 14) == CMD_NONE)); + assert((command_of("@TRI-CLAW land", 14) == CMD_NONE)); + assert((command_of("ask @tri-cl", 11) == CMD_NONE)); + assert((command_of("@tri-claw ", 12) == CMD_NONE)); + assert((command_of("@tri-claw", 9) == CMD_NONE)); + assert((command_of("@tri-claw land", 13) == CMD_NONE)); +} + +void test_only_the_first_mention_counts(void) { + assert((command_of("@tri-claw dance, then @tri-claw land", 36) == CMD_NONE)); + assert((command_of("@tri-claw status and @tri-claw land", 35) == CMD_STATUS)); + assert((mention_end("hi @tri-claw land", 17) == 12)); + assert((mention_end("@tri-claw", 9) == 9)); + assert((mention_end("no mention here", 15) == 0)); +} + +void test_author_association_is_read_whole(void) { + assert((association_of("OWNER", 5) == AS_OWNER)); + assert((association_of("MEMBER", 6) == AS_MEMBER)); + assert((association_of("COLLABORATOR", 12) == AS_COLLABORATOR)); + assert((association_of("CONTRIBUTOR", 11) == AS_CONTRIBUTOR)); + assert((association_of("FIRST_TIME_CONTRIBUTOR", 22) == AS_FIRST_TIME_CONTRIBUTOR)); + assert((association_of("FIRST_TIMER", 11) == AS_FIRST_TIMER)); + assert((association_of("MANNEQUIN", 9) == AS_MANNEQUIN)); + assert((association_of("NONE", 4) == AS_NONE)); + assert((association_of("OWNERS", 6) == AS_UNKNOWN)); + assert((association_of("OWNE", 4) == AS_UNKNOWN)); + assert((association_of("owner", 5) == AS_UNKNOWN)); + assert((association_of("FIRST_TIME", 10) == AS_UNKNOWN)); + assert((association_of("", 0) == AS_UNKNOWN)); +} + +void test_a_bot_gets_nothing(void) { + uint8_t c = 0; + while ((c < COMMANDS)) { + assert((allowed(c, AS_OWNER, true) == false)); + assert((allowed(c, AS_NONE, true) == false)); + assert((answer_of(c, AS_OWNER, true) == ANS_IGNORE)); + assert((answer_of(c, AS_NONE, true) == ANS_IGNORE)); + c += 1; + } +} + +void test_owner_member_and_collaborator_may_say_any_command(void) { + uint8_t a = AS_OWNER; + while ((a <= AS_COLLABORATOR)) { + uint8_t c = CMD_STATUS; + while ((c < COMMANDS)) { + assert((allowed(c, a, false) == true)); + assert((answer_of(c, a, false) == ANS_ACT)); + c += 1; + } + assert((allowed(CMD_NONE, a, false) == false)); + assert((allowed(COMMANDS, a, false) == false)); + assert((answer_of(CMD_NONE, a, false) == ANS_IGNORE)); + assert((answer_of(COMMANDS, a, false) == ANS_IGNORE)); + a += 1; + } +} + +void test_anyone_else_gets_help_and_status_only(void) { + uint8_t a = AS_CONTRIBUTOR; + while ((a <= (AS_NONE + 1))) { + assert((allowed(CMD_HELP, a, false) == true)); + assert((allowed(CMD_STATUS, a, false) == true)); + assert((answer_of(CMD_HELP, a, false) == ANS_ACT)); + assert((answer_of(CMD_STATUS, a, false) == ANS_ACT)); + uint8_t c = CMD_LAND; + while ((c <= CMD_BUDGET)) { + assert((allowed(c, a, false) == false)); + assert((answer_of(c, a, false) == ANS_HELP)); + c += 1; + } + a += 1; + } + assert((allowed(CMD_LAND, AS_UNKNOWN, false) == false)); + assert((allowed(CMD_HELP, AS_UNKNOWN, false) == true)); + assert((answer_of(CMD_PAUSE, AS_UNKNOWN, false) == ANS_HELP)); +} + +void test_the_three_scenarios_of_the_issue(void) { + uint8_t land = command_of("@tri-claw land", 14); + assert((land == CMD_LAND)); + assert((answer_of(land, association_of("OWNER", 5), false) == ANS_ACT)); + assert((surface_of(land) == SF_MERGE_PR)); + uint8_t pause = command_of("@tri-claw pause", 15); + assert((pause == CMD_PAUSE)); + assert((allowed(pause, association_of("FIRST_TIME_CONTRIBUTOR", 22), false) == false)); + assert((answer_of(pause, association_of("FIRST_TIME_CONTRIBUTOR", 22), false) == ANS_HELP)); + uint8_t own = command_of("@tri-claw help -- commands: status, land", 40); + assert((own == CMD_HELP)); + assert((answer_of(own, AS_NONE, true) == ANS_IGNORE)); +} + +void test_each_command_mints_its_row_or_none(void) { + assert((surface_of(CMD_NONE) == SF_NONE)); + assert((surface_of(CMD_STATUS) == SF_NONE)); + assert((surface_of(CMD_LAND) == SF_MERGE_PR)); + assert((surface_of(CMD_BATCH) == SF_MERGE_PR)); + assert((surface_of(CMD_REFRESH) == SF_UPDATE_BRANCH)); + assert((surface_of(CMD_RERUN) == SF_RERUN_FAILED)); + assert((surface_of(CMD_PAUSE) == SF_CONTROL_COMMENTS)); + assert((surface_of(CMD_RESUME) == SF_CONTROL_COMMENTS)); + assert((surface_of(CMD_BUDGET) == SF_NONE)); + assert((surface_of(CMD_HELP) == SF_NONE)); + assert((surface_of(COMMANDS) == SF_NONE)); + assert((surface_of(255) == SF_NONE)); +} + +void test_every_command_that_mints_writes_a_row_name(void) { + uint8_t c = CMD_NONE; + size_t minting = 0; + while ((c < COMMANDS)) { + uint8_t b[32] = {0}; + size_t n = put_surface(surface_of(c), &b, 32); + if ((surface_of(c) != SF_NONE)) { + assert((n > 0)); + minting += 1; + } + if ((surface_of(c) == SF_NONE)) { + assert((n == 0)); + } + c += 1; + } + assert((minting == 6)); +} + +void test_the_row_names_are_written_whole(void) { + assert((list_len(SURFACE_ROWS) == 5)); + assert(((list_index(SURFACE_ROWS, "merge-pr", 0, 8) + 1) == SF_MERGE_PR)); + assert(((list_index(SURFACE_ROWS, "update-branch", 0, 13) + 1) == SF_UPDATE_BRANCH)); + assert(((list_index(SURFACE_ROWS, "rerun-failed", 0, 12) + 1) == SF_RERUN_FAILED)); + assert(((list_index(SURFACE_ROWS, "control-comments", 0, 16) + 1) == SF_CONTROL_COMMENTS)); + assert(((list_index(SURFACE_ROWS, "queue-dispatch", 0, 14) + 1) == SF_QUEUE_DISPATCH)); + uint8_t b[32] = {0}; + size_t n1 = put_surface(SF_MERGE_PR, &b, 32); + assert((is_piece(&b, 0, n1, "merge-pr$") == true)); + size_t n4 = put_surface(SF_CONTROL_COMMENTS, &b, 32); + assert((is_piece(&b, 0, n4, "control-comments$") == true)); + size_t n5 = put_surface(SF_QUEUE_DISPATCH, &b, 32); + assert((is_piece(&b, 0, n5, "queue-dispatch$") == true)); + assert((put_surface(surface_of(CMD_REFRESH), &b, 32) == 13)); + assert((is_piece(&b, 0, 13, "update-branch$") == true)); + assert((put_surface(surface_of(CMD_RERUN), &b, 32) == 12)); + assert((is_piece(&b, 0, 12, "rerun-failed$") == true)); + assert((put_surface(SF_NONE, &b, 32) == 0)); + assert((put_surface(SF_COUNT, &b, 32) == 0)); + assert((put_surface(255, &b, 32) == 0)); + assert((put_surface(SF_CONTROL_COMMENTS, &b, 4) == 16)); +} + +void test_land_refresh_and_rerun_act_on_this_pr_only(void) { + assert((acts_on_this_pr(CMD_LAND) == true)); + assert((acts_on_this_pr(CMD_REFRESH) == true)); + assert((acts_on_this_pr(CMD_RERUN) == true)); + assert((acts_on_this_pr(CMD_NONE) == false)); + assert((acts_on_this_pr(CMD_STATUS) == false)); + assert((acts_on_this_pr(CMD_BATCH) == false)); + assert((acts_on_this_pr(CMD_PAUSE) == false)); + assert((acts_on_this_pr(CMD_RESUME) == false)); + assert((acts_on_this_pr(CMD_BUDGET) == false)); + assert((acts_on_this_pr(CMD_HELP) == false)); + assert((acts_on_this_pr(COMMANDS) == false)); +} + +void test_batch_reads_numbers_until_the_first_non_number(void) { + uint32_t out[8] = {0}; + assert((batch_numbers("@tri-claw batch 12 345, #6,7", 28, &out, 8) == 4)); + assert((out[0] == 12)); + assert((out[1] == 345)); + assert((out[2] == 6)); + assert((out[3] == 7)); + assert((batch_numbers("@tri-claw batch #10406 30,100", 29, &out, 8) == 3)); + assert((out[0] == 10406)); + assert((out[1] == 30)); + assert((out[2] == 100)); + assert((batch_numbers("@tri-claw batch 5 please 9", 26, &out, 8) == 1)); + assert((out[0] == 5)); + assert((batch_numbers("@tri-claw batch 5\n9", 19, &out, 8) == 1)); + uint8_t t[20] = {0}; + const char* head = "@tri-claw batch 5 9"; + size_t w = 0; + while ((w < 19)) { + t[w] = head[w]; + w += 1; + } + t[17] = 13; + assert((batch_numbers(&t, 19, &out, 8) == 1)); + assert((out[0] == 5)); + t[16] = 13; + assert((batch_numbers(&t, 19, &out, 8) == 0)); + assert((batch_numbers("@tri-claw batch 0 4", 19, &out, 8) == 0)); + assert((batch_numbers("@tri-claw batch # 4", 19, &out, 8) == 0)); + assert((batch_numbers("@tri-claw batch 4x 5", 20, &out, 8) == 0)); + assert((batch_numbers("@tri-claw batch 1234567890", 26, &out, 8) == 0)); + assert((batch_numbers("@tri-claw batch 123456789", 25, &out, 8) == 1)); + assert((out[0] == 123456789)); + assert((batch_numbers("@tri-claw batch", 15, &out, 8) == 0)); + assert((batch_numbers("@tri-claw batch 8 , ", 20, &out, 8) == 1)); + assert((batch_numbers("@tri-claw land 12", 17, &out, 8) == 0)); + assert((batch_numbers("batch 12", 8, &out, 8) == 0)); + assert((batch_numbers("@tri-claw batch 1 2 3 4", 23, &out, 3) == 3)); + assert((out[2] == 3)); +} + +void test_batch_reads_at_most_a_hundred_numbers(void) { + uint8_t t[256] = {0}; + const char* head = "@tri-claw batch"; + size_t w = 0; + while ((w < 15)) { + t[w] = head[w]; + w += 1; + } + size_t k = 0; + while ((k < 101)) { + t[w] = ' '; + t[(w + 1)] = '7'; + w += 2; + k += 1; + } + uint32_t out[128] = {0}; + assert((batch_numbers(&t, w, &out, 128) == BATCH_MAX)); + assert((out[99] == 7)); + assert((batch_numbers(&t, (w - 2), &out, 128) == BATCH_MAX)); + assert((batch_numbers(&t, (w - 4), &out, 128) == 99)); +} + + +/* ------------------------------------------------------- + Test runner (compile with -DT27_TEST_MAIN to execute) + ------------------------------------------------------- */ + +#ifdef T27_TEST_MAIN +#include +int main(void) { + test_every_command_is_read_after_the_mention(); + test_no_mention_or_an_unknown_word_is_no_command(); + test_only_the_first_mention_counts(); + test_author_association_is_read_whole(); + test_a_bot_gets_nothing(); + test_owner_member_and_collaborator_may_say_any_command(); + test_anyone_else_gets_help_and_status_only(); + test_the_three_scenarios_of_the_issue(); + test_each_command_mints_its_row_or_none(); + test_every_command_that_mints_writes_a_row_name(); + test_the_row_names_are_written_whole(); + test_land_refresh_and_rerun_act_on_this_pr_only(); + test_batch_reads_numbers_until_the_first_non_number(); + test_batch_reads_at_most_a_hundred_numbers(); + printf("All %d tests passed.\n", 14); + return 0; +} +#endif /* T27_TEST_MAIN */ + +#endif /* CLAWCONTROL_H */ diff --git a/specs/claw/control.t27 b/specs/claw/control.t27 new file mode 100644 index 0000000000..88ba607543 --- /dev/null +++ b/specs/claw/control.t27 @@ -0,0 +1,505 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/claw/control.t27 -- the @tri-claw comment grammar: what a person may ask the TRI CLAW App, +; who may ask it, and which row of specs/claw/github_surface.t27 the answer mints (#10395, lane 2 of +; epic #10393). +; Owner, 2026-10-18 (translated): move the queue and dispatch into our GitHub App, TRI CLAW, and +; control it with @tri-claw comments; the App runs in GitHub CI, so dispatch gets faster. +; -- +; Same discipline as command_of in specs/queen/app.t27: a '$'-terminated comma list walked byte by +; byte, the command is the word after the FIRST mention, and no mention is no command. The walkers +; are policy::l2_generation's (starts_with, piece_len, list_index, put_entry); nothing is copied. +; -- +; The caller is the issue_comment job of lane 7 (#10400), through gen/c/claw/control.c. It hands over +; the comment body and its length, author_association as GitHub spells it, and whether the sender is +; a bot, and gets back u8 codes: +; command_of the command (CMD_*), CMD_NONE when there is none +; association_of the author_association string as an AS_* code +; answer_of IGNORE, ACT or HELP (refused, so only help is offered) +; surface_of the github_surface row the action mints (SF_*), SF_NONE when it mints nothing +; put_surface that row's name, the string the job asks the token for +; batch_numbers the PR numbers after "batch" +; -- +; Who: OWNER, MEMBER and COLLABORATOR may say any command; anyone else gets help and status only; a +; bot gets nothing, so the App never answers its own comment and two bots cannot loop. +; Where: pause and resume are queue-wide; land, refresh and rerun act on the pull request the comment +; is on; batch takes PR numbers after the word (none: the job's own choice of the ready set). +; -- +; The surface names below are rows of github_surface.t27 (lane 1, #10394). specs/claw/control_pins.t27 +; walks that table's own rows and checks each SF code names exactly one of them and that it is +; mintable. The check lives there, a test-only companion never passed to gen-c, because gen-c lowers +; tests too and the table's STATUS row and str .len do not lower to C; control.c stays standalone. +; pause and resume mint control-comments (issues write): the answer is a comment, and the pause flag +; must be one an issues token can hold, such as a label on the queue dashboard issue. A repository +; variable would need actions_variables write, which no row of the table grants. +; ASCII only (L3). phi^2 + 1/phi^2 = 3 | TRINITY + +module ClawControl; + +use policy::l2_generation; + +/// Commands. Index in COMMAND_WORDS + 1 = the code; CMD_NONE is no command. +/// status what the queue holds and what it is doing +/// land merge the pull request the comment is on, once it is green +/// batch merge the PRs named after the word, together +/// refresh bring the pull request up to date with its base +/// rerun rerun the failed jobs of the pull request's last run +/// pause stop the queue (queue-wide); resume undoes it +/// budget what today's PR and run budget has left +/// help list the commands +pub const CMD_NONE : u8 = 0; +pub const CMD_STATUS : u8 = 1; +pub const CMD_LAND : u8 = 2; +pub const CMD_BATCH : u8 = 3; +pub const CMD_REFRESH : u8 = 4; +pub const CMD_RERUN : u8 = 5; +pub const CMD_PAUSE : u8 = 6; +pub const CMD_RESUME : u8 = 7; +pub const CMD_BUDGET : u8 = 8; +pub const CMD_HELP : u8 = 9; +pub const COMMANDS : u8 = 10; + +pub const MENTION : str = "@tri-claw$"; +pub const COMMAND_WORDS : str = "status,land,batch,refresh,rerun,pause,resume,budget,help,$"; + +/// author_association, as GitHub spells it. Index in ASSOCIATIONS + 1 = the code; a string GitHub +/// may add later is AS_UNKNOWN, which is treated as a stranger. +pub const AS_UNKNOWN : u8 = 0; +pub const AS_OWNER : u8 = 1; +pub const AS_MEMBER : u8 = 2; +pub const AS_COLLABORATOR : u8 = 3; +pub const AS_CONTRIBUTOR : u8 = 4; +pub const AS_FIRST_TIME_CONTRIBUTOR : u8 = 5; +pub const AS_FIRST_TIMER : u8 = 6; +pub const AS_MANNEQUIN : u8 = 7; +pub const AS_NONE : u8 = 8; +pub const ASSOCIATIONS : str = "OWNER,MEMBER,COLLABORATOR,CONTRIBUTOR,FIRST_TIME_CONTRIBUTOR,FIRST_TIMER,MANNEQUIN,NONE,$"; + +/// What the job does with a comment. +/// IGNORE nothing: a bot wrote it, or it holds no command +/// ACT the command is allowed: do it and answer +/// HELP the command is refused: answer with help only +pub const ANS_IGNORE : u8 = 0; +pub const ANS_ACT : u8 = 1; +pub const ANS_HELP : u8 = 2; + +/// Rows of specs/claw/github_surface.t27 a command can mint. Index in SURFACE_ROWS + 1 = the code. +/// queue-dispatch is the queue's own row (it tells the Queen there is work); no comment mints it. +pub const SF_NONE : u8 = 0; +pub const SF_MERGE_PR : u8 = 1; +pub const SF_UPDATE_BRANCH : u8 = 2; +pub const SF_RERUN_FAILED : u8 = 3; +pub const SF_CONTROL_COMMENTS : u8 = 4; +pub const SF_QUEUE_DISPATCH : u8 = 5; +pub const SF_COUNT : u8 = 6; +pub const SURFACE_ROWS : str = "merge-pr,update-branch,rerun-failed,control-comments,queue-dispatch,$"; + +/// The row each command mints, by command code. +pub const COMMAND_SURFACE : [10]u8 = [ + SF_NONE, SF_NONE, SF_MERGE_PR, SF_MERGE_PR, SF_UPDATE_BRANCH, + SF_RERUN_FAILED, SF_CONTROL_COMMENTS, SF_CONTROL_COMMENTS, SF_NONE, SF_NONE, +]; + +/// Whether the command acts on the pull request the comment is on (so it is refused on an issue). +pub const ON_THIS_PR : [10]bool = [ + false, false, true, false, true, + true, false, false, false, false, +]; + +/// At most this many PR numbers are read after "batch". +pub const BATCH_MAX : usize = 100; +/// A PR number has at most this many digits; a longer run of digits is not a number. +pub const NUMBER_DIGITS : usize = 9; + +/// Index just past the first mention in text[0..len]; 0 when there is none (a mention is never +/// empty, so 0 is never the end of one). (Mutating `at < len` to `at <= len` is an equivalent +/// mutant: at `at == len` starts_with refuses at once, its first bound check.) +pub fn mention_end(text: []const u8, len: usize) -> usize { + var at : usize = 0; + while (at < len) { + if (l2_generation::starts_with(text, at, len, MENTION)) { + return at + l2_generation::piece_len(MENTION); + } + at += 1; + } + return 0; +} + +/// The first byte at or after `from` that is not a space. +pub fn skip_spaces(text: []const u8, len: usize, from: usize) -> usize { + var k : usize = from; + while (k < len and text[k] == ' ') { k += 1; } + return k; +} + +/// The end of the word that starts at `from`: the first space, newline or carriage return, or len. +pub fn word_end(text: []const u8, len: usize, from: usize) -> usize { + var e : usize = from; + while (e < len and text[e] != ' ' and text[e] != 10 and text[e] != 13) { e += 1; } + return e; +} + +/// The command in a comment: the word after the first mention, whole. No mention, an unknown word or +/// a word that only starts like a command is CMD_NONE. +pub fn command_of(text: []const u8, len: usize) -> u8 { + const m : usize = mention_end(text, len); + if (m == 0) { return CMD_NONE; } + const k : usize = skip_spaces(text, len, m); + const i : usize = l2_generation::list_index(COMMAND_WORDS, text, k, word_end(text, len, k)); + if (i == l2_generation::NOT_FOUND) { return CMD_NONE; } + return (i + 1) as u8; +} + +/// The AS_* code of author_association s[0..len], matched whole. +pub fn association_of(s: []const u8, len: usize) -> u8 { + const i : usize = l2_generation::list_index(ASSOCIATIONS, s, 0, len); + if (i == l2_generation::NOT_FOUND) { return AS_UNKNOWN; } + return (i + 1) as u8; +} + +/// May this author give this command? A bot never; OWNER, MEMBER and COLLABORATOR always; anyone +/// else help and status only. No command, or a code past the table, is never allowed. +pub fn allowed(command: u8, association: u8, from_a_bot: bool) -> bool { + if (from_a_bot) { return false; } + if (command == CMD_NONE or command >= COMMANDS) { return false; } + if (association >= AS_OWNER and association <= AS_COLLABORATOR) { return true; } + return command == CMD_HELP or command == CMD_STATUS; +} + +/// What the job does: IGNORE for a bot or no command, ACT when allowed, HELP when refused. +pub fn answer_of(command: u8, association: u8, from_a_bot: bool) -> u8 { + if (from_a_bot or command == CMD_NONE or command >= COMMANDS) { return ANS_IGNORE; } + if (allowed(command, association, from_a_bot)) { return ANS_ACT; } + return ANS_HELP; +} + +/// The github_surface row the command's action mints; SF_NONE for help, status, budget, no command +/// and a code past the table. +pub fn surface_of(command: u8) -> u8 { + if (command >= COMMANDS) { return SF_NONE; } + return COMMAND_SURFACE[(command) as usize]; +} + +/// Does the command act on the pull request the comment is on? +pub fn acts_on_this_pr(command: u8) -> bool { + if (command >= COMMANDS) { return false; } + return ON_THIS_PR[(command) as usize]; +} + +/// Write the row name of surface code sf to out; the bytes it needs, 0 for SF_NONE or a code past +/// the table (put_entry writes nothing for an index past the list's '$'). The caller checks the +/// result against cap. +pub fn put_surface(sf: u8, out: []u8, cap: usize) -> usize { + if (sf == SF_NONE) { return 0; } + return l2_generation::put_entry(out, 0, cap, SURFACE_ROWS, (sf - 1) as usize); +} + +/// The first byte at or after `from` that is neither a space nor a comma. +pub fn skip_gaps(text: []const u8, len: usize, from: usize) -> usize { + var k : usize = from; + while (k < len and (text[k] == ' ' or text[k] == ',')) { k += 1; } + return k; +} + +/// The PR numbers after "@tri-claw batch", written to out[0..]; how many. Numbers are separated by +/// spaces or commas and may carry a '#'. Reading stops at the first thing that is not a number (a +/// word, a newline, a 0, more than NUMBER_DIGITS digits, digits glued to a letter), after BATCH_MAX +/// numbers, or when out[0..cap] is full. A comment whose command is not batch reads none. +/// (Mutating `cap < most` to `cap <= most` is an equivalent mutant: at cap == most both keep most.) +pub fn batch_numbers(text: []const u8, len: usize, out: []u32, cap: usize) -> usize { + if (command_of(text, len) != CMD_BATCH) { return 0; } + var most : usize = BATCH_MAX; + if (cap < most) { most = cap; } + var p : usize = word_end(text, len, skip_spaces(text, len, mention_end(text, len))); + var n : usize = 0; + while (n < most) { + p = skip_gaps(text, len, p); + if (p < len and text[p] == '#') { p += 1; } + var v : u32 = 0; + var d : usize = 0; + while (p < len and d < NUMBER_DIGITS and text[p] >= '0' and text[p] <= '9') { + v = v * 10 + ((text[p] - '0') as u32); + p += 1; + d += 1; + } + if (d == 0 or v == 0) { return n; } + if (p < len and text[p] != ' ' and text[p] != ',' and text[p] != 10 and text[p] != 13) { return n; } + out[n] = v; + n += 1; + } + return n; +} + +test every_command_is_read_after_the_mention { + assert command_of("@tri-claw status", 16) == CMD_STATUS; + assert command_of("@tri-claw land", 14) == CMD_LAND; + assert command_of("@tri-claw batch", 15) == CMD_BATCH; + assert command_of("@tri-claw refresh", 17) == CMD_REFRESH; + assert command_of("@tri-claw rerun", 15) == CMD_RERUN; + assert command_of("@tri-claw pause", 15) == CMD_PAUSE; + assert command_of("@tri-claw resume", 16) == CMD_RESUME; + assert command_of("@tri-claw budget", 16) == CMD_BUDGET; + assert command_of("@tri-claw help", 14) == CMD_HELP; + // spaces before the word, text after it on the same line or the next + assert command_of("please @tri-claw land now", 27) == CMD_LAND; + assert command_of("@tri-claw help\nthanks", 21) == CMD_HELP; + // a carriage return ends the word too ("\r" in a literal is two bytes, so it is written as 13) + var b : [16]u8 = undefined; + const head : str = "@tri-claw rerun"; + var w : usize = 0; + while (w < 15) { + b[w] = head[w]; + w += 1; + } + b[15] = 13; + assert command_of(&b, 16) == CMD_RERUN; +} + +test no_mention_or_an_unknown_word_is_no_command { + assert command_of("land", 4) == CMD_NONE; + assert command_of("", 0) == CMD_NONE; + assert command_of("@tri-claw dance", 15) == CMD_NONE; + assert command_of("@tri-claw lands", 15) == CMD_NONE; + assert command_of("@tri-claw land.", 15) == CMD_NONE; + assert command_of("@tri-claw\tland", 14) == CMD_NONE; + assert command_of("@tri-clow land", 14) == CMD_NONE; + assert command_of("@TRI-CLAW land", 14) == CMD_NONE; + // the text ends inside the mention, in the spaces after it, or right after it + assert command_of("ask @tri-cl", 11) == CMD_NONE; + assert command_of("@tri-claw ", 12) == CMD_NONE; + assert command_of("@tri-claw", 9) == CMD_NONE; + // len is the end: a command past it is not read + assert command_of("@tri-claw land", 13) == CMD_NONE; +} + +test only_the_first_mention_counts { + assert command_of("@tri-claw dance, then @tri-claw land", 36) == CMD_NONE; + assert command_of("@tri-claw status and @tri-claw land", 35) == CMD_STATUS; + assert mention_end("hi @tri-claw land", 17) == 12; + assert mention_end("@tri-claw", 9) == 9; + assert mention_end("no mention here", 15) == 0; +} + +test author_association_is_read_whole { + assert association_of("OWNER", 5) == AS_OWNER; + assert association_of("MEMBER", 6) == AS_MEMBER; + assert association_of("COLLABORATOR", 12) == AS_COLLABORATOR; + assert association_of("CONTRIBUTOR", 11) == AS_CONTRIBUTOR; + assert association_of("FIRST_TIME_CONTRIBUTOR", 22) == AS_FIRST_TIME_CONTRIBUTOR; + assert association_of("FIRST_TIMER", 11) == AS_FIRST_TIMER; + assert association_of("MANNEQUIN", 9) == AS_MANNEQUIN; + assert association_of("NONE", 4) == AS_NONE; + // a prefix, a longer word, another case or nothing is not an association + assert association_of("OWNERS", 6) == AS_UNKNOWN; + assert association_of("OWNE", 4) == AS_UNKNOWN; + assert association_of("owner", 5) == AS_UNKNOWN; + assert association_of("FIRST_TIME", 10) == AS_UNKNOWN; + assert association_of("", 0) == AS_UNKNOWN; +} + +test a_bot_gets_nothing { + var c : u8 = 0; + while (c < COMMANDS) { + assert allowed(c, AS_OWNER, true) == false; + assert allowed(c, AS_NONE, true) == false; + assert answer_of(c, AS_OWNER, true) == ANS_IGNORE; + assert answer_of(c, AS_NONE, true) == ANS_IGNORE; + c += 1; + } +} + +test owner_member_and_collaborator_may_say_any_command { + var a : u8 = AS_OWNER; + while (a <= AS_COLLABORATOR) { + var c : u8 = CMD_STATUS; + while (c < COMMANDS) { + assert allowed(c, a, false) == true; + assert answer_of(c, a, false) == ANS_ACT; + c += 1; + } + // no command, or a code past the table, is never allowed + assert allowed(CMD_NONE, a, false) == false; + assert allowed(COMMANDS, a, false) == false; + assert answer_of(CMD_NONE, a, false) == ANS_IGNORE; + assert answer_of(COMMANDS, a, false) == ANS_IGNORE; + a += 1; + } +} + +test anyone_else_gets_help_and_status_only { + var a : u8 = AS_CONTRIBUTOR; + while (a <= AS_NONE + 1) { + // AS_NONE + 1 is a string GitHub may add later; AS_UNKNOWN is checked after the loop + assert allowed(CMD_HELP, a, false) == true; + assert allowed(CMD_STATUS, a, false) == true; + assert answer_of(CMD_HELP, a, false) == ANS_ACT; + assert answer_of(CMD_STATUS, a, false) == ANS_ACT; + var c : u8 = CMD_LAND; + while (c <= CMD_BUDGET) { + assert allowed(c, a, false) == false; + assert answer_of(c, a, false) == ANS_HELP; + c += 1; + } + a += 1; + } + assert allowed(CMD_LAND, AS_UNKNOWN, false) == false; + assert allowed(CMD_HELP, AS_UNKNOWN, false) == true; + assert answer_of(CMD_PAUSE, AS_UNKNOWN, false) == ANS_HELP; +} + +test the_three_scenarios_of_the_issue { + // the owner comments `@tri-claw land` on a green PR: LAND, allowed, surface merge-pr + const land : u8 = command_of("@tri-claw land", 14); + assert land == CMD_LAND; + assert answer_of(land, association_of("OWNER", 5), false) == ANS_ACT; + assert surface_of(land) == SF_MERGE_PR; + // a first-time contributor comments `@tri-claw pause`: refused, help only + const pause : u8 = command_of("@tri-claw pause", 15); + assert pause == CMD_PAUSE; + assert allowed(pause, association_of("FIRST_TIME_CONTRIBUTOR", 22), false) == false; + assert answer_of(pause, association_of("FIRST_TIME_CONTRIBUTOR", 22), false) == ANS_HELP; + // the App's own comment mentions @tri-claw: nothing happens + const own : u8 = command_of("@tri-claw help -- commands: status, land", 40); + assert own == CMD_HELP; + assert answer_of(own, AS_NONE, true) == ANS_IGNORE; +} + +test each_command_mints_its_row_or_none { + assert surface_of(CMD_NONE) == SF_NONE; + assert surface_of(CMD_STATUS) == SF_NONE; + assert surface_of(CMD_LAND) == SF_MERGE_PR; + assert surface_of(CMD_BATCH) == SF_MERGE_PR; + assert surface_of(CMD_REFRESH) == SF_UPDATE_BRANCH; + assert surface_of(CMD_RERUN) == SF_RERUN_FAILED; + assert surface_of(CMD_PAUSE) == SF_CONTROL_COMMENTS; + assert surface_of(CMD_RESUME) == SF_CONTROL_COMMENTS; + assert surface_of(CMD_BUDGET) == SF_NONE; + assert surface_of(CMD_HELP) == SF_NONE; + assert surface_of(COMMANDS) == SF_NONE; + assert surface_of(255) == SF_NONE; +} + +test every_command_that_mints_writes_a_row_name { + // which row each name is, and that it is mintable, is control_pins.t27's cross-check + var c : u8 = CMD_NONE; + var minting : usize = 0; + while (c < COMMANDS) { + var b : [32]u8 = undefined; + const n : usize = put_surface(surface_of(c), &b, 32); + if (surface_of(c) != SF_NONE) { + assert n > 0; + minting += 1; + } + if (surface_of(c) == SF_NONE) { assert n == 0; } + c += 1; + } + assert minting == 6; +} + +test the_row_names_are_written_whole { + assert l2_generation::list_len(SURFACE_ROWS) == 5; + assert l2_generation::list_index(SURFACE_ROWS, "merge-pr", 0, 8) + 1 == SF_MERGE_PR; + assert l2_generation::list_index(SURFACE_ROWS, "update-branch", 0, 13) + 1 == SF_UPDATE_BRANCH; + assert l2_generation::list_index(SURFACE_ROWS, "rerun-failed", 0, 12) + 1 == SF_RERUN_FAILED; + assert l2_generation::list_index(SURFACE_ROWS, "control-comments", 0, 16) + 1 == SF_CONTROL_COMMENTS; + assert l2_generation::list_index(SURFACE_ROWS, "queue-dispatch", 0, 14) + 1 == SF_QUEUE_DISPATCH; + // put_surface writes the name the token is minted for, and nothing for no row + var b : [32]u8 = undefined; + const n1 : usize = put_surface(SF_MERGE_PR, &b, 32); + assert l2_generation::is_piece(&b, 0, n1, "merge-pr$") == true; + const n4 : usize = put_surface(SF_CONTROL_COMMENTS, &b, 32); + assert l2_generation::is_piece(&b, 0, n4, "control-comments$") == true; + const n5 : usize = put_surface(SF_QUEUE_DISPATCH, &b, 32); + assert l2_generation::is_piece(&b, 0, n5, "queue-dispatch$") == true; + assert put_surface(surface_of(CMD_REFRESH), &b, 32) == 13; + assert l2_generation::is_piece(&b, 0, 13, "update-branch$") == true; + assert put_surface(surface_of(CMD_RERUN), &b, 32) == 12; + assert l2_generation::is_piece(&b, 0, 12, "rerun-failed$") == true; + assert put_surface(SF_NONE, &b, 32) == 0; + assert put_surface(SF_COUNT, &b, 32) == 0; + assert put_surface(255, &b, 32) == 0; + // a short buffer still reports the bytes the name needs + assert put_surface(SF_CONTROL_COMMENTS, &b, 4) == 16; +} + +test land_refresh_and_rerun_act_on_this_pr_only { + assert acts_on_this_pr(CMD_LAND) == true; + assert acts_on_this_pr(CMD_REFRESH) == true; + assert acts_on_this_pr(CMD_RERUN) == true; + assert acts_on_this_pr(CMD_NONE) == false; + assert acts_on_this_pr(CMD_STATUS) == false; + assert acts_on_this_pr(CMD_BATCH) == false; + assert acts_on_this_pr(CMD_PAUSE) == false; + assert acts_on_this_pr(CMD_RESUME) == false; + assert acts_on_this_pr(CMD_BUDGET) == false; + assert acts_on_this_pr(CMD_HELP) == false; + assert acts_on_this_pr(COMMANDS) == false; +} + +test batch_reads_numbers_until_the_first_non_number { + var out : [8]u32 = undefined; + assert batch_numbers("@tri-claw batch 12 345, #6,7", 28, &out, 8) == 4; + assert out[0] == 12; + assert out[1] == 345; + assert out[2] == 6; + assert out[3] == 7; + // a 0 inside a number is a digit + assert batch_numbers("@tri-claw batch #10406 30,100", 29, &out, 8) == 3; + assert out[0] == 10406; + assert out[1] == 30; + assert out[2] == 100; + // a word stops it; the number after the word is not read + assert batch_numbers("@tri-claw batch 5 please 9", 26, &out, 8) == 1; + assert out[0] == 5; + // a newline stops it + assert batch_numbers("@tri-claw batch 5\n9", 19, &out, 8) == 1; + // a carriage return ends a number as well (written as 13: "\r" in a literal is two bytes) + var t : [20]u8 = undefined; + const head : str = "@tri-claw batch 5 9"; + var w : usize = 0; + while (w < 19) { + t[w] = head[w]; + w += 1; + } + t[17] = 13; + assert batch_numbers(&t, 19, &out, 8) == 1; + assert out[0] == 5; + t[16] = 13; + assert batch_numbers(&t, 19, &out, 8) == 0; + // a 0, a bare '#', digits glued to a letter and a ten-digit run are not numbers + assert batch_numbers("@tri-claw batch 0 4", 19, &out, 8) == 0; + assert batch_numbers("@tri-claw batch # 4", 19, &out, 8) == 0; + assert batch_numbers("@tri-claw batch 4x 5", 20, &out, 8) == 0; + assert batch_numbers("@tri-claw batch 1234567890", 26, &out, 8) == 0; + assert batch_numbers("@tri-claw batch 123456789", 25, &out, 8) == 1; + assert out[0] == 123456789; + // no numbers, trailing gaps, and a command that is not batch + assert batch_numbers("@tri-claw batch", 15, &out, 8) == 0; + assert batch_numbers("@tri-claw batch 8 , ", 20, &out, 8) == 1; + assert batch_numbers("@tri-claw land 12", 17, &out, 8) == 0; + assert batch_numbers("batch 12", 8, &out, 8) == 0; + // the out buffer bounds it + assert batch_numbers("@tri-claw batch 1 2 3 4", 23, &out, 3) == 3; + assert out[2] == 3; +} + +test batch_reads_at_most_a_hundred_numbers { + var t : [256]u8 = undefined; + const head : str = "@tri-claw batch"; + var w : usize = 0; + while (w < 15) { + t[w] = head[w]; + w += 1; + } + var k : usize = 0; + while (k < 101) { + t[w] = ' '; + t[w + 1] = '7'; + w += 2; + k += 1; + } + var out : [128]u32 = undefined; + assert batch_numbers(&t, w, &out, 128) == BATCH_MAX; + assert out[99] == 7; + assert batch_numbers(&t, w - 2, &out, 128) == BATCH_MAX; + assert batch_numbers(&t, w - 4, &out, 128) == 99; +} diff --git a/specs/claw/control_pins.t27 b/specs/claw/control_pins.t27 new file mode 100644 index 0000000000..f65a8cb556 --- /dev/null +++ b/specs/claw/control_pins.t27 @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/claw/control_pins.t27 -- the cross-check of specs/claw/control.t27 against the surface table +; of specs/claw/github_surface.t27 (#10395, lane 2 of epic #10393; the rows are lane 1's, #10394). +; control.t27 names the row each command mints by its own codes (SF_*) and a '$'-terminated list of +; row names. Nothing in control.t27 can tell whether those names are rows of the table: a name typed +; wrong there would be written to the token request and refused only at mint time. This module walks +; the table's own rows, so a name that is not a row, is a row twice, or is a row that cannot be minted +; fails here. +; It is a test-only companion, never passed to gen-c, like gates_pins.t27: gen-c lowers tests as well, +; and the table's STATUS row and str .len do not lower to C, so the check cannot sit in control.t27 +; without breaking gen/c/claw/control.c. +; The walk's bound is the table's 27 rows as a literal: the bare name COUNT is declared both in +; github_surface.t27 and in recipes.t27, and use resolution splices neither. A table with fewer rows +; makes SURFACES[i] panic here; a table with more is a change to this bound, and uniqueness of the +; names is the table's own test. (Name no table function in prose here: use resolution reads +; identifiers out of comments too, and splices what it finds.) +; ASCII only (L3). phi^2 + 1/phi^2 = 3 | TRINITY + +module ClawControlPins; + +use claw::control; +use claw::github_surface; +use policy::l2_generation; + +test every_surface_is_one_mintable_row_of_github_surface { + var sf : u8 = control::SF_MERGE_PR; + var rows : usize = 0; + while (sf < control::SF_COUNT) { + var found : usize = 0; + var i : usize = 0; + while (i < 27) { + const name : str = github_surface::SURFACES[i]; + if (l2_generation::list_index(control::SURFACE_ROWS, name, 0, name.len) + 1 == (sf) as usize) { + assert github_surface::row_mintable(i, false) == true; + found += 1; + } + i += 1; + } + assert found == 1; + rows += found; + sf += 1; + } + assert rows == 5; +} + +test every_command_that_mints_lands_on_one_mintable_row { + // From the command side: each command's surface is one mintable row of the table, by name. + // (put_surface's bytes are control.t27's own test; it is not called here, as use resolution + // does not carry its inner l2_generation call into this module.) + var c : u8 = control::CMD_NONE; + var minting : usize = 0; + while (c < control::COMMANDS) { + const sf : u8 = control::surface_of(c); + if (sf != control::SF_NONE) { + var hits : usize = 0; + var i : usize = 0; + while (i < 27) { + const name : str = github_surface::SURFACES[i]; + if (l2_generation::list_index(control::SURFACE_ROWS, name, 0, name.len) + 1 == (sf) as usize) { + assert github_surface::row_mintable(i, false) == true; + hits += 1; + } + i += 1; + } + assert hits == 1; + minting += 1; + } + c += 1; + } + assert minting == 6; +}