@a2f0/agent-tool 0.1.49 → 0.1.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Heuristic signals of implicit state machines in one source file. Read-only
3
+ * and line-based: it points a reader at files worth reading, it never decides.
4
+ */
5
+
6
+ type SignalKind = "flags" | "status";
7
+
8
+ export interface Signal {
9
+ readonly kind: SignalKind;
10
+ /** The flag names, or the status field name. */
11
+ readonly names: string[];
12
+ /** For status signals, the distinct string values assigned. */
13
+ readonly values?: string[];
14
+ /** 1-based lines where the flags are toggled or the status is assigned. */
15
+ readonly lines: number[];
16
+ }
17
+
18
+ export interface FileReport {
19
+ readonly path: string;
20
+ readonly score: number;
21
+ readonly signals: Signal[];
22
+ }
23
+
24
+ /** Source extensions the scan reads, by the comment syntax their lines use. */
25
+ const LANGUAGES: Record<string, "c" | "hash"> = {
26
+ ".ts": "c",
27
+ ".tsx": "c",
28
+ ".mts": "c",
29
+ ".cts": "c",
30
+ ".js": "c",
31
+ ".jsx": "c",
32
+ ".mjs": "c",
33
+ ".cjs": "c",
34
+ ".swift": "c",
35
+ ".kt": "c",
36
+ ".kts": "c",
37
+ ".go": "c",
38
+ ".rs": "c",
39
+ ".java": "c",
40
+ ".dart": "c",
41
+ ".py": "hash",
42
+ ".rb": "hash",
43
+ ".sh": "hash",
44
+ ".bash": "hash",
45
+ ".zsh": "hash",
46
+ ".exp": "hash",
47
+ };
48
+
49
+ /** Interpreters whose extensionless scripts the scan reads, by their shebang. */
50
+ const INTERPRETER = /^(?:sh|bash|zsh|dash|ksh|python[\d.]*|ruby|expect|tclsh[\d.]*)$/;
51
+
52
+ /**
53
+ * Whether a first line is a shebang for an interpreter the scan reads:
54
+ * `#!/bin/sh`, `#!/usr/bin/env -S python3 -u`, `#!/usr/bin/expect -f`. Parsed
55
+ * token by token (no backtracking regex) and bounded, since the line is
56
+ * untrusted input of any length.
57
+ */
58
+ function readsShebang(firstLine: string): boolean {
59
+ if (!firstLine.startsWith("#!")) return false;
60
+ const tokens = firstLine.slice(2, 512).trim().split(/\s+/);
61
+ const base = (token: string | undefined) => token?.slice(token.lastIndexOf("/") + 1) ?? "";
62
+ let index = 0;
63
+ if (base(tokens[0]) === "env") {
64
+ index = 1;
65
+ while (index < tokens.length && (tokens[index]!.startsWith("-") || tokens[index]!.includes("="))) index++;
66
+ }
67
+ return INTERPRETER.test(base(tokens[index]));
68
+ }
69
+
70
+ /** Largest file the scan reads; bigger tracked files are data or generated. */
71
+ export const MAX_BYTES = 512 * 1024;
72
+
73
+ /** Paths that are generated, vendored, tests, or agent skills rather than application code. */
74
+ const SKIPPED = [
75
+ /(^|\/)(node_modules|vendor|dist|build|out|coverage|Pods|DerivedData|\.next|\.turbo|generated|__generated__)\//,
76
+ /(^|\/)\.(agents|claude|codex|opencode)\//,
77
+ /(^|\/)(__tests__|__mocks__|e2e|fixtures?|test|tests)\//,
78
+ /\.(test|spec|stories|e2e)\.[a-z]+$/,
79
+ /_test\.(go|py)$/,
80
+ /(^|\/)(test_[^/]*|conftest)\.py$/,
81
+ /(Tests?|Spec)\.(swift|kt|java)$/,
82
+ /\.d\.ts$/,
83
+ /\.min\.[a-z]+$/,
84
+ ];
85
+
86
+ /**
87
+ * The comment syntax of a source file, from its extension; an extensionless
88
+ * file (`bin/deploy`, `files/functions`) counts when its first line is a
89
+ * shell, Python, Ruby or Expect shebang. Without `firstLine`, extensionless
90
+ * files report "maybe" so a caller knows to read the first line.
91
+ */
92
+ export function language(file: string, firstLine?: string): "c" | "hash" | "maybe" | undefined {
93
+ if (SKIPPED.some(pattern => pattern.test(file))) return undefined;
94
+ const base = file.slice(file.lastIndexOf("/") + 1);
95
+ const dot = base.lastIndexOf(".");
96
+ if (dot > 0) return LANGUAGES[base.slice(dot).toLowerCase()];
97
+ if (firstLine === undefined) return "maybe";
98
+ return readsShebang(firstLine) ? "hash" : undefined;
99
+ }
100
+
101
+ /**
102
+ * Names that read as a lifecycle or mode flag rather than a setting: `isLoading`,
103
+ * `hasError`, `_running`, `submitting`, `show_dialog`. Settings such as
104
+ * `darkMode` or `enabled` toggled by a user are usually independent, so a
105
+ * bare `enabled`/`visible` does not count on its own. Nor do `cancelled` and
106
+ * `settled`: effect cleanups and promise races set them once as guards.
107
+ */
108
+ const FLAG =
109
+ /^_*(?:(?:is|has|was|did|should|show|needs?|can)(?:[A-Z_].*)?|(?:loading|pending|running|started|starting|stopping|stopped|done|finished|complete|completed|ready|busy|saving|submitting|fetching|syncing|connecting|connected|reconnecting|initiali[sz]ed|initiali[sz]ing|cancell?ing|failed|retrying|paused|waiting|polling|booting|editing|reserving|paging|rendered|rendering|unavailable|interrupted|dismissed|opened|published|uploading|downloading|refreshing|processing|in_?progress|inflight|in_?flight)(?:[A-Z_].*)?)$/;
110
+
111
+ /** Field names that hold a mode as a string. */
112
+ const STATUS = /^_*(?:status|state|phase|mode|stage|step|lifecycle|connectionState|syncState|loadState|[a-z]+(?:Status|State|Phase|Stage))$/;
113
+
114
+ /**
115
+ * The flag or field an assignment target names: the last member of a chain
116
+ * (`record.steps[i]!.status` -> `status`, `this.#rendered` -> `rendered`), or a
117
+ * React ref's own name (`busy.current` -> `busy`).
118
+ */
119
+ function targetName(owner: string | undefined, member: string): string {
120
+ const name = member.replace(/^#/, "");
121
+ return name === "current" && owner ? owner : name;
122
+ }
123
+
124
+ function isFlag(name: string): boolean {
125
+ return FLAG.test(name) && !/^(?:is|has|was|did|should|show|needs?|can)$/.test(name);
126
+ }
127
+
128
+ /** `setIsLoading` -> `isLoading`; `set_loading` stays as is. */
129
+ function fromSetter(name: string): string {
130
+ return name[0].toLowerCase() + name.slice(1);
131
+ }
132
+
133
+ /** Blanks comments, which often quote code, keeping line numbers. */
134
+ export function codeLines(text: string, syntax: "c" | "hash"): string[] {
135
+ const lines = text.split("\n");
136
+ if (syntax === "hash") return lines.map(line => (line.trimStart().startsWith("#") ? "" : line));
137
+ // String-aware, so "/*" or "//" inside a string literal never hides code.
138
+ // Strings are tracked per line; a template literal spanning lines is rare
139
+ // enough in state code that a lead missed there is acceptable.
140
+ const state = { inBlock: false };
141
+ return lines.map(line => stripComments(line, state));
142
+ }
143
+
144
+ const QUOTES = new Set(['"', "'", "`"]);
145
+
146
+ /** One line without its comments; `state.inBlock` carries an open block comment to the next line. */
147
+ function stripComments(line: string, state: { inBlock: boolean }): string {
148
+ let out = "";
149
+ let index = 0;
150
+ while (index < line.length) {
151
+ if (state.inBlock) {
152
+ index = blockCommentEnd(line, index, state);
153
+ continue;
154
+ }
155
+ if (line.startsWith("/*", index)) {
156
+ state.inBlock = true;
157
+ index += 2;
158
+ continue;
159
+ }
160
+ if (line.startsWith("//", index)) break;
161
+ const end = QUOTES.has(line[index]) ? stringEnd(line, index) : index + 1;
162
+ out += line.slice(index, end);
163
+ index = end;
164
+ }
165
+ return out;
166
+ }
167
+
168
+ // Where an open block comment ends on this line (just past its `*/`), or the line's end.
169
+ function blockCommentEnd(line: string, index: number, state: { inBlock: boolean }): number {
170
+ const close = line.indexOf("*/", index);
171
+ if (close < 0) return line.length;
172
+ state.inBlock = false;
173
+ return close + 2;
174
+ }
175
+
176
+ /** Just past the string literal opening at `start`, honoring backslash escapes; the line's end when unterminated. */
177
+ function stringEnd(line: string, start: number): number {
178
+ const quote = line[start];
179
+ let index = start + 1;
180
+ while (index < line.length) {
181
+ if (line[index] === "\\") index += 2;
182
+ else if (line[index] === quote) return index + 1;
183
+ else index++;
184
+ }
185
+ return index;
186
+ }
187
+
188
+ const BOOLEAN = String.raw`(true|false|True|False)\b`;
189
+ /** An assignment target's last two members: `(owner.)?member`, where the owner may follow any chain. */
190
+ const TARGET = String.raw`(?<![\w$#])(?:([A-Za-z_$][\w$]*)[!?]?\.)?(#?[A-Za-z_$][\w$]*)`;
191
+ /**
192
+ * `x = true`, `this.x = false`, `self._x = True`, `this.#x = true`,
193
+ * `busy.current = true`, `press.moved = true`, `x: false` (object literal),
194
+ * but not `==`/`===`.
195
+ */
196
+ const ASSIGN = new RegExp(TARGET + String.raw`\s*(?:=(?!=)|:)\s*` + BOOLEAN, "g");
197
+ /** `setIsOpen(true)`, `set_loading(False)`. */
198
+ const SETTER = new RegExp(String.raw`\bset_?([A-Za-z_$][\w$]*)\(\s*` + BOOLEAN, "g");
199
+ /** `const [isOpen, setIsOpen] = useState(false)` names the flag and its first value. */
200
+ const USE_STATE = /\[\s*([A-Za-z_$][\w$]*)\s*,\s*set[A-Za-z_$][\w$]*\s*\]\s*=\s*(?:React\.)?useState(?:<boolean>)?\(\s*(true|false)\s*\)/g;
201
+ /**
202
+ * `status = "loading"`, `this.phase = 'done'`, `setStatus("error")`. Not
203
+ * `status: "idle"`: type members and returned object literals are how an
204
+ * explicit machine names its states, not scattered writes.
205
+ */
206
+ const STATUS_ASSIGN = new RegExp(TARGET + String.raw`\s*=(?!=)\s*(["'\`])([\w-]+)\3`, "g");
207
+ /** Tcl and Expect: `set busy true`, `set ::done 0`, `set status running`, `set status "done"`. */
208
+ const TCL_FLAG = /^\s*set\s+(?:::)?([A-Za-z_][\w]*)\s+(true|false|1|0|yes|no)\s*$/;
209
+ const TCL_STATUS = /^\s*set\s+(?:::)?([A-Za-z_][\w]*)\s+"?([A-Za-z][\w-]*)"?\s*$/;
210
+ const TCL_TRUE = new Set(["true", "1", "yes"]);
211
+ const STATUS_SETTER = /\bset_?([A-Za-z_$][\w$]*)\(\s*(["'`])([\w-]+)\2\s*\)/g;
212
+
213
+ /**
214
+ * Signals in one file:
215
+ * - `flags`: two or more lifecycle-style booleans that are each set both true
216
+ * and false, so their combinations encode one mode with impossible states.
217
+ * - `status`: one string field assigned three or more distinct literal values
218
+ * from several places, so its transitions are scattered.
219
+ */
220
+ export function scanText(text: string, syntax: "c" | "hash"): Signal[] {
221
+ const flags: Writes = new Map();
222
+ const statuses: Writes = new Map();
223
+ codeLines(text, syntax).forEach((line, index) => {
224
+ const at = index + 1;
225
+ noteFlagWrites(line, at, flags);
226
+ noteStatusAssigns(line, at, statuses);
227
+ if (syntax === "hash") noteTclWrites(line, at, flags, statuses);
228
+ noteStatusSetters(line, at, statuses);
229
+ });
230
+ return signalsFrom(flags, statuses);
231
+ }
232
+
233
+ /** The values each flag or status field is written with, and the lines that write it. */
234
+ type Writes = Map<string, { values: Set<string>; lines: Set<number> }>;
235
+
236
+ function note(map: Writes, name: string, value: string, line: number): void {
237
+ const entry = map.get(name) ?? { values: new Set<string>(), lines: new Set<number>() };
238
+ entry.values.add(value);
239
+ entry.lines.add(line);
240
+ map.set(name, entry);
241
+ }
242
+
243
+ /** `useState` booleans, `x = true` assignments and `setX(false)` calls. */
244
+ function noteFlagWrites(line: string, at: number, flags: Writes): void {
245
+ for (const match of line.matchAll(USE_STATE)) if (isFlag(match[1])) note(flags, match[1], match[2], at);
246
+ for (const match of line.matchAll(ASSIGN)) {
247
+ const name = targetName(match[1], match[2]);
248
+ if (isFlag(name)) note(flags, name, match[3].toLowerCase(), at);
249
+ }
250
+ for (const match of line.matchAll(SETTER)) {
251
+ const name = fromSetter(match[1]);
252
+ if (isFlag(name)) note(flags, name, match[2].toLowerCase(), at);
253
+ }
254
+ }
255
+
256
+ function noteStatusAssigns(line: string, at: number, statuses: Writes): void {
257
+ for (const match of line.matchAll(STATUS_ASSIGN)) {
258
+ const name = targetName(match[1], match[2]);
259
+ if (STATUS.test(name)) note(statuses, name, match[4], at);
260
+ }
261
+ }
262
+
263
+ /** Tcl's `set name value`: a flag when the value is boolean, otherwise a status. */
264
+ function noteTclWrites(line: string, at: number, flags: Writes, statuses: Writes): void {
265
+ const flag = TCL_FLAG.exec(line);
266
+ if (flag && isFlag(flag[1])) note(flags, flag[1], String(TCL_TRUE.has(flag[2])), at);
267
+ const status = flag ? undefined : TCL_STATUS.exec(line);
268
+ if (status && STATUS.test(status[1])) note(statuses, status[1], status[2], at);
269
+ }
270
+
271
+ function noteStatusSetters(line: string, at: number, statuses: Writes): void {
272
+ for (const match of line.matchAll(STATUS_SETTER)) {
273
+ const name = fromSetter(match[1]);
274
+ if (STATUS.test(name)) note(statuses, name, match[3], at);
275
+ }
276
+ }
277
+
278
+ function signalsFrom(flags: Writes, statuses: Writes): Signal[] {
279
+ const signals: Signal[] = [];
280
+ const toggled = [...flags].filter(([, entry]) => entry.values.size === 2);
281
+ if (toggled.length >= 2) {
282
+ signals.push({
283
+ kind: "flags",
284
+ names: toggled.map(([name]) => name).sort(),
285
+ lines: [...new Set(toggled.flatMap(([, entry]) => [...entry.lines]))].sort((a, b) => a - b),
286
+ });
287
+ }
288
+ for (const [name, entry] of statuses) {
289
+ if (entry.values.size >= 3 && entry.lines.size >= 3) {
290
+ signals.push({ kind: "status", names: [name], values: [...entry.values].sort(), lines: [...entry.lines].sort((a, b) => a - b) });
291
+ }
292
+ }
293
+ return signals;
294
+ }
295
+
296
+ /** Toggled flags weigh most: each one doubles the combinations a reader must rule out. */
297
+ function score(signals: Signal[]): number {
298
+ return signals.reduce((total, signal) => total + (signal.kind === "flags" ? 2 * signal.names.length : signal.values!.length), 0);
299
+ }
300
+
301
+ export function scanFile(file: string, text: string): FileReport | undefined {
302
+ const syntax = language(file, text.split("\n", 1)[0]);
303
+ if (!syntax || syntax === "maybe" || text.length > MAX_BYTES || text.includes("\u0000")) return undefined;
304
+ const signals = scanText(text, syntax);
305
+ return signals.length ? { path: file, score: score(signals), signals } : undefined;
306
+ }
@@ -89,18 +89,17 @@ export function preflight(expectedHead: string, baseBranch: string, baseOid: str
89
89
  /**
90
90
  * Merge a reviewed HEAD only after every preflight gate passes against the
91
91
  * freshly fetched live base: a skipped, failed, or stale preflight run never
92
- * leads to a merge. Without a reviewed HEAD (legacy manual form) only the
93
- * merge helper's own checks apply.
92
+ * leads to a merge. The reviewed HEAD and base branch are required, so no
93
+ * merge path skips preflight.
94
94
  */
95
- export function mergeReviewedHead(rootDir: string, subject?: string, expectedHead?: string, baseBranch?: string): number {
96
- if (!expectedHead) return squashMerge(rootDir, subject, expectedHead, baseBranch);
95
+ export function mergeReviewedHead(rootDir: string, subject: string | undefined, expectedHead: string, baseBranch: string): number {
96
+ assert(!!expectedHead && !!baseBranch, "Merge requires a reviewed HEAD and a base branch.");
97
97
  const identity = resolvePr();
98
- const branch = baseBranch ?? resolvePullRequestMergeTarget(identity).baseRefName;
99
- const baseOid = resolveFreshBaseRef(identity.repo, branch);
100
- try { preflight(expectedHead, branch, baseOid); }
101
- catch (error) { throw new Error(`Refusing to merge: preflight failed against live ${branch} ${baseOid}: ${(error as Error).message}`); }
102
- // Bind the merge to the branch that passed preflight, inferred or given, so a retarget in between is refused.
103
- return squashMerge(rootDir, subject, expectedHead, branch);
98
+ const baseOid = resolveFreshBaseRef(identity.repo, baseBranch);
99
+ try { preflight(expectedHead, baseBranch, baseOid); }
100
+ catch (error) { throw new Error(`Refusing to merge: preflight failed against live ${baseBranch} ${baseOid}: ${(error as Error).message}`); }
101
+ // Bind the merge to the branch that passed preflight, so a retarget in between is refused.
102
+ return squashMerge(rootDir, subject, expectedHead, baseBranch);
104
103
  }
105
104
 
106
105
  interface Comment { id: string; databaseId: number; body: string; url: string; author: { login: string } | null }