team 0.0.1 → 0.1.1
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.
- package/LICENSE +21 -0
- package/README.md +310 -0
- package/dist/approve/approval.d.ts +38 -0
- package/dist/approve/approval.js +78 -0
- package/dist/approve/diff.d.ts +13 -0
- package/dist/approve/diff.js +47 -0
- package/dist/approve/fingerprint.d.ts +44 -0
- package/dist/approve/fingerprint.js +109 -0
- package/dist/args.d.ts +7 -0
- package/dist/args.js +22 -0
- package/dist/budgets/checks.d.ts +35 -0
- package/dist/budgets/checks.js +77 -0
- package/dist/budgets/gate.d.ts +24 -0
- package/dist/budgets/gate.js +127 -0
- package/dist/budgets/readings.d.ts +69 -0
- package/dist/budgets/readings.js +156 -0
- package/dist/budgets/run.d.ts +66 -0
- package/dist/budgets/run.js +119 -0
- package/dist/budgets/table.d.ts +24 -0
- package/dist/budgets/table.js +90 -0
- package/dist/caller.d.ts +48 -0
- package/dist/caller.js +126 -0
- package/dist/check/config.d.ts +51 -0
- package/dist/check/config.js +50 -0
- package/dist/check/git.d.ts +27 -0
- package/dist/check/git.js +97 -0
- package/dist/check/message.d.ts +27 -0
- package/dist/check/message.js +101 -0
- package/dist/check/run.d.ts +31 -0
- package/dist/check/run.js +77 -0
- package/dist/check/signature.d.ts +10 -0
- package/dist/check/signature.js +22 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +76 -0
- package/dist/clis.d.ts +7 -0
- package/dist/clis.js +9 -0
- package/dist/commands/add.d.ts +23 -0
- package/dist/commands/add.js +445 -0
- package/dist/commands/approve.d.ts +11 -0
- package/dist/commands/approve.js +146 -0
- package/dist/commands/check.d.ts +17 -0
- package/dist/commands/check.js +103 -0
- package/dist/commands/doctor.d.ts +38 -0
- package/dist/commands/doctor.js +232 -0
- package/dist/commands/down.d.ts +37 -0
- package/dist/commands/down.js +258 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +138 -0
- package/dist/commands/remove.d.ts +23 -0
- package/dist/commands/remove.js +235 -0
- package/dist/commands/status.d.ts +41 -0
- package/dist/commands/status.js +147 -0
- package/dist/commands/up.d.ts +49 -0
- package/dist/commands/up.js +362 -0
- package/dist/commands/watch.d.ts +35 -0
- package/dist/commands/watch.js +324 -0
- package/dist/commands/worktree.d.ts +12 -0
- package/dist/commands/worktree.js +388 -0
- package/dist/end/condition.d.ts +28 -0
- package/dist/end/condition.js +77 -0
- package/dist/file/current.d.ts +13 -0
- package/dist/file/current.js +53 -0
- package/dist/file/lines.d.ts +25 -0
- package/dist/file/lines.js +277 -0
- package/dist/file/load.d.ts +7 -0
- package/dist/file/load.js +90 -0
- package/dist/file/paths.d.ts +6 -0
- package/dist/file/paths.js +77 -0
- package/dist/file/secrets.d.ts +6 -0
- package/dist/file/secrets.js +54 -0
- package/dist/file/signature.d.ts +13 -0
- package/dist/file/signature.js +36 -0
- package/dist/file/types.d.ts +130 -0
- package/dist/file/types.js +1 -0
- package/dist/file/validate.d.ts +13 -0
- package/dist/file/validate.js +702 -0
- package/dist/file/write.d.ts +11 -0
- package/dist/file/write.js +13 -0
- package/dist/herdr.d.ts +38 -0
- package/dist/herdr.js +227 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +6 -0
- package/dist/io.d.ts +10 -0
- package/dist/io.js +1 -0
- package/dist/launch/deliver.d.ts +10 -0
- package/dist/launch/deliver.js +39 -0
- package/dist/launch/execute.d.ts +46 -0
- package/dist/launch/execute.js +284 -0
- package/dist/launch/plan.d.ts +185 -0
- package/dist/launch/plan.js +280 -0
- package/dist/launch/rules.d.ts +25 -0
- package/dist/launch/rules.js +36 -0
- package/dist/log.d.ts +1 -0
- package/dist/log.js +24 -0
- package/dist/profiles/antigravity.yaml +66 -0
- package/dist/profiles/claude-code.yaml +69 -0
- package/dist/profiles/codex.yaml +77 -0
- package/dist/profiles/cursor.yaml +68 -0
- package/dist/profiles/index.d.ts +1 -0
- package/dist/profiles/index.js +1 -0
- package/dist/profiles/profile.d.ts +60 -0
- package/dist/profiles/profile.js +313 -0
- package/dist/profiles/quota.d.ts +27 -0
- package/dist/profiles/quota.js +57 -0
- package/dist/state.d.ts +61 -0
- package/dist/state.js +127 -0
- package/dist/status/compare.d.ts +26 -0
- package/dist/status/compare.js +110 -0
- package/dist/status/statusline.d.ts +9 -0
- package/dist/status/statusline.js +26 -0
- package/dist/store/store.d.ts +62 -0
- package/dist/store/store.js +116 -0
- package/dist/watch/check.d.ts +82 -0
- package/dist/watch/check.js +29 -0
- package/dist/watch/checks/approval.d.ts +2 -0
- package/dist/watch/checks/approval.js +16 -0
- package/dist/watch/checks/attention.d.ts +2 -0
- package/dist/watch/checks/attention.js +23 -0
- package/dist/watch/checks/budget.d.ts +2 -0
- package/dist/watch/checks/budget.js +141 -0
- package/dist/watch/checks/disk.d.ts +2 -0
- package/dist/watch/checks/disk.js +13 -0
- package/dist/watch/checks/extra.d.ts +2 -0
- package/dist/watch/checks/extra.js +18 -0
- package/dist/watch/checks/idle.d.ts +2 -0
- package/dist/watch/checks/idle.js +32 -0
- package/dist/watch/checks/load.d.ts +2 -0
- package/dist/watch/checks/load.js +11 -0
- package/dist/watch/checks/memory.d.ts +2 -0
- package/dist/watch/checks/memory.js +11 -0
- package/dist/watch/checks/missing.d.ts +2 -0
- package/dist/watch/checks/missing.js +12 -0
- package/dist/watch/checks/model-drift.d.ts +2 -0
- package/dist/watch/checks/model-drift.js +14 -0
- package/dist/watch/checks/swap-free.d.ts +2 -0
- package/dist/watch/checks/swap-free.js +12 -0
- package/dist/watch/checks/swap-growth.d.ts +2 -0
- package/dist/watch/checks/swap-growth.js +18 -0
- package/dist/watch/checks/team-idle.d.ts +2 -0
- package/dist/watch/checks/team-idle.js +18 -0
- package/dist/watch/checks/unsent.d.ts +2 -0
- package/dist/watch/checks/unsent.js +18 -0
- package/dist/watch/close.d.ts +19 -0
- package/dist/watch/close.js +40 -0
- package/dist/watch/dialect.d.ts +5 -0
- package/dist/watch/dialect.js +430 -0
- package/dist/watch/end.d.ts +11 -0
- package/dist/watch/end.js +17 -0
- package/dist/watch/machine.d.ts +38 -0
- package/dist/watch/machine.js +137 -0
- package/dist/watch/notify.d.ts +1 -0
- package/dist/watch/notify.js +15 -0
- package/dist/watch/pass.d.ts +42 -0
- package/dist/watch/pass.js +242 -0
- package/dist/watch/screen-core.d.ts +20 -0
- package/dist/watch/screen-core.js +356 -0
- package/dist/watch/screen-data.d.ts +65 -0
- package/dist/watch/screen-data.js +1 -0
- package/dist/watch/screen-file.d.ts +2 -0
- package/dist/watch/screen-file.js +269 -0
- package/dist/watch/screen.d.ts +24 -0
- package/dist/watch/screen.js +47 -0
- package/dist/worktree/place.d.ts +18 -0
- package/dist/worktree/place.js +126 -0
- package/dist/yaml.d.ts +30 -0
- package/dist/yaml.js +380 -0
- package/examples/checks/codex-quota +217 -0
- package/examples/team.yaml +76 -0
- package/package.json +29 -10
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// A `check` command's run and its output contract (RFC 0003 § 5). The raw output
|
|
2
|
+
// lives only here: it is parsed and dropped, and never returned, logged or shown.
|
|
3
|
+
// A check reads a vendor home; its bytes could carry anything the owner's tools know.
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { budgetsInForce } from "../approve/approval.js";
|
|
7
|
+
import { readApproval, storePath } from "../store/store.js";
|
|
8
|
+
import { checkCommands, checkReadings } from "./checks.js";
|
|
9
|
+
import { resetsFrom } from "./readings.js";
|
|
10
|
+
// § 5: one line, an amount with at most four decimals, an uppercase currency.
|
|
11
|
+
const SPEND = /^([0-9]+(?:\.[0-9]{1,4})?) ([A-Z]{3})$/;
|
|
12
|
+
// § 5: one to three lines, each a window, each figure whole and at most 100. The
|
|
13
|
+
// printed reset is a duration; the printed measurement time is unix seconds.
|
|
14
|
+
const LINE = /^(session|daily|weekly) (100|[0-9]{1,2})% (used|left)(?: resets ([0-9]+h(?:[0-9]+m)?|[0-9]+m))?(?: at ([0-9]{10}))?$/;
|
|
15
|
+
/** An output's lines: one trailing newline allowed, and nothing else. */
|
|
16
|
+
function linesOf(text) {
|
|
17
|
+
const body = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
18
|
+
return body === '' ? null : body.split('\n');
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A subscription output against § 5. Any line off the contract, a figure over
|
|
22
|
+
* 100, or a window twice reads unknown — the whole output, not just the line.
|
|
23
|
+
* `at`, when printed, is the line's own time; a time later than the run is the run's.
|
|
24
|
+
*/
|
|
25
|
+
export function parseSubscription(text, now) {
|
|
26
|
+
const lines = linesOf(text);
|
|
27
|
+
if (!lines || lines.length > 3)
|
|
28
|
+
return null;
|
|
29
|
+
const windows = [];
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
const match = LINE.exec(line);
|
|
32
|
+
if (!match)
|
|
33
|
+
return null;
|
|
34
|
+
const window = match[1];
|
|
35
|
+
if (windows.some((one) => one.window === window))
|
|
36
|
+
return null;
|
|
37
|
+
const value = Number(match[2]);
|
|
38
|
+
const used = match[3] === 'used' ? value : 100 - value;
|
|
39
|
+
const at = match[5] === undefined ? now : Math.min(Number(match[5]) * 1000, now);
|
|
40
|
+
windows.push({ window, left: 100 - used, used, at, resetsAt: match[4] === undefined ? null : resetsFrom(match[4], at) });
|
|
41
|
+
}
|
|
42
|
+
return windows.length ? { kind: 'subscription', windows } : null;
|
|
43
|
+
}
|
|
44
|
+
/** A spend output against § 5: exactly one line, and in the floor's currency. */
|
|
45
|
+
export function parseSpend(text, currency, now) {
|
|
46
|
+
const lines = linesOf(text);
|
|
47
|
+
if (!lines || lines.length !== 1)
|
|
48
|
+
return null;
|
|
49
|
+
const match = SPEND.exec(lines[0] ?? '');
|
|
50
|
+
if (!match || match[2] !== currency)
|
|
51
|
+
return null;
|
|
52
|
+
return { kind: 'spend', amount: Number(match[1]), currency: match[2], at: now };
|
|
53
|
+
}
|
|
54
|
+
/** One output against its account's contract. Null reads unknown for that account. */
|
|
55
|
+
export function parseOutput(account, text, now) {
|
|
56
|
+
if (account.kind === 'spend')
|
|
57
|
+
return account.floor === null ? null : parseSpend(text, account.floor.currency, now);
|
|
58
|
+
return parseSubscription(text, now);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Runs one approved command and returns its output, or null: § 5 gives it an
|
|
62
|
+
* empty environment with PATH and HOME, a timeout, and exit 0; stderr is dropped.
|
|
63
|
+
* The timeout kills (SIGKILL: a command that traps TERM would otherwise run on,
|
|
64
|
+
* and `spawnSync` blocks the whole watch loop while it does). The command's bytes
|
|
65
|
+
* are the owner's, so they are returned to the parser only.
|
|
66
|
+
*/
|
|
67
|
+
export function runCommand(path, timeoutMs = 10_000) {
|
|
68
|
+
try {
|
|
69
|
+
const result = spawnSync(path, [], {
|
|
70
|
+
encoding: 'utf8',
|
|
71
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
72
|
+
timeout: timeoutMs,
|
|
73
|
+
killSignal: 'SIGKILL',
|
|
74
|
+
env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '' },
|
|
75
|
+
});
|
|
76
|
+
if (result.error || result.status !== 0)
|
|
77
|
+
return null;
|
|
78
|
+
return result.stdout;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** One account's reading this pass: null when the command fails, times out, or breaks its contract. */
|
|
85
|
+
export function readCheck(account, path, now, timeoutMs = 10_000) {
|
|
86
|
+
const text = runCommand(path, timeoutMs);
|
|
87
|
+
return text === null ? null : parseOutput(account, text, now);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* What each checked account reads this pass: its reading, or that it has none
|
|
91
|
+
* and why. `budgets` is the section in force: an account an unapproved edit
|
|
92
|
+
* took out is not checked, and one it changed reads under the approved entry.
|
|
93
|
+
* An account whose check is changed, missing or unapproved is not run at all
|
|
94
|
+
* (§ 5, #45). The path handed to `run` is the approved one, hashed again
|
|
95
|
+
* against the approval: the file's command is never run directly.
|
|
96
|
+
*/
|
|
97
|
+
export function checkOutcomes(budgets, approved, run, now) {
|
|
98
|
+
const may = new Map(checkReadings(budgets, approved).map((one) => [one.account, one.state === 'approved']));
|
|
99
|
+
const outcomes = [];
|
|
100
|
+
for (const { account } of checkCommands(budgets)) {
|
|
101
|
+
const entry = budgets.accounts[account];
|
|
102
|
+
const known = approved?.[account];
|
|
103
|
+
if (entry === undefined || known === undefined || may.get(account) !== true) {
|
|
104
|
+
outcomes.push({ account, state: 'unapproved' });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const reading = run(entry, known.path, now);
|
|
108
|
+
outcomes.push(reading === null ? { account, state: 'unreadable' } : { account, state: 'read', reading });
|
|
109
|
+
}
|
|
110
|
+
return outcomes;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* What every checked account reads this pass, with the approval of this machine. The accounts are
|
|
114
|
+
* the ones the budgets in force name, never the file's unapproved edit of them.
|
|
115
|
+
*/
|
|
116
|
+
export function runChecks(team, root, now, home = homedir(), run = readCheck) {
|
|
117
|
+
const approved = readApproval(storePath(team.project, root, home))?.approval.checks;
|
|
118
|
+
return checkOutcomes(budgetsInForce(team, root, home), approved, run, now);
|
|
119
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { TeamFile } from '../file/types.ts';
|
|
2
|
+
import type { WindowName } from '../profiles/quota.ts';
|
|
3
|
+
import { type Seen } from './readings.ts';
|
|
4
|
+
export type BudgetState = 'fresh' | 'unconfirmed' | 'stale' | 'refusing' | 'unknown';
|
|
5
|
+
export type BudgetRow = {
|
|
6
|
+
account: string;
|
|
7
|
+
window: WindowName | null;
|
|
8
|
+
left: number | null;
|
|
9
|
+
used: number | null;
|
|
10
|
+
resetsIn: string | null;
|
|
11
|
+
seat: string | null;
|
|
12
|
+
age: string | null;
|
|
13
|
+
source: 'status_line' | null;
|
|
14
|
+
state: BudgetState;
|
|
15
|
+
/** True when a subscription's left figure is at or inside its reserve. */
|
|
16
|
+
inside: boolean;
|
|
17
|
+
/** The reserve the row was read against, or null: the line and the JSON can't disagree. */
|
|
18
|
+
reserve: number | null;
|
|
19
|
+
};
|
|
20
|
+
/** Rows for the accounts the budgets in force name, and for any reading still in the state. */
|
|
21
|
+
export declare function budgetTable(budgets: TeamFile['budgets'], list: readonly Seen[], now: number): BudgetRow[];
|
|
22
|
+
/** An account's reserve: a subscription's own, and none for anything else. */
|
|
23
|
+
export declare function reserveOf(budgets: TeamFile['budgets'], account: string): number | null;
|
|
24
|
+
export declare function budgetLine(row: BudgetRow): string;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { verdict } from "./readings.js";
|
|
2
|
+
const WINDOWS = ['session', 'daily', 'weekly'];
|
|
3
|
+
/** Rows for the accounts the budgets in force name, and for any reading still in the state. */
|
|
4
|
+
export function budgetTable(budgets, list, now) {
|
|
5
|
+
const staleAfterMs = budgets.staleAfter * 1000;
|
|
6
|
+
const groups = new Map();
|
|
7
|
+
for (const reading of list) {
|
|
8
|
+
const key = `${reading.account}\0${reading.window}`;
|
|
9
|
+
const group = groups.get(key) ?? [];
|
|
10
|
+
group.push(reading);
|
|
11
|
+
groups.set(key, group);
|
|
12
|
+
}
|
|
13
|
+
const rows = [];
|
|
14
|
+
const named = new Set();
|
|
15
|
+
for (const [key, group] of groups) {
|
|
16
|
+
const account = key.slice(0, key.indexOf('\0'));
|
|
17
|
+
const window = group[0]?.window ?? null;
|
|
18
|
+
named.add(account);
|
|
19
|
+
const reserve = reserveOf(budgets, account);
|
|
20
|
+
rows.push(rowOf(account, window, verdict(group, now, staleAfterMs, reserve), now, reserve));
|
|
21
|
+
}
|
|
22
|
+
for (const account of Object.keys(budgets.accounts)) {
|
|
23
|
+
if (!named.has(account))
|
|
24
|
+
rows.push(blank(account, reserveOf(budgets, account)));
|
|
25
|
+
}
|
|
26
|
+
return rows.sort(byAccount);
|
|
27
|
+
}
|
|
28
|
+
/** An account's reserve: a subscription's own, and none for anything else. */
|
|
29
|
+
export function reserveOf(budgets, account) {
|
|
30
|
+
const entry = budgets.accounts[account];
|
|
31
|
+
return entry?.kind === 'subscription' ? entry.reserve : null;
|
|
32
|
+
}
|
|
33
|
+
export function budgetLine(row) {
|
|
34
|
+
if (row.state === 'unknown' || row.left === null || row.used === null) {
|
|
35
|
+
return [row.account, row.window, 'unknown'].filter((part) => part).join(' ');
|
|
36
|
+
}
|
|
37
|
+
const reset = row.resetsIn === null ? 'resets unknown' : `resets in ${row.resetsIn}`;
|
|
38
|
+
const from = row.source === 'status_line' ? 'status line' : 'unknown source';
|
|
39
|
+
const state = row.inside && row.reserve !== null ? `${row.state}, inside reserve ${row.reserve}%` : row.state;
|
|
40
|
+
return `${row.account} ${row.window} left ${row.left}% used ${row.used}% ${reset} ${row.seat} changed ${row.age} ago ${from} ${state}`;
|
|
41
|
+
}
|
|
42
|
+
function rowOf(account, window, result, now, reserve) {
|
|
43
|
+
if (result.kind === 'unknown')
|
|
44
|
+
return { ...blank(account, reserve), window };
|
|
45
|
+
const reading = result.reading;
|
|
46
|
+
return {
|
|
47
|
+
account,
|
|
48
|
+
window: reading.window,
|
|
49
|
+
left: reading.left,
|
|
50
|
+
used: reading.used,
|
|
51
|
+
resetsIn: reading.resetsAt === null ? null : span(reading.resetsAt - now),
|
|
52
|
+
seat: reading.seat,
|
|
53
|
+
age: span(now - reading.changedAt),
|
|
54
|
+
source: 'status_line',
|
|
55
|
+
state: result.kind,
|
|
56
|
+
inside: reserve !== null && reading.left <= reserve,
|
|
57
|
+
reserve,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function blank(account, reserve) {
|
|
61
|
+
return {
|
|
62
|
+
account,
|
|
63
|
+
window: null,
|
|
64
|
+
left: null,
|
|
65
|
+
used: null,
|
|
66
|
+
resetsIn: null,
|
|
67
|
+
seat: null,
|
|
68
|
+
age: null,
|
|
69
|
+
source: null,
|
|
70
|
+
state: 'unknown',
|
|
71
|
+
inside: false,
|
|
72
|
+
reserve,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function byAccount(a, b) {
|
|
76
|
+
if (a.account !== b.account)
|
|
77
|
+
return a.account < b.account ? -1 : 1;
|
|
78
|
+
const rank = (window) => (window === null ? WINDOWS.length : WINDOWS.indexOf(window));
|
|
79
|
+
return rank(a.window) - rank(b.window);
|
|
80
|
+
}
|
|
81
|
+
function span(ms) {
|
|
82
|
+
const minutes = Math.max(0, Math.floor(ms / 60_000));
|
|
83
|
+
const hours = Math.floor(minutes / 60);
|
|
84
|
+
const rest = minutes % 60;
|
|
85
|
+
if (hours && rest)
|
|
86
|
+
return `${hours}h${rest}m`;
|
|
87
|
+
if (hours)
|
|
88
|
+
return `${hours}h`;
|
|
89
|
+
return `${minutes}m`;
|
|
90
|
+
}
|
package/dist/caller.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { TeamFile } from './file/types.ts';
|
|
2
|
+
import type { HerdrAgent } from './herdr.ts';
|
|
3
|
+
import type { Io } from './io.ts';
|
|
4
|
+
export type Caller = {
|
|
5
|
+
kind: 'owner';
|
|
6
|
+
} | {
|
|
7
|
+
kind: 'seat';
|
|
8
|
+
name: string;
|
|
9
|
+
pane: string;
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'unplaced';
|
|
12
|
+
reason: string;
|
|
13
|
+
};
|
|
14
|
+
export type Process = {
|
|
15
|
+
pid: number;
|
|
16
|
+
name: string;
|
|
17
|
+
};
|
|
18
|
+
export type CallerSources = {
|
|
19
|
+
ancestors(): Process[] | null;
|
|
20
|
+
agents(): HerdrAgent[] | null;
|
|
21
|
+
paneRootPid(pane: string): number | null;
|
|
22
|
+
env: Record<string, string | undefined>;
|
|
23
|
+
stdinIsTTY: boolean;
|
|
24
|
+
};
|
|
25
|
+
export declare function placeCaller(sources: CallerSources): Caller;
|
|
26
|
+
export declare function isOwner(caller: Caller): boolean;
|
|
27
|
+
export declare function mayChangeTeam(caller: Caller, team: Pick<TeamFile, 'coordinator' | 'operator'>): boolean;
|
|
28
|
+
export declare function describeCaller(caller: Caller): string;
|
|
29
|
+
export type ReadProcess = (pid: number) => {
|
|
30
|
+
ppid: number;
|
|
31
|
+
name: string;
|
|
32
|
+
} | null;
|
|
33
|
+
export declare const readWithPs: ReadProcess;
|
|
34
|
+
export declare function parseStat(text: string): {
|
|
35
|
+
ppid: number;
|
|
36
|
+
name: string;
|
|
37
|
+
} | null;
|
|
38
|
+
export declare function readWithProc(pid: number, proc?: string): {
|
|
39
|
+
ppid: number;
|
|
40
|
+
name: string;
|
|
41
|
+
} | null;
|
|
42
|
+
export declare function processReader(platform?: string): ReadProcess;
|
|
43
|
+
export declare function readAncestors(pid?: number, read?: ReadProcess): Process[] | null;
|
|
44
|
+
export declare function callerOf(io: Pick<Io, 'env' | 'stdinIsTTY' | 'caller'>, session?: string): Caller;
|
|
45
|
+
export declare function currentCaller(io: {
|
|
46
|
+
env: Record<string, string | undefined>;
|
|
47
|
+
stdinIsTTY: boolean;
|
|
48
|
+
}, session?: string): Caller;
|
package/dist/caller.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
import { agentList, paneRootPid } from "./herdr.js";
|
|
5
|
+
import { CLI_PROCESSES } from "./clis.js";
|
|
6
|
+
export function placeCaller(sources) {
|
|
7
|
+
const ancestors = sources.ancestors();
|
|
8
|
+
// A walk that stopped early may have stopped below a herdr server: it places nobody.
|
|
9
|
+
if (!ancestors?.length)
|
|
10
|
+
return { kind: 'unplaced', reason: 'its parent processes can\'t be read to the top' };
|
|
11
|
+
const pids = new Set(ancestors.map((process) => process.pid));
|
|
12
|
+
if (ancestors.some((process) => process.name === 'herdr')) {
|
|
13
|
+
const agents = sources.agents();
|
|
14
|
+
if (!agents)
|
|
15
|
+
return { kind: 'unplaced', reason: 'it runs under herdr, and herdr doesn\'t answer' };
|
|
16
|
+
// The variable only says which pane to read first; the pids decide.
|
|
17
|
+
const hint = sources.env.HERDR_PANE_ID;
|
|
18
|
+
const ordered = [...agents].sort((a, b) => Number(b.pane === hint) - Number(a.pane === hint));
|
|
19
|
+
for (const agent of ordered) {
|
|
20
|
+
const root = sources.paneRootPid(agent.pane);
|
|
21
|
+
if (root === null || !pids.has(root))
|
|
22
|
+
continue;
|
|
23
|
+
if (agent.name)
|
|
24
|
+
return { kind: 'seat', name: agent.name, pane: agent.pane };
|
|
25
|
+
return { kind: 'unplaced', reason: `it runs in pane ${agent.pane}, whose agent has no herdr name` };
|
|
26
|
+
}
|
|
27
|
+
return { kind: 'unplaced', reason: 'it runs in a herdr pane without an agent' };
|
|
28
|
+
}
|
|
29
|
+
const cli = ancestors.find((process) => CLI_PROCESSES.includes(process.name));
|
|
30
|
+
if (cli)
|
|
31
|
+
return { kind: 'unplaced', reason: `it is run by an agent (${cli.name}) outside herdr` };
|
|
32
|
+
if (sources.env.AGENT_UNATTENDED)
|
|
33
|
+
return { kind: 'unplaced', reason: 'AGENT_UNATTENDED is set' };
|
|
34
|
+
if (!sources.stdinIsTTY)
|
|
35
|
+
return { kind: 'unplaced', reason: 'it doesn\'t run on a terminal' };
|
|
36
|
+
return { kind: 'owner' };
|
|
37
|
+
}
|
|
38
|
+
export function isOwner(caller) {
|
|
39
|
+
return caller.kind === 'owner';
|
|
40
|
+
}
|
|
41
|
+
// The owner, or the coordinator's or the operator's seat: who may change a running team.
|
|
42
|
+
export function mayChangeTeam(caller, team) {
|
|
43
|
+
if (caller.kind === 'owner')
|
|
44
|
+
return true;
|
|
45
|
+
return caller.kind === 'seat' && (caller.name === team.coordinator || caller.name === team.operator);
|
|
46
|
+
}
|
|
47
|
+
export function describeCaller(caller) {
|
|
48
|
+
if (caller.kind === 'owner')
|
|
49
|
+
return 'owner';
|
|
50
|
+
if (caller.kind === 'seat')
|
|
51
|
+
return caller.name;
|
|
52
|
+
return `unplaced (${caller.reason})`;
|
|
53
|
+
}
|
|
54
|
+
// A process's name, however the platform spells it: ps prints a path, /proc a bare name, and a
|
|
55
|
+
// login shell leads with a dash.
|
|
56
|
+
function processName(raw) {
|
|
57
|
+
return basename(raw).replace(/^-/, '');
|
|
58
|
+
}
|
|
59
|
+
export const readWithPs = (pid) => {
|
|
60
|
+
try {
|
|
61
|
+
const line = execFileSync('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
62
|
+
const match = /^(\d+)\s+(.+)$/.exec(line);
|
|
63
|
+
return match ? { ppid: Number(match[1]), name: processName(match[2]) } : null;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
// "10 (herdr) S 1 10 …", one line of /proc/<pid>/stat: the name in parentheses — it can hold
|
|
70
|
+
// spaces and parentheses, so the last of them closes it — then the state, then the parent.
|
|
71
|
+
export function parseStat(text) {
|
|
72
|
+
const open = text.indexOf('(');
|
|
73
|
+
const close = text.lastIndexOf(')');
|
|
74
|
+
if (open < 0 || close < open)
|
|
75
|
+
return null;
|
|
76
|
+
const after = text.slice(close + 1).trim().split(/\s+/);
|
|
77
|
+
const ppid = Number(after[1]);
|
|
78
|
+
if (!Number.isInteger(ppid) || ppid < 0)
|
|
79
|
+
return null;
|
|
80
|
+
const name = processName(text.slice(open + 1, close));
|
|
81
|
+
return name ? { ppid, name } : null;
|
|
82
|
+
}
|
|
83
|
+
// One process, from Linux's own table. `proc` is the /proc root, a parameter so a test can walk a
|
|
84
|
+
// captured one.
|
|
85
|
+
export function readWithProc(pid, proc = '/proc') {
|
|
86
|
+
try {
|
|
87
|
+
return parseStat(readFileSync(`${proc}/${pid}/stat`, 'utf8'));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// The table this platform keeps: Linux answers from /proc, every other system through ps.
|
|
94
|
+
export function processReader(platform = process.platform) {
|
|
95
|
+
return platform === 'linux' ? readWithProc : readWithPs;
|
|
96
|
+
}
|
|
97
|
+
// The parent processes of `pid`, nearest first, up to the system's first process. Null when a
|
|
98
|
+
// process on the way can't be read, or the chain is longer than any real one: a partial list
|
|
99
|
+
// could hide the herdr server above it.
|
|
100
|
+
export function readAncestors(pid = process.ppid, read = processReader()) {
|
|
101
|
+
const out = [];
|
|
102
|
+
let at = pid;
|
|
103
|
+
for (let depth = 0; depth < 64; depth++) {
|
|
104
|
+
if (at <= 1)
|
|
105
|
+
return out;
|
|
106
|
+
const found = read(at);
|
|
107
|
+
if (!found)
|
|
108
|
+
return null;
|
|
109
|
+
out.push({ pid: at, name: found.name });
|
|
110
|
+
at = found.ppid;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
// The caller of a command: the one a test handed in, or the one the processes show.
|
|
115
|
+
export function callerOf(io, session) {
|
|
116
|
+
return io.caller ?? currentCaller(io, session);
|
|
117
|
+
}
|
|
118
|
+
export function currentCaller(io, session) {
|
|
119
|
+
return placeCaller({
|
|
120
|
+
ancestors: () => readAncestors(),
|
|
121
|
+
agents: () => agentList(session),
|
|
122
|
+
paneRootPid: (pane) => paneRootPid(pane, session),
|
|
123
|
+
env: io.env,
|
|
124
|
+
stdinIsTTY: io.stdinIsTTY,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `check` needs from a validated team file: the `identity` section with
|
|
3
|
+
* its defaults applied, and the seats whose signatures are accepted.
|
|
4
|
+
*/
|
|
5
|
+
import type { Position, TeamFile } from '../file/types.ts';
|
|
6
|
+
/** One `display` and `role` the team has had, with the fields a template may use. */
|
|
7
|
+
export interface SeatIdentity {
|
|
8
|
+
display: string;
|
|
9
|
+
role: string;
|
|
10
|
+
model: string;
|
|
11
|
+
version: string;
|
|
12
|
+
}
|
|
13
|
+
export interface SignatureRule {
|
|
14
|
+
template: string;
|
|
15
|
+
position: Position;
|
|
16
|
+
}
|
|
17
|
+
export interface CheckConfig {
|
|
18
|
+
/** Whether `forbiddenPublic` applies. */
|
|
19
|
+
public: boolean;
|
|
20
|
+
commits: SignatureRule & {
|
|
21
|
+
exemptMerge: boolean;
|
|
22
|
+
};
|
|
23
|
+
pullRequests: SignatureRule;
|
|
24
|
+
/** Author emails whose commits need no signature. */
|
|
25
|
+
humans: string[];
|
|
26
|
+
/** A commit: it and everything reachable from it are skipped. */
|
|
27
|
+
since?: string;
|
|
28
|
+
/** The file's patterns; the defaults are added by `forbiddenPatterns`. */
|
|
29
|
+
forbidden: string[];
|
|
30
|
+
forbiddenPublic: string[];
|
|
31
|
+
/** Every seat whose signature is accepted. */
|
|
32
|
+
ledger: SeatIdentity[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* What `check` reads of a validated file. The accepted signatures are those
|
|
36
|
+
* of the file's seats and of the ledger: every seat the team has had, so the
|
|
37
|
+
* commits of a removed or temporary seat, or of a seat before a version
|
|
38
|
+
* change, still pass.
|
|
39
|
+
*/
|
|
40
|
+
export declare function fromTeamFile(team: TeamFile, ledger?: readonly SeatIdentity[]): CheckConfig;
|
|
41
|
+
export interface ForbiddenPattern {
|
|
42
|
+
source: string;
|
|
43
|
+
regex: RegExp;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The patterns that apply to this project: the defaults (a validated file
|
|
47
|
+
* already lists them; they are never left out), the file's, and the public
|
|
48
|
+
* ones when the project is public. Throws on a pattern that isn't a
|
|
49
|
+
* regular expression.
|
|
50
|
+
*/
|
|
51
|
+
export declare function forbiddenPatterns(config: CheckConfig): ForbiddenPattern[];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `check` needs from a validated team file: the `identity` section with
|
|
3
|
+
* its defaults applied, and the seats whose signatures are accepted.
|
|
4
|
+
*/
|
|
5
|
+
import { DEFAULT_FORBIDDEN } from "../file/signature.js";
|
|
6
|
+
import { mergeLedger } from "../store/store.js";
|
|
7
|
+
/**
|
|
8
|
+
* What `check` reads of a validated file. The accepted signatures are those
|
|
9
|
+
* of the file's seats and of the ledger: every seat the team has had, so the
|
|
10
|
+
* commits of a removed or temporary seat, or of a seat before a version
|
|
11
|
+
* change, still pass.
|
|
12
|
+
*/
|
|
13
|
+
export function fromTeamFile(team, ledger = []) {
|
|
14
|
+
const { identity } = team;
|
|
15
|
+
return {
|
|
16
|
+
public: team.visibility === 'public',
|
|
17
|
+
commits: {
|
|
18
|
+
template: identity.signature.commits.template,
|
|
19
|
+
position: identity.signature.commits.position,
|
|
20
|
+
exemptMerge: identity.signature.commits.exempt.includes('merge'),
|
|
21
|
+
},
|
|
22
|
+
pullRequests: identity.signature.pullRequests,
|
|
23
|
+
humans: identity.humans,
|
|
24
|
+
since: identity.since ?? undefined,
|
|
25
|
+
forbidden: identity.forbidden,
|
|
26
|
+
forbiddenPublic: identity.forbiddenPublic,
|
|
27
|
+
ledger: mergeLedger(ledger, team.seats),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The patterns that apply to this project: the defaults (a validated file
|
|
32
|
+
* already lists them; they are never left out), the file's, and the public
|
|
33
|
+
* ones when the project is public. Throws on a pattern that isn't a
|
|
34
|
+
* regular expression.
|
|
35
|
+
*/
|
|
36
|
+
export function forbiddenPatterns(config) {
|
|
37
|
+
const sources = [...DEFAULT_FORBIDDEN, ...config.forbidden, ...(config.public ? config.forbiddenPublic : [])];
|
|
38
|
+
const patterns = [];
|
|
39
|
+
for (const source of new Set(sources)) {
|
|
40
|
+
let regex;
|
|
41
|
+
try {
|
|
42
|
+
regex = new RegExp(source);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
throw new Error(`forbidden pattern ${JSON.stringify(source)} is not a regular expression: ${error.message}`);
|
|
46
|
+
}
|
|
47
|
+
patterns.push({ source, regex });
|
|
48
|
+
}
|
|
49
|
+
return patterns;
|
|
50
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** A git command that failed, or a ref that can't be used. */
|
|
2
|
+
export declare class GitError extends Error {
|
|
3
|
+
}
|
|
4
|
+
export interface Commit {
|
|
5
|
+
hash: string;
|
|
6
|
+
parents: string[];
|
|
7
|
+
authorEmail: string;
|
|
8
|
+
subject: string;
|
|
9
|
+
message: string;
|
|
10
|
+
/** The lines of the final trailer block as git reads it; empty when it sees none. */
|
|
11
|
+
trailers: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface Selection {
|
|
14
|
+
/** The commits to check, newest first. */
|
|
15
|
+
commits: Commit[];
|
|
16
|
+
/** Commits of the range that `since` leaves out. */
|
|
17
|
+
skipped: number;
|
|
18
|
+
/** `since` as a commit hash, when one applies. */
|
|
19
|
+
since?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The commits `check` reads for `<ref>`: the range as given when it holds
|
|
23
|
+
* `..`, else that one commit, without what is reachable from `since`.
|
|
24
|
+
* An empty or unresolvable range is an error, and so is a `since` that isn't
|
|
25
|
+
* reachable from the range.
|
|
26
|
+
*/
|
|
27
|
+
export declare function selectCommits(cwd: string, ref: string, since?: string): Selection;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
/** A git command that failed, or a ref that can't be used. */
|
|
3
|
+
export class GitError extends Error {
|
|
4
|
+
}
|
|
5
|
+
/** Set for every call, so the machine's git configuration can't change what is read. */
|
|
6
|
+
const CONFIGURATION = ['log.showSignature=false', 'i18n.logOutputEncoding=UTF-8', 'trailer.separators=:'].flatMap((setting) => ['-c', setting]);
|
|
7
|
+
function run(cwd, args) {
|
|
8
|
+
const result = spawnSync('git', [...CONFIGURATION, ...args], {
|
|
9
|
+
cwd,
|
|
10
|
+
encoding: 'utf8',
|
|
11
|
+
maxBuffer: 1024 * 1024 * 1024,
|
|
12
|
+
});
|
|
13
|
+
if (result.error)
|
|
14
|
+
throw new GitError(`can't run git: ${result.error.message}`);
|
|
15
|
+
return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr };
|
|
16
|
+
}
|
|
17
|
+
function git(cwd, args) {
|
|
18
|
+
const result = run(cwd, args);
|
|
19
|
+
if (result.status !== 0) {
|
|
20
|
+
throw new GitError(result.stderr.trim() || `git ${args[0]} exited ${result.status}`);
|
|
21
|
+
}
|
|
22
|
+
return result.stdout;
|
|
23
|
+
}
|
|
24
|
+
function refuseOption(ref, what) {
|
|
25
|
+
if (ref === '' || ref.startsWith('-'))
|
|
26
|
+
throw new GitError(`${what} ${JSON.stringify(ref)} is not a ref`);
|
|
27
|
+
}
|
|
28
|
+
/** The revision arguments for `<ref>`: a range as given, or that one commit. */
|
|
29
|
+
function revision(cwd, ref) {
|
|
30
|
+
refuseOption(ref, 'the ref');
|
|
31
|
+
if (ref.includes('..'))
|
|
32
|
+
return ref;
|
|
33
|
+
const result = run(cwd, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${ref}^{commit}`]);
|
|
34
|
+
if (result.status !== 0)
|
|
35
|
+
throw new GitError(`${JSON.stringify(ref)} doesn't name a commit`);
|
|
36
|
+
return `${result.stdout.trim()}^!`;
|
|
37
|
+
}
|
|
38
|
+
/** The commits a range starts from: what `rev-parse` gives without a `^`. */
|
|
39
|
+
function tips(cwd, range) {
|
|
40
|
+
const result = run(cwd, ['rev-parse', '--revs-only', '--end-of-options', range]);
|
|
41
|
+
if (result.status !== 0 || result.stdout.trim() === '') {
|
|
42
|
+
throw new GitError(`the range ${JSON.stringify(range)} can't be resolved`);
|
|
43
|
+
}
|
|
44
|
+
return result.stdout.split('\n').filter((line) => line !== '' && !line.startsWith('^'));
|
|
45
|
+
}
|
|
46
|
+
const FIELDS = 6;
|
|
47
|
+
const FORMAT = ['%H', '%P', '%ae', '%s', '%B', '%(trailers)'].map((field) => `${field}%x00`).join('');
|
|
48
|
+
function parseLog(output) {
|
|
49
|
+
const fields = output.split('\0');
|
|
50
|
+
const commits = [];
|
|
51
|
+
for (let index = 0; index + FIELDS <= fields.length; index += FIELDS) {
|
|
52
|
+
const [hash, parents, authorEmail, subject, message, trailers] = fields.slice(index, index + FIELDS);
|
|
53
|
+
commits.push({
|
|
54
|
+
// Each record ends with a newline, which lands before the next hash.
|
|
55
|
+
hash: hash.trim(),
|
|
56
|
+
parents: parents.split(' ').filter(Boolean),
|
|
57
|
+
authorEmail,
|
|
58
|
+
subject,
|
|
59
|
+
message,
|
|
60
|
+
trailers: trailers.split('\n').filter((line) => line.trim() !== ''),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return commits;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The commits `check` reads for `<ref>`: the range as given when it holds
|
|
67
|
+
* `..`, else that one commit, without what is reachable from `since`.
|
|
68
|
+
* An empty or unresolvable range is an error, and so is a `since` that isn't
|
|
69
|
+
* reachable from the range.
|
|
70
|
+
*/
|
|
71
|
+
export function selectCommits(cwd, ref, since) {
|
|
72
|
+
const inside = run(cwd, ['rev-parse', '--git-dir']);
|
|
73
|
+
if (inside.status !== 0)
|
|
74
|
+
throw new GitError('not in a git repository');
|
|
75
|
+
const range = revision(cwd, ref);
|
|
76
|
+
const from = tips(cwd, range);
|
|
77
|
+
const total = Number(git(cwd, ['rev-list', '--count', '--end-of-options', range]).trim());
|
|
78
|
+
if (total === 0)
|
|
79
|
+
throw new GitError(`the range ${JSON.stringify(ref)} holds no commit`);
|
|
80
|
+
const revisions = [range];
|
|
81
|
+
let sinceHash;
|
|
82
|
+
if (since !== undefined) {
|
|
83
|
+
refuseOption(since, 'since');
|
|
84
|
+
const resolved = run(cwd, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${since}^{commit}`]);
|
|
85
|
+
if (resolved.status !== 0) {
|
|
86
|
+
throw new GitError(`since ${JSON.stringify(since)} doesn't name a commit here (a shallow clone doesn't hold the history it needs)`);
|
|
87
|
+
}
|
|
88
|
+
sinceHash = resolved.stdout.trim();
|
|
89
|
+
const reachable = from.some((tip) => run(cwd, ['merge-base', '--is-ancestor', sinceHash, tip]).status === 0);
|
|
90
|
+
if (!reachable) {
|
|
91
|
+
throw new GitError(`since ${JSON.stringify(since)} is not reachable from ${JSON.stringify(ref)}`);
|
|
92
|
+
}
|
|
93
|
+
revisions.push(`^${sinceHash}`);
|
|
94
|
+
}
|
|
95
|
+
const commits = parseLog(git(cwd, ['log', '--no-color', `--format=tformat:${FORMAT}`, '--end-of-options', ...revisions]));
|
|
96
|
+
return { commits, skipped: total - commits.length, since: sinceHash };
|
|
97
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ForbiddenPattern, SeatIdentity, SignatureRule } from './config.ts';
|
|
2
|
+
export type FindingKind = 'forbidden' | 'signature-missing' | 'signature-misplaced' | 'signature-in-prose' | 'signature-unknown';
|
|
3
|
+
export interface Finding {
|
|
4
|
+
kind: FindingKind;
|
|
5
|
+
message: string;
|
|
6
|
+
/** 1-based line of the commit message or PR body, when the finding has one. */
|
|
7
|
+
line?: number;
|
|
8
|
+
/** The offending line. */
|
|
9
|
+
text?: string;
|
|
10
|
+
}
|
|
11
|
+
/** The text's lines, trailing spaces trimmed. */
|
|
12
|
+
export declare function splitLines(text: string): string[];
|
|
13
|
+
/** Every line that matches a forbidden pattern. */
|
|
14
|
+
export declare function findForbidden(lines: readonly string[], patterns: readonly ForbiddenPattern[]): Finding[];
|
|
15
|
+
export interface SignatureInput {
|
|
16
|
+
lines: readonly string[];
|
|
17
|
+
rule: SignatureRule;
|
|
18
|
+
ledger: readonly SeatIdentity[];
|
|
19
|
+
/**
|
|
20
|
+
* For a commit: the lines of the final trailer block as git reads it, empty
|
|
21
|
+
* when git sees none. Absent for a PR body, where `trailer` means the last
|
|
22
|
+
* paragraph.
|
|
23
|
+
*/
|
|
24
|
+
trailers?: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
/** Empty when the text carries a signature of the team's at the position the rule says. */
|
|
27
|
+
export declare function checkSignature(input: SignatureInput): Finding[];
|