@bamr87/fleet-engines 0.1.0
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/README.md +43 -0
- package/VERSION +31 -0
- package/dist/fleet/audit.d.ts +26 -0
- package/dist/fleet/audit.js +467 -0
- package/dist/fleet/facts.d.ts +25 -0
- package/dist/fleet/facts.js +617 -0
- package/dist/fleet/import.d.ts +17 -0
- package/dist/fleet/import.js +74 -0
- package/dist/fleet/manifest.d.ts +23 -0
- package/dist/fleet/manifest.js +118 -0
- package/dist/fleet/metrics.d.ts +45 -0
- package/dist/fleet/metrics.js +284 -0
- package/dist/fleet/parse.d.ts +15 -0
- package/dist/fleet/parse.js +298 -0
- package/dist/fleet/types.d.ts +250 -0
- package/dist/fleet/types.js +7 -0
- package/dist/github/telemetry.d.ts +40 -0
- package/dist/github/telemetry.js +131 -0
- package/dist/github/types.d.ts +240 -0
- package/dist/github/types.js +36 -0
- package/dist/harness/health.d.ts +135 -0
- package/dist/harness/health.js +431 -0
- package/dist/harness/hub-paths.d.ts +45 -0
- package/dist/harness/hub-paths.js +35 -0
- package/dist/harness/hubread.d.ts +7 -0
- package/dist/harness/hubread.js +101 -0
- package/dist/harness/lanes.d.ts +45 -0
- package/dist/harness/lanes.js +92 -0
- package/dist/harness/manifest-yaml.d.ts +12 -0
- package/dist/harness/manifest-yaml.js +69 -0
- package/dist/harness/signals.d.ts +32 -0
- package/dist/harness/signals.js +74 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +19 -0
- package/package.json +55 -0
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# `@bamr87/fleet-engines` — the fleet's engines, versioned once
|
|
2
|
+
|
|
3
|
+
The pure logic behind every fleet console, published from the hub and consumed **by dependency**: GitFactory's Fleet Ops cockpit, Observe map and Harness tab run it in the browser; zer0-CMS's Fleet console runs it inside VS Code. One implementation, one rulebook, one set of tests.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @bamr87/fleet-engines
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { parseFleetManifest, extractFacts, auditWorkflow, harnessHealth } from '@bamr87/fleet-engines';
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## What is in it
|
|
14
|
+
|
|
15
|
+
| Module | What it does | Pure? |
|
|
16
|
+
| --- | --- | --- |
|
|
17
|
+
| `harness/lanes` | The `fleet/v1` vocabulary (`FleetLane`, `FleetManifest`), `parseFleetManifest` (tolerant, never throws), `laneForPath` | yes |
|
|
18
|
+
| `harness/manifest-yaml` | `toFleetManifestYaml(lanes, meta)` — a manifest document from lanes, deterministic emitter | yes |
|
|
19
|
+
| `fleet/parse`, `fleet/facts` | Reverse-import any repo's `.github/workflows/*.yml`: triggers, sinks, the AI runner (the hub's `claude-run` by reference, a vendored copy, `claude-code-action`, the bare CLI, the agentic engine), kill switches, guards, permissions, pins | yes |
|
|
20
|
+
| `fleet/audit` | The rulebook: the fleet's conventions as data (`AUDIT_RULES`) and the checkers (`auditWorkflow`, `auditRepo`, `gradeFor`). Every rule is one entry — meta, applies, violations — so the published rulebook and the checks cannot drift | yes |
|
|
21
|
+
| `fleet/metrics` | Run-level rollups per workflow and repo | yes |
|
|
22
|
+
| `fleet/import` | The one orchestrator: fetches workflows + the manifest through a `GithubClient` you inject | I/O via the client only |
|
|
23
|
+
| `harness/health` | The hub's harness scorecard and six trip wires, recomputed from committed signals | yes |
|
|
24
|
+
| `harness/signals`, `harness/hubread`, `harness/hub-paths` | Live signals from a scan; the eight hub reads; the hub's paths and snapshot shape | I/O via the client only |
|
|
25
|
+
| `github/types`, `github/telemetry` | The `GithubClient` contract (22 members) every console implements over its own fetch, `RepoRef`, `GithubError`; ETag-polled run telemetry | yes |
|
|
26
|
+
|
|
27
|
+
Not in it, on purpose: a fetch client (each consumer brings its own: a browser client, an editor client over VS Code's fetch and SecretStorage, a fixture client for demos), and the blueprint → lanes direction, which belongs to GitFactory's compiler.
|
|
28
|
+
|
|
29
|
+
## Where it came from
|
|
30
|
+
|
|
31
|
+
Lifted verbatim from GitFactory (`bamr87/gitorio` `app/src/{fleet,harness,github}`) at the commit named in `VERSION`, with two seams cut so it stands alone; GitFactory switches to consuming this package next (its backlog item), and the copy there is retired, never edited. The tests came with the code: 230+ cases over 39 real-fleet workflow fixtures and the hub's own committed signal files.
|
|
32
|
+
|
|
33
|
+
## Developing
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
cd templates/fleet-engines
|
|
37
|
+
npm install --no-package-lock # always-latest: floors, no lockfile
|
|
38
|
+
npm test # vitest, node environment
|
|
39
|
+
npm run typecheck
|
|
40
|
+
npm run build # dist/ (ESM + d.ts); prepack runs it for publish
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`fleet-engines-contract.yml` runs the tests, the typecheck and the build on every change here; `publish-kits.yml` builds, packs and publishes on a version bump. Bump `version` in `package.json` **and** `VERSION` together.
|
package/VERSION
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
kit: fleet-engines
|
|
2
|
+
version: 0.1.0
|
|
3
|
+
source: "bamr87/bamr87 templates/fleet-engines/ (npm: @bamr87/fleet-engines)"
|
|
4
|
+
seeded-by: "consumed BY DEPENDENCY — npm install @bamr87/fleet-engines (publish-kits.yml publishes on version change); not fanned out by tools/fanout.sh"
|
|
5
|
+
files: package.json, src/**/*.ts (github/, fleet/, harness/), tests + fixtures beside the sources, README.md
|
|
6
|
+
updated: 2026-09-08
|
|
7
|
+
changelog: |
|
|
8
|
+
0.1.0 — the fleet's pure engines, lifted from GitFactory (bamr87/gitorio app/src at
|
|
9
|
+
1ea1836) so that GitFactory and zer0-CMS run ONE implementation: fleet/v1 manifest
|
|
10
|
+
parse + emit (harness/lanes, manifest-yaml), workflow facts (fleet/facts, parse),
|
|
11
|
+
the audit rulebook (fleet/audit — the fleet's conventions as data: OAuth-first,
|
|
12
|
+
kill switch on scheduled AI, loop guards, concurrency, least privilege, timeouts,
|
|
13
|
+
pinning, plan/apply, dormant crons, sticky markers, the runner by reference),
|
|
14
|
+
metrics, the harness scorecard + six trip wires (harness/health), live signals,
|
|
15
|
+
the hub reader, and the GithubClient contract (github/types) every console
|
|
16
|
+
implements over its own fetch. Two seams cut on the way out: the blueprint →
|
|
17
|
+
lanes direction (toFleetLanes) stays with GitFactory's compiler, and
|
|
18
|
+
toFleetManifestYaml now takes lanes + meta instead of a blueprint; HUB_PATHS /
|
|
19
|
+
HubSnapshot were lifted out of GitFactory's zustand store into harness/hub-paths.
|
|
20
|
+
Tests travel with the code (vitest, node environment): 230+ cases over 39
|
|
21
|
+
real-fleet workflow fixtures and the hub's committed signal files. The live
|
|
22
|
+
signals adapter (harness/signals) is exercised by GitFactory's demo scan and
|
|
23
|
+
keeps its test there.
|
|
24
|
+
notes: |
|
|
25
|
+
Runtime dependency: exactly one, `yaml` (floor, per the always-latest policy).
|
|
26
|
+
Ships built JS + d.ts (dist/, produced by `prepack`; publish-kits.yml builds
|
|
27
|
+
before its `node --check` because the source is TypeScript). Consumers bundle it
|
|
28
|
+
(Vite, esbuild) and implement `GithubClient` themselves — a browser fetch
|
|
29
|
+
client, an editor fetch + SecretStorage client, a fixture client for demos.
|
|
30
|
+
Until GitFactory switches to this package (gitorio BL item) its app/src copy is
|
|
31
|
+
the same code at the SHA above; that copy is retired by the switch, never edited.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { AuditFinding, AuditRuleMeta, Grade, RepoAudit, WorkflowFacts } from './types.js';
|
|
2
|
+
/** The rulebook, in check order — what the UI renders as the certification standard. */
|
|
3
|
+
export declare const AUDIT_RULES: AuditRuleMeta[];
|
|
4
|
+
/**
|
|
5
|
+
* Run every workflow-scoped rule against one workflow's facts. Repo-scoped rules
|
|
6
|
+
* (standard-trio, disabled-workflow) only run in {@link auditRepo}. PURE and total.
|
|
7
|
+
*/
|
|
8
|
+
export declare function auditWorkflow(f: WorkflowFacts): AuditFinding[];
|
|
9
|
+
/**
|
|
10
|
+
* Certify one repo's whole workflow fleet: all workflow-scoped rules across every
|
|
11
|
+
* workflow, plus the repo-scoped rules. `opts.workflowStates` (path → Actions API
|
|
12
|
+
* state) feeds the disabled-workflow rule when the caller has polled live states.
|
|
13
|
+
*
|
|
14
|
+
* Scoring: 100 − 10·fails − 3·warns − 1·infos, clamped to 0–100. A rule lands in
|
|
15
|
+
* `passedRules` only when it applied to ≥1 workflow (or to the repo) and produced
|
|
16
|
+
* zero findings — a rule that never applied is neither passed nor failed.
|
|
17
|
+
*/
|
|
18
|
+
export declare function auditRepo(facts: WorkflowFacts[], opts?: {
|
|
19
|
+
workflowStates?: Record<string, string>;
|
|
20
|
+
}): RepoAudit;
|
|
21
|
+
/**
|
|
22
|
+
* Factorio-style certification grade. 'S' demands a spotless run (no fail, no warn,
|
|
23
|
+
* score ≥ 95 — infos alone can't block it); 'A' tolerates warns at ≥ 85 but no fail;
|
|
24
|
+
* below that only the score matters: 'B' ≥ 70, 'C' ≥ 50, else 'D'.
|
|
25
|
+
*/
|
|
26
|
+
export declare function gradeFor(score: number, findings: AuditFinding[]): Grade;
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
// Alignment-audit rules engine — Fleet Ops' "certification" pass. Grades one repo's
|
|
2
|
+
// reverse-engineered WorkflowFacts against the real bamr87 fleet conventions
|
|
3
|
+
// (ground-truthed by survey): OAuth-first Claude auth, default-OFF vars.*_ENABLED
|
|
4
|
+
// kill switches on scheduled AI, loop guards on event-triggered writers, near-universal
|
|
5
|
+
// concurrency blocks, least-privilege permissions, plan/apply duality on mutating
|
|
6
|
+
// crons, sticky-marker dedup on issue-filing crons, and the standard trio (a CI
|
|
7
|
+
// workflow + the @claude mention handler + the markdown-oneline prose kit).
|
|
8
|
+
//
|
|
9
|
+
// Every rule is ONE entry in a single data-driven list (meta + applies + violations),
|
|
10
|
+
// so the published rulebook (AUDIT_RULES) and the checkers cannot drift — there is no
|
|
11
|
+
// parallel switch statement. `applies` is deliberately separate from the violation
|
|
12
|
+
// check: a rule only counts as *passed* when it actually applied and stayed silent;
|
|
13
|
+
// silence from an inapplicable rule is neither pass nor fail. Pure and total: no I/O,
|
|
14
|
+
// no globals, no clock, never throws.
|
|
15
|
+
function wfRule(meta, applies, violations) {
|
|
16
|
+
return { scope: 'workflow', meta, applies, violations };
|
|
17
|
+
}
|
|
18
|
+
function repoRule(meta, applies, violations) {
|
|
19
|
+
return { scope: 'repo', meta, applies, violations };
|
|
20
|
+
}
|
|
21
|
+
// ── shared predicates ─────────────────────────────────────────────────────────
|
|
22
|
+
/** Events whose payloads a writer can retrigger — the feedback-loop surface. */
|
|
23
|
+
const LOOP_EVENT_TRIGGERS = ['issue_comment', 'issues', 'pull_request', 'pull_request_target'];
|
|
24
|
+
/** Sinks that can feed one of those events back. */
|
|
25
|
+
const LOOP_WRITE_SINKS = ['comment', 'issue', 'label', 'pr', 'commit'];
|
|
26
|
+
/** Triggers where parallel runs pile up or race without a concurrency group. */
|
|
27
|
+
const PILE_UP_TRIGGERS = ['push', 'pull_request', 'schedule'];
|
|
28
|
+
/** Sinks that mutate repo state — the plan/apply-duality surface. */
|
|
29
|
+
const MUTATING_SINKS = ['commit', 'pr', 'issue', 'merge'];
|
|
30
|
+
function hasTrigger(f, kinds) {
|
|
31
|
+
return f.triggers.some((t) => kinds.includes(t.kind));
|
|
32
|
+
}
|
|
33
|
+
/** Deduped trigger kinds of `f` that fall in `kinds`, for citing evidence. */
|
|
34
|
+
function triggersIn(f, kinds) {
|
|
35
|
+
return Array.from(new Set(f.triggers.map((t) => t.kind).filter((k) => kinds.includes(k))));
|
|
36
|
+
}
|
|
37
|
+
/** The subset of `f.sinks` in `kinds` — intersection test and evidence in one. */
|
|
38
|
+
function sinksIn(f, kinds) {
|
|
39
|
+
return f.sinks.filter((s) => kinds.includes(s));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Reusable-workflow refs (`…/x.yml@main`) may float on a branch by fleet convention —
|
|
43
|
+
* the hub's standard-ci.yml callers all track @main — so the pin rules exclude them.
|
|
44
|
+
*/
|
|
45
|
+
function isReusableWorkflowRef(a) {
|
|
46
|
+
return /\.ya?ml$/i.test(a.action);
|
|
47
|
+
}
|
|
48
|
+
/** A marketplace action `uses:` — excludes reusable-workflow refs (`…/*.yml`) and local composites. */
|
|
49
|
+
function isMarketplaceAction(a) {
|
|
50
|
+
return !isReusableWorkflowRef(a) && a.pin !== 'local';
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Whether a `pull_request`/`pull_request_target` trigger subscribes to a given activity.
|
|
54
|
+
* facts.ts surfaces the explicit `types:` in the trigger `detail`; a MISSING detail means
|
|
55
|
+
* the `types:` block was omitted, so GitHub's DEFAULT PR activity set — [opened, synchronize,
|
|
56
|
+
* reopened] — applies. So `synchronize`/`opened`/`reopened` count when the detail is absent
|
|
57
|
+
* (the default includes them), while non-default activities like `labeled` count only when
|
|
58
|
+
* an explicit `detail` names them.
|
|
59
|
+
*/
|
|
60
|
+
function prSubscribes(f, activity, inDefaultSet) {
|
|
61
|
+
return f.triggers.some((t) => {
|
|
62
|
+
if (t.kind !== 'pull_request' && t.kind !== 'pull_request_target')
|
|
63
|
+
return false;
|
|
64
|
+
if (t.detail === undefined)
|
|
65
|
+
return inDefaultSet; // no types: → GitHub default set
|
|
66
|
+
return t.detail.includes(activity);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** A self-push (`commit` to the PR branch) re-emits `synchronize`, which is in the default set. */
|
|
70
|
+
function subscribesSynchronize(f) {
|
|
71
|
+
return prSubscribes(f, 'synchronize', true);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The write sinks that can retrigger this workflow: a sink is loopy only when the event it
|
|
75
|
+
* emits is one the workflow already subscribes to. Emission map — comment→issue_comment;
|
|
76
|
+
* issue→issues; label→issues(labeled) for an issue label OR pull_request(labeled) for a PR
|
|
77
|
+
* label; pr→pull_request(opened); merge→nothing (terminal); commit→push (always) plus
|
|
78
|
+
* pull_request(synchronize) when the PR trigger subscribes to synchronize. A writer whose
|
|
79
|
+
* sinks emit nothing it listens for cannot feed itself and needs no guard.
|
|
80
|
+
*/
|
|
81
|
+
function selfRetriggerSinks(f) {
|
|
82
|
+
const subscribed = new Set(f.triggers.map((t) => t.kind));
|
|
83
|
+
const prFamily = subscribed.has('pull_request') || subscribed.has('pull_request_target');
|
|
84
|
+
const sync = subscribesSynchronize(f);
|
|
85
|
+
// `labeled` is NOT in the default PR set, so it only counts with an explicit `types:`.
|
|
86
|
+
const prLabeled = prFamily && prSubscribes(f, 'labeled', false);
|
|
87
|
+
const loops = (s) => {
|
|
88
|
+
switch (s) {
|
|
89
|
+
case 'comment':
|
|
90
|
+
return subscribed.has('issue_comment');
|
|
91
|
+
case 'issue':
|
|
92
|
+
return subscribed.has('issues');
|
|
93
|
+
case 'label':
|
|
94
|
+
// the `label` sink can be an issue label (→issues) or a PR label (→pull_request labeled)
|
|
95
|
+
return subscribed.has('issues') || prLabeled;
|
|
96
|
+
case 'pr':
|
|
97
|
+
return prFamily;
|
|
98
|
+
case 'commit':
|
|
99
|
+
return subscribed.has('push') || (prFamily && sync);
|
|
100
|
+
default:
|
|
101
|
+
return false; // merge (terminal), deploy, cross_repo_issue, dispatch — emit nothing we listen for
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
return f.sinks.filter(loops);
|
|
105
|
+
}
|
|
106
|
+
// ── the rulebook ──────────────────────────────────────────────────────────────
|
|
107
|
+
const RULES = [
|
|
108
|
+
wfRule({
|
|
109
|
+
id: 'auth-oauth-first',
|
|
110
|
+
title: 'OAuth-first Claude auth',
|
|
111
|
+
severity: 'warn',
|
|
112
|
+
standard: 'AI workflows authenticate with CLAUDE_CODE_OAUTH_TOKEN first and keep ANTHROPIC_API_KEY only as the fallback.',
|
|
113
|
+
}, (f) => f.ai.present, (f) => f.ai.authMode === 'api-key-only'
|
|
114
|
+
? [
|
|
115
|
+
{
|
|
116
|
+
message: 'uses ANTHROPIC_API_KEY only; fleet convention is OAuth-first (CLAUDE_CODE_OAUTH_TOKEN with API-key fallback)',
|
|
117
|
+
fix: 'Pass claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} first and keep anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} as the fallback.',
|
|
118
|
+
},
|
|
119
|
+
]
|
|
120
|
+
: []),
|
|
121
|
+
wfRule({
|
|
122
|
+
id: 'kill-switch',
|
|
123
|
+
title: 'Kill switch on scheduled AI',
|
|
124
|
+
severity: 'fail',
|
|
125
|
+
standard: 'Scheduled AI lines gate on a default-OFF vars.*_ENABLED repo variable so they can be disarmed without a redeploy.',
|
|
126
|
+
}, (f) => f.ai.present && f.crons.length > 0, (f) => f.killSwitches.length === 0
|
|
127
|
+
? [
|
|
128
|
+
{
|
|
129
|
+
message: `scheduled AI line with no vars.*_ENABLED kill switch — cannot be disarmed without a redeploy (crons: ${f.crons.join(', ')})`,
|
|
130
|
+
fix: "Gate the job with if: vars.<LINE>_ENABLED == 'true' (a default-OFF repo variable) so the schedule can be disarmed from repo settings.",
|
|
131
|
+
},
|
|
132
|
+
]
|
|
133
|
+
: []),
|
|
134
|
+
wfRule({
|
|
135
|
+
id: 'loop-safety',
|
|
136
|
+
title: 'Loop guard on self-retriggering writers',
|
|
137
|
+
severity: 'fail',
|
|
138
|
+
standard: 'A workflow whose write sink can re-emit an event it subscribes to carries at least one loop guard; a writer that cannot retrigger itself (its sink emits nothing it listens for) needs none.',
|
|
139
|
+
}, (f) => hasTrigger(f, LOOP_EVENT_TRIGGERS) && sinksIn(f, LOOP_WRITE_SINKS).length > 0, (f) => {
|
|
140
|
+
const loopy = selfRetriggerSinks(f);
|
|
141
|
+
if (loopy.length === 0)
|
|
142
|
+
return [];
|
|
143
|
+
// A GENERAL guard (attempt limit, label opt-in, actor guard, rate limiter, gate job,
|
|
144
|
+
// singleton concurrency, sticky marker, …) breaks any loop. `synchronize-skip` is
|
|
145
|
+
// narrower: it neutralizes ONLY the commit→synchronize path, so it counts only when
|
|
146
|
+
// every loopy sink is `commit` — never for a label/comment/pr self-retrigger.
|
|
147
|
+
const generalGuards = f.guards.filter((g) => g !== 'synchronize-skip');
|
|
148
|
+
if (generalGuards.length > 0)
|
|
149
|
+
return [];
|
|
150
|
+
if (f.guards.includes('synchronize-skip') && loopy.every((s) => s === 'commit'))
|
|
151
|
+
return [];
|
|
152
|
+
return [
|
|
153
|
+
{
|
|
154
|
+
message: `event-triggered writer with no loop guard — writing ${loopy.join(', ')} re-emits an event its own trigger subscribes to (${triggersIn(f, LOOP_EVENT_TRIGGERS).join(', ')})`,
|
|
155
|
+
fix: 'Add a loop guard (attempt-limit counter, label opt-in, actor/bot guard, rate limiter, sticky marker, gate job, or singleton concurrency), or stop subscribing to the activity the sink emits (e.g. drop pull_request synchronize).',
|
|
156
|
+
},
|
|
157
|
+
];
|
|
158
|
+
}),
|
|
159
|
+
wfRule({
|
|
160
|
+
id: 'concurrency',
|
|
161
|
+
title: 'Concurrency block',
|
|
162
|
+
severity: 'warn',
|
|
163
|
+
standard: 'Push, pull_request, and scheduled workflows declare a concurrency group so runs cannot pile up or race.',
|
|
164
|
+
}, (f) => !f.isReusable && hasTrigger(f, PILE_UP_TRIGGERS), (f) => f.concurrency.present
|
|
165
|
+
? []
|
|
166
|
+
: [
|
|
167
|
+
{
|
|
168
|
+
message: `no concurrency block; parallel runs can pile up or race (triggers: ${triggersIn(f, PILE_UP_TRIGGERS).join(', ')})`,
|
|
169
|
+
fix: 'Add a top-level concurrency block, e.g. concurrency: { group: "<line>-${{ github.ref }}", cancel-in-progress: true }.',
|
|
170
|
+
},
|
|
171
|
+
]),
|
|
172
|
+
wfRule({
|
|
173
|
+
id: 'permissions',
|
|
174
|
+
title: 'Least-privilege permissions',
|
|
175
|
+
severity: 'warn',
|
|
176
|
+
standard: 'Every non-reusable workflow declares an explicit permissions block instead of inheriting the repo default token grants.',
|
|
177
|
+
}, (f) => !f.isReusable, (f) => f.permissions.declared
|
|
178
|
+
? []
|
|
179
|
+
: [
|
|
180
|
+
{
|
|
181
|
+
message: "no permissions block — the job runs with the repo's default token grants",
|
|
182
|
+
fix: 'Declare a permissions: block with the minimal scopes each job needs (start from contents: read).',
|
|
183
|
+
},
|
|
184
|
+
]),
|
|
185
|
+
wfRule({
|
|
186
|
+
id: 'top-level-write',
|
|
187
|
+
title: 'Job-scoped write grants',
|
|
188
|
+
severity: 'info',
|
|
189
|
+
standard: 'Write scopes are escalated per job, never granted at the workflow top level.',
|
|
190
|
+
}, (f) => f.permissions.declared, (f) => f.permissions.topLevelWrite
|
|
191
|
+
? [
|
|
192
|
+
{
|
|
193
|
+
message: `write scopes granted at the top level; prefer per-job escalation${f.permissions.writeScopes.length > 0
|
|
194
|
+
? ` (write scopes in file: ${f.permissions.writeScopes.join(', ')})`
|
|
195
|
+
: ''}`,
|
|
196
|
+
fix: 'Move write scopes down to the jobs that need them and keep the top level read-only.',
|
|
197
|
+
},
|
|
198
|
+
]
|
|
199
|
+
: []),
|
|
200
|
+
wfRule({
|
|
201
|
+
id: 'ai-timeout',
|
|
202
|
+
title: 'Timeout on AI jobs',
|
|
203
|
+
severity: 'warn',
|
|
204
|
+
standard: 'AI jobs declare timeout-minutes so a wedged agent cannot burn unbounded minutes.',
|
|
205
|
+
}, (f) => f.ai.present, (f) => f.timeoutMinutes === null
|
|
206
|
+
? [
|
|
207
|
+
{
|
|
208
|
+
message: 'AI job without timeout-minutes — an unbounded cost risk',
|
|
209
|
+
fix: 'Set timeout-minutes on the AI job (e.g. timeout-minutes: 30).',
|
|
210
|
+
},
|
|
211
|
+
]
|
|
212
|
+
: []),
|
|
213
|
+
wfRule({
|
|
214
|
+
id: 'pin-branch',
|
|
215
|
+
title: 'No branch-pinned actions',
|
|
216
|
+
severity: 'warn',
|
|
217
|
+
standard: 'Actions are pinned to a tag or SHA; only reusable fleet-hub workflow refs may float on a branch.',
|
|
218
|
+
}, (f) => f.actions.some((a) => !isReusableWorkflowRef(a)), (f) => f.actions
|
|
219
|
+
.filter((a) => !isReusableWorkflowRef(a) && a.pin === 'branch')
|
|
220
|
+
.map((a) => ({
|
|
221
|
+
message: `${a.action}@${a.ref ?? '?'} — action pinned to a moving branch`,
|
|
222
|
+
fix: `Pin ${a.action} to a release tag or commit SHA instead of @${a.ref ?? '?'}.`,
|
|
223
|
+
}))),
|
|
224
|
+
wfRule({
|
|
225
|
+
id: 'pin-unpinned',
|
|
226
|
+
title: 'No unpinned actions',
|
|
227
|
+
severity: 'warn',
|
|
228
|
+
standard: 'Every non-local uses: reference carries an explicit @ref pin.',
|
|
229
|
+
}, (f) => f.actions.some((a) => a.pin !== 'local'), (f) => f.actions
|
|
230
|
+
.filter((a) => a.pin === 'unpinned')
|
|
231
|
+
.map((a) => ({
|
|
232
|
+
message: `${a.action} has no @ref — it resolves to whatever the default branch holds`,
|
|
233
|
+
fix: `Add an explicit pin: ${a.action}@<tag-or-sha>.`,
|
|
234
|
+
}))),
|
|
235
|
+
repoRule({
|
|
236
|
+
id: 'pin-sha',
|
|
237
|
+
title: 'SHA-pin marketplace actions',
|
|
238
|
+
severity: 'info',
|
|
239
|
+
standard: 'In repos that pin marketplace actions to commit SHAs by convention (SHA-pins outnumber tag-pins), every marketplace action is SHA-pinned rather than left tag-pinned.',
|
|
240
|
+
}, ({ facts }) => {
|
|
241
|
+
let sha = 0;
|
|
242
|
+
let tag = 0;
|
|
243
|
+
for (const f of facts) {
|
|
244
|
+
for (const a of f.actions) {
|
|
245
|
+
if (!isMarketplaceAction(a))
|
|
246
|
+
continue;
|
|
247
|
+
if (a.pin === 'sha')
|
|
248
|
+
sha += 1;
|
|
249
|
+
else if (a.pin === 'tag')
|
|
250
|
+
tag += 1;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return sha > tag;
|
|
254
|
+
}, ({ facts }) => {
|
|
255
|
+
const out = [];
|
|
256
|
+
for (const f of facts) {
|
|
257
|
+
for (const a of f.actions) {
|
|
258
|
+
if (!isMarketplaceAction(a) || a.pin !== 'tag')
|
|
259
|
+
continue;
|
|
260
|
+
out.push({
|
|
261
|
+
path: f.path,
|
|
262
|
+
message: `${a.action}@${a.ref ?? '?'} is tag-pinned while this repo SHA-pins marketplace actions by convention`,
|
|
263
|
+
fix: `Pin ${a.action} to a full commit SHA (${a.action}@<sha>) to match the repo's SHA-pinning convention.`,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}),
|
|
269
|
+
wfRule({
|
|
270
|
+
id: 'plan-apply',
|
|
271
|
+
title: 'Plan/apply duality on mutating crons',
|
|
272
|
+
severity: 'info',
|
|
273
|
+
standard: 'Mutating scheduled lines expose a plan-first (dry-run) apply input so a human can preview before the line writes.',
|
|
274
|
+
}, (f) => f.crons.length > 0 && sinksIn(f, MUTATING_SINKS).length > 0, (f) => f.planApply
|
|
275
|
+
? []
|
|
276
|
+
: [
|
|
277
|
+
{
|
|
278
|
+
message: `mutating scheduled line without a plan-first apply input (dry-run duality) — writes ${sinksIn(f, MUTATING_SINKS).join(', ')}`,
|
|
279
|
+
fix: 'Add a workflow_dispatch apply input (default false) and keep the scheduled path plan-only.',
|
|
280
|
+
},
|
|
281
|
+
]),
|
|
282
|
+
wfRule({
|
|
283
|
+
id: 'dormant-cron',
|
|
284
|
+
title: 'No dormant schedules',
|
|
285
|
+
severity: 'info',
|
|
286
|
+
standard: 'Commented-out cron lines are re-armed or removed rather than left as dead automation.',
|
|
287
|
+
}, (f) => f.crons.length > 0 || f.dormantCrons.length > 0, (f) => f.dormantCrons.length > 0
|
|
288
|
+
? [
|
|
289
|
+
{
|
|
290
|
+
message: `schedule is commented out — dormant automation; re-arm or remove it (${f.dormantCrons.join(', ')})`,
|
|
291
|
+
fix: 'Uncomment the cron to re-arm the schedule, or delete the commented-out block.',
|
|
292
|
+
},
|
|
293
|
+
]
|
|
294
|
+
: []),
|
|
295
|
+
wfRule({
|
|
296
|
+
id: 'sticky-marker',
|
|
297
|
+
title: 'Sticky-marker dedup on issue-filing crons',
|
|
298
|
+
severity: 'info',
|
|
299
|
+
standard: 'Scheduled issue-filers upsert against an HTML-comment marker instead of filing duplicates.',
|
|
300
|
+
}, (f) => f.crons.length > 0 && f.sinks.includes('issue'), (f) => f.guards.includes('sticky-marker')
|
|
301
|
+
? []
|
|
302
|
+
: [
|
|
303
|
+
{
|
|
304
|
+
message: 'scheduled issue-filer without an HTML-comment dedup marker — risks duplicate issues',
|
|
305
|
+
fix: 'Embed an HTML-comment marker (e.g. <!-- fleet:rule-id -->) in the issue body and update the marked issue when it already exists.',
|
|
306
|
+
},
|
|
307
|
+
]),
|
|
308
|
+
repoRule({
|
|
309
|
+
id: 'standard-trio',
|
|
310
|
+
title: 'Standard trio',
|
|
311
|
+
severity: 'warn',
|
|
312
|
+
standard: 'Every fleet repo carries a CI workflow, the @claude mention handler, and the markdown-oneline prose kit.',
|
|
313
|
+
}, () => true, ({ facts }) => {
|
|
314
|
+
const has = (...kinds) => facts.some((f) => kinds.includes(f.archetype));
|
|
315
|
+
const out = [];
|
|
316
|
+
if (!has('standard-ci-caller', 'ci-gate')) {
|
|
317
|
+
out.push({
|
|
318
|
+
path: null,
|
|
319
|
+
message: "standard trio: no CI workflow — nothing with archetype 'standard-ci-caller' or 'ci-gate'",
|
|
320
|
+
fix: 'Add the thin caller of the hub reusable standard-ci.yml (or a repo-local CI gate).',
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
if (!has('mention-handler')) {
|
|
324
|
+
out.push({
|
|
325
|
+
path: null,
|
|
326
|
+
message: "standard trio: no @claude mention handler — nothing with archetype 'mention-handler'",
|
|
327
|
+
fix: 'Seed the @claude mention workflow from the agent-context kit (standardize fan-out).',
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
if (!has('prose-kit')) {
|
|
331
|
+
out.push({
|
|
332
|
+
path: null,
|
|
333
|
+
message: "standard trio: no markdown-oneline prose kit — nothing with archetype 'prose-kit'",
|
|
334
|
+
fix: 'Seed the markdown-oneline prose workflow (fan-out kit: prose).',
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
return out;
|
|
338
|
+
}),
|
|
339
|
+
repoRule({
|
|
340
|
+
id: 'disabled-workflow',
|
|
341
|
+
title: 'No disabled workflows',
|
|
342
|
+
severity: 'info',
|
|
343
|
+
standard: 'Disabled workflows are dead weight — re-enabled or deleted, never left lingering.',
|
|
344
|
+
}, ({ workflowStates }) => workflowStates !== undefined && Object.keys(workflowStates).length > 0, ({ workflowStates }) => Object.entries(workflowStates ?? {})
|
|
345
|
+
.filter(([, state]) => typeof state === 'string' && state.startsWith('disabled'))
|
|
346
|
+
.map(([path, state]) => ({
|
|
347
|
+
path,
|
|
348
|
+
message: `workflow is disabled (${state}) — dead weight; remove it or re-enable it`,
|
|
349
|
+
fix: 'Re-enable the workflow from the Actions tab, or delete the file if the line is retired.',
|
|
350
|
+
}))),
|
|
351
|
+
];
|
|
352
|
+
// ── public API ────────────────────────────────────────────────────────────────
|
|
353
|
+
/** The rulebook, in check order — what the UI renders as the certification standard. */
|
|
354
|
+
export const AUDIT_RULES = RULES.map((r) => r.meta);
|
|
355
|
+
/**
|
|
356
|
+
* Run every workflow-scoped rule against one workflow's facts. Repo-scoped rules
|
|
357
|
+
* (standard-trio, disabled-workflow) only run in {@link auditRepo}. PURE and total.
|
|
358
|
+
*/
|
|
359
|
+
export function auditWorkflow(f) {
|
|
360
|
+
const out = [];
|
|
361
|
+
for (const rule of RULES) {
|
|
362
|
+
if (rule.scope !== 'workflow' || !rule.applies(f))
|
|
363
|
+
continue;
|
|
364
|
+
for (const v of rule.violations(f)) {
|
|
365
|
+
out.push({
|
|
366
|
+
ruleId: rule.meta.id,
|
|
367
|
+
severity: rule.meta.severity,
|
|
368
|
+
path: f.path,
|
|
369
|
+
message: v.message,
|
|
370
|
+
fix: v.fix,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Certify one repo's whole workflow fleet: all workflow-scoped rules across every
|
|
378
|
+
* workflow, plus the repo-scoped rules. `opts.workflowStates` (path → Actions API
|
|
379
|
+
* state) feeds the disabled-workflow rule when the caller has polled live states.
|
|
380
|
+
*
|
|
381
|
+
* Scoring: 100 − 10·fails − 3·warns − 1·infos, clamped to 0–100. A rule lands in
|
|
382
|
+
* `passedRules` only when it applied to ≥1 workflow (or to the repo) and produced
|
|
383
|
+
* zero findings — a rule that never applied is neither passed nor failed.
|
|
384
|
+
*/
|
|
385
|
+
export function auditRepo(facts, opts) {
|
|
386
|
+
const ctx = { facts, workflowStates: opts?.workflowStates };
|
|
387
|
+
const findings = [];
|
|
388
|
+
const passedRules = [];
|
|
389
|
+
for (const rule of RULES) {
|
|
390
|
+
let applied = false;
|
|
391
|
+
const before = findings.length;
|
|
392
|
+
if (rule.scope === 'workflow') {
|
|
393
|
+
for (const f of facts) {
|
|
394
|
+
if (!rule.applies(f))
|
|
395
|
+
continue;
|
|
396
|
+
applied = true;
|
|
397
|
+
for (const v of rule.violations(f)) {
|
|
398
|
+
findings.push({
|
|
399
|
+
ruleId: rule.meta.id,
|
|
400
|
+
severity: rule.meta.severity,
|
|
401
|
+
path: f.path,
|
|
402
|
+
message: v.message,
|
|
403
|
+
fix: v.fix,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
else if (rule.applies(ctx)) {
|
|
409
|
+
applied = true;
|
|
410
|
+
for (const v of rule.violations(ctx)) {
|
|
411
|
+
findings.push({
|
|
412
|
+
ruleId: rule.meta.id,
|
|
413
|
+
severity: rule.meta.severity,
|
|
414
|
+
path: v.path,
|
|
415
|
+
message: v.message,
|
|
416
|
+
fix: v.fix,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (applied && findings.length === before)
|
|
421
|
+
passedRules.push(rule.meta.id);
|
|
422
|
+
}
|
|
423
|
+
let fails = 0;
|
|
424
|
+
let warns = 0;
|
|
425
|
+
let infos = 0;
|
|
426
|
+
const byWorkflow = {};
|
|
427
|
+
for (const f of facts)
|
|
428
|
+
byWorkflow[f.path] = { fails: 0, warns: 0, infos: 0 };
|
|
429
|
+
for (const fd of findings) {
|
|
430
|
+
if (fd.severity === 'fail')
|
|
431
|
+
fails += 1;
|
|
432
|
+
else if (fd.severity === 'warn')
|
|
433
|
+
warns += 1;
|
|
434
|
+
else
|
|
435
|
+
infos += 1;
|
|
436
|
+
if (fd.path === null)
|
|
437
|
+
continue;
|
|
438
|
+
const cell = byWorkflow[fd.path] ?? { fails: 0, warns: 0, infos: 0 };
|
|
439
|
+
byWorkflow[fd.path] = cell;
|
|
440
|
+
if (fd.severity === 'fail')
|
|
441
|
+
cell.fails += 1;
|
|
442
|
+
else if (fd.severity === 'warn')
|
|
443
|
+
cell.warns += 1;
|
|
444
|
+
else
|
|
445
|
+
cell.infos += 1;
|
|
446
|
+
}
|
|
447
|
+
const score = Math.max(0, Math.min(100, 100 - 10 * fails - 3 * warns - infos));
|
|
448
|
+
return { score, grade: gradeFor(score, findings), findings, passedRules, byWorkflow };
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Factorio-style certification grade. 'S' demands a spotless run (no fail, no warn,
|
|
452
|
+
* score ≥ 95 — infos alone can't block it); 'A' tolerates warns at ≥ 85 but no fail;
|
|
453
|
+
* below that only the score matters: 'B' ≥ 70, 'C' ≥ 50, else 'D'.
|
|
454
|
+
*/
|
|
455
|
+
export function gradeFor(score, findings) {
|
|
456
|
+
const hasFail = findings.some((f) => f.severity === 'fail');
|
|
457
|
+
const hasWarn = findings.some((f) => f.severity === 'warn');
|
|
458
|
+
if (!hasFail && !hasWarn && score >= 95)
|
|
459
|
+
return 'S';
|
|
460
|
+
if (!hasFail && score >= 85)
|
|
461
|
+
return 'A';
|
|
462
|
+
if (score >= 70)
|
|
463
|
+
return 'B';
|
|
464
|
+
if (score >= 50)
|
|
465
|
+
return 'C';
|
|
466
|
+
return 'D';
|
|
467
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Archetype, DashType, WorkflowFacts } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Exact port of the dash's ordered-substring workflow-type classifier: the first
|
|
4
|
+
* rule with any keyword contained in `"<name> <path>".toLowerCase()` wins;
|
|
5
|
+
* anything unmatched is 'other'. PURE.
|
|
6
|
+
*/
|
|
7
|
+
export declare function classifyDashType(name: string, path: string): DashType;
|
|
8
|
+
/** The pre-archetype subset of WorkflowFacts that classifyArchetype reads. */
|
|
9
|
+
export type ArchetypeSignals = Pick<WorkflowFacts, 'path' | 'name' | 'triggers' | 'crons' | 'isReusable' | 'reusableCalls' | 'jobCount' | 'hasMatrix' | 'matrixDynamic' | 'sinks' | 'crossRepoTargets' | 'ai' | 'guards' | 'generated'>;
|
|
10
|
+
/**
|
|
11
|
+
* First-match archetype classification, ground-truthed against the real fleet.
|
|
12
|
+
* Name-flavored checks run against `"<name> <path>"` (not the raw text) because
|
|
13
|
+
* real workflows *mention* their siblings constantly — zer0's auto-merge denylists
|
|
14
|
+
* `release-please-config.json`, the UI audit describes the issue autopilot — and a
|
|
15
|
+
* raw-text match on those words would misfile them. Raw text is only consulted for
|
|
16
|
+
* signals that genuinely live in the body (the @claude mention, seeded-kit script
|
|
17
|
+
* names, dispatch plans). PURE.
|
|
18
|
+
*/
|
|
19
|
+
export declare function classifyArchetype(f: ArchetypeSignals, rawText: string): Archetype;
|
|
20
|
+
/**
|
|
21
|
+
* Extract the full {@link WorkflowFacts} for one workflow file. PURE and total:
|
|
22
|
+
* never throws — unparseable YAML degrades to raw-text signals (jobCount 0, raw
|
|
23
|
+
* fallbacks for matrix/timeout/concurrency), exactly like parseWorkflow.
|
|
24
|
+
*/
|
|
25
|
+
export declare function extractFacts(path: string, yamlText: string): WorkflowFacts;
|