@codyswann/lisa 3.16.0 → 3.17.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/all/copy-overwrite/scripts/lisa-gates.mjs +1165 -0
- package/all/copy-overwrite/scripts/lisa-reconcile-policy.mjs +1188 -0
- package/all/copy-overwrite/scripts/lisa-run-gates.mjs +597 -0
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +24 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +72 -8
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +6 -2
- package/plugins/lisa/.claude-plugin/plugin.json +10 -1
- package/plugins/lisa/.codex-plugin/hooks.json +9 -0
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa/hooks/secrets-preflight.sh +72 -0
- package/plugins/lisa/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +10 -1
- package/plugins/lisa-copilot/hooks/secrets-preflight.sh +72 -0
- package/plugins/lisa-copilot/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/hooks.json +3 -0
- package/plugins/lisa-cursor/hooks/secrets-preflight.sh +72 -0
- package/plugins/lisa-cursor/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/.claude-plugin/plugin.json +9 -0
- package/plugins/src/base/hooks/secrets-preflight.sh +72 -0
- package/plugins/src/base/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/src/base/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/src/base/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/scripts/generate-lisa-owned-hash-ledger.mjs +10 -1
- package/scripts/lib/per-agent-hook-filter.mjs +18 -0
- package/typescript/copy-contents/.husky/pre-commit +130 -21
- package/typescript/copy-contents/.husky/pre-push +202 -22
|
@@ -0,0 +1,1165 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// This file is managed by Lisa and IS replaced on each `lisa` run.
|
|
3
|
+
// Do not edit directly — durable changes belong upstream in Lisa.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The gate registry: what Lisa guarantees, and where each guarantee is proved.
|
|
7
|
+
*
|
|
8
|
+
* A **gate is a property**, not a tool. `gitleaks` is not a gate — *credential
|
|
9
|
+
* leakage* is the gate, and gitleaks is one way to prove it. Catalogued as
|
|
10
|
+
* tools, one guarantee appears three times under three names and nobody can see
|
|
11
|
+
* that a fourth is missing; catalogued as properties, "which guarantees rest on
|
|
12
|
+
* a single mechanism" has an answer.
|
|
13
|
+
*
|
|
14
|
+
* **Lisa owns the vocabulary; the project owns the implementation.** Lisa says
|
|
15
|
+
* "prove credential leakage"; the project says what that means by naming one of
|
|
16
|
+
* its own tasks. Swapping gitleaks for trufflehog changes one line of project
|
|
17
|
+
* config and nothing in Lisa.
|
|
18
|
+
*
|
|
19
|
+
* ## One axis: the moment
|
|
20
|
+
*
|
|
21
|
+
* A gate declares *when* it runs. Not *where* — the surface a session is on is
|
|
22
|
+
* detected, not declared, and asking someone to restate it in config is how you
|
|
23
|
+
* end up with `local`/`remote` in one subsystem and five real surfaces in
|
|
24
|
+
* another. What a gate needs in order to run is expressed as `needs`, and the
|
|
25
|
+
* surface falls out of wherever that moment happens to occur.
|
|
26
|
+
*
|
|
27
|
+
* ## Two proof modes
|
|
28
|
+
*
|
|
29
|
+
* `run` executes a task and reads its exit code. `await` watches for an
|
|
30
|
+
* external signal — a review bot, a SAST service — and reads its verdict. Which
|
|
31
|
+
* applies is the project's choice, not the gate's: Snyk can be a CLI you run or
|
|
32
|
+
* an app that reports. A gate may use different modes at different moments,
|
|
33
|
+
* which is the normal shape for code review: an agentic review before push, a
|
|
34
|
+
* bot on the pull request.
|
|
35
|
+
*
|
|
36
|
+
* ## Green is not proof
|
|
37
|
+
*
|
|
38
|
+
* The defect this design is organised against is a check reporting satisfied
|
|
39
|
+
* without having proved anything. It has two forms and both are handled here.
|
|
40
|
+
*
|
|
41
|
+
* A required `CodeRabbit` context posted `success` with the description
|
|
42
|
+
* "Review rate limited", having reviewed nothing, on two security-relevant pull
|
|
43
|
+
* requests that then merged. That is the `await` form, caught by description.
|
|
44
|
+
*
|
|
45
|
+
* `passWithNoTests: true` ships in five stack configs, so a unit-test gate can
|
|
46
|
+
* report green having run zero tests. That is the `run` form, caught by a work
|
|
47
|
+
* count. No description is involved and no vendor is at fault.
|
|
48
|
+
*
|
|
49
|
+
* The response is configurable, with one exception. You may configure whether
|
|
50
|
+
* to stop; you may not configure it into having been proved. A hollow result is
|
|
51
|
+
* recorded as unproved in every mode, or `on_hollow: report` becomes a way to
|
|
52
|
+
* launder an unreviewed merge.
|
|
53
|
+
* @module lisa-gates
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
57
|
+
import { join } from "node:path";
|
|
58
|
+
|
|
59
|
+
/** Enforcement levels a moment may carry. */
|
|
60
|
+
export const LEVELS = ["required", "optional", "off"];
|
|
61
|
+
|
|
62
|
+
/** What to do when a gate reports success without evidence of work. */
|
|
63
|
+
export const HOLLOW_RESPONSES = ["report", "wait", "block"];
|
|
64
|
+
|
|
65
|
+
/** Fixed moments. Two more families take an environment suffix. */
|
|
66
|
+
export const MOMENTS = [
|
|
67
|
+
"session-start",
|
|
68
|
+
"pre-tool",
|
|
69
|
+
"commit",
|
|
70
|
+
"push",
|
|
71
|
+
"pull-request",
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/** Moment families that take an `:<environment>` suffix. */
|
|
75
|
+
export const MOMENT_FAMILIES = ["pre-deploy", "post-deploy", "continuous"];
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Moments that gate a *state* rather than a change.
|
|
79
|
+
*
|
|
80
|
+
* Every other moment blocks a diff: the commit, the push, the merge, the
|
|
81
|
+
* deploy. A continuous gate has no diff to block — it runs on a schedule
|
|
82
|
+
* against a stable target, and by the time it fails whatever it covered merged
|
|
83
|
+
* hours ago. What it establishes is whether that target is healthy, and the
|
|
84
|
+
* enforcement point is therefore *promotion out of it*: a red
|
|
85
|
+
* `continuous:staging` means staging is not promotable, which a
|
|
86
|
+
* `pre-deploy:production` gate can require.
|
|
87
|
+
*
|
|
88
|
+
* TASC SI9 requires this directly for generated testing — confining generation
|
|
89
|
+
* to the per-change gate explores far less of the input space than generating
|
|
90
|
+
* new cases against a stable one. It applies equally to a CVE published today,
|
|
91
|
+
* which makes yesterday's dependency scan wrong with no change at all.
|
|
92
|
+
*/
|
|
93
|
+
export const STATE_FAMILIES = ["continuous"];
|
|
94
|
+
|
|
95
|
+
/** Keys on a gate entry that are settings rather than moments. */
|
|
96
|
+
export const GATE_FIELDS = new Set(["run", "needs", "task"]);
|
|
97
|
+
|
|
98
|
+
/** Prefix marking a gate, or a config key, that this project invented. */
|
|
99
|
+
export const CUSTOM_PREFIX = "x-";
|
|
100
|
+
|
|
101
|
+
const COMMIT_ONWARD = [
|
|
102
|
+
"commit",
|
|
103
|
+
"push",
|
|
104
|
+
"pull-request",
|
|
105
|
+
"pre-deploy",
|
|
106
|
+
"post-deploy",
|
|
107
|
+
];
|
|
108
|
+
const PUSH_ONWARD = ["push", "pull-request", "pre-deploy", "post-deploy"];
|
|
109
|
+
const PR_ONWARD = ["pull-request", "pre-deploy", "post-deploy"];
|
|
110
|
+
const PR_ONLY = ["pull-request"];
|
|
111
|
+
const DEPLOY_ONLY = ["pre-deploy", "post-deploy", "continuous"];
|
|
112
|
+
const CONTINUOUS_ONLY = ["continuous"];
|
|
113
|
+
const SESSION_ONWARD = ["session-start", ...COMMIT_ONWARD];
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Lisa's canonical gates.
|
|
117
|
+
*
|
|
118
|
+
* `label` is the CI job name and it is load-bearing: a repository ruleset names
|
|
119
|
+
* required checks by exact string, so this is what a branch-protection context
|
|
120
|
+
* is built from.
|
|
121
|
+
*
|
|
122
|
+
* `moments` is the closed set of places a gate may legally be declared. It
|
|
123
|
+
* exists to make wrong configurations unrepresentable rather than merely
|
|
124
|
+
* discouraged: type-aware lint at commit needs a whole-project build, and a
|
|
125
|
+
* DAST scan at commit has nothing deployed to point at.
|
|
126
|
+
*
|
|
127
|
+
* `work` names what a nonzero count proves, for gates that can otherwise
|
|
128
|
+
* succeed having done nothing.
|
|
129
|
+
*/
|
|
130
|
+
export const REGISTRY = Object.freeze({
|
|
131
|
+
"code-style": {
|
|
132
|
+
label: "🧹 Lint",
|
|
133
|
+
summary: "Code conforms to the project's lint rules.",
|
|
134
|
+
task: "lint",
|
|
135
|
+
moments: COMMIT_ONWARD,
|
|
136
|
+
},
|
|
137
|
+
"code-style-slow": {
|
|
138
|
+
label: "🐢 Slow Lint Rules",
|
|
139
|
+
summary: "Type-aware lint rules pass.",
|
|
140
|
+
task: "lint:slow",
|
|
141
|
+
moments: PUSH_ONWARD,
|
|
142
|
+
},
|
|
143
|
+
"format-conformance": {
|
|
144
|
+
label: "📐 Check Formatting",
|
|
145
|
+
summary: "Files match the project's formatter.",
|
|
146
|
+
task: "format:check",
|
|
147
|
+
moments: COMMIT_ONWARD,
|
|
148
|
+
},
|
|
149
|
+
"type-correctness": {
|
|
150
|
+
label: "🔍 Type Check",
|
|
151
|
+
summary: "The project compiles.",
|
|
152
|
+
task: "typecheck",
|
|
153
|
+
moments: PUSH_ONWARD,
|
|
154
|
+
},
|
|
155
|
+
"build-integrity": {
|
|
156
|
+
label: "🏗️ Build",
|
|
157
|
+
summary: "The project builds.",
|
|
158
|
+
task: "build",
|
|
159
|
+
moments: PUSH_ONWARD,
|
|
160
|
+
},
|
|
161
|
+
"test-correctness": {
|
|
162
|
+
label: "🧪 Run Unit Tests",
|
|
163
|
+
summary: "The unit suite passes.",
|
|
164
|
+
task: "test:unit",
|
|
165
|
+
moments: PUSH_ONWARD,
|
|
166
|
+
work: "tests run",
|
|
167
|
+
},
|
|
168
|
+
"test-integration": {
|
|
169
|
+
label: "🧪 Run Integration Tests",
|
|
170
|
+
summary: "The integration suite passes.",
|
|
171
|
+
task: "test:integration",
|
|
172
|
+
moments: PUSH_ONWARD,
|
|
173
|
+
work: "tests run",
|
|
174
|
+
},
|
|
175
|
+
"test-meaningfulness": {
|
|
176
|
+
label: "🧬 Mutation Testing Gate",
|
|
177
|
+
summary: "Tests fail when the code they cover is wrong.",
|
|
178
|
+
task: "test:mutation",
|
|
179
|
+
moments: PR_ONWARD,
|
|
180
|
+
work: "mutants generated",
|
|
181
|
+
},
|
|
182
|
+
"coverage-adequacy": {
|
|
183
|
+
label: "✅ Verification Coverage",
|
|
184
|
+
summary: "What must be proved is covered by something that runs.",
|
|
185
|
+
task: "test:coverage",
|
|
186
|
+
moments: PUSH_ONWARD,
|
|
187
|
+
work: "files measured",
|
|
188
|
+
},
|
|
189
|
+
"e2e-browser": {
|
|
190
|
+
label: "🎭 Playwright E2E Tests",
|
|
191
|
+
summary: "Browser journeys pass end to end.",
|
|
192
|
+
task: "test:e2e",
|
|
193
|
+
moments: [...PR_ONWARD, "continuous"],
|
|
194
|
+
work: "specs run",
|
|
195
|
+
},
|
|
196
|
+
"e2e-native": {
|
|
197
|
+
label: "📱 Maestro Native E2E",
|
|
198
|
+
summary: "Native device journeys pass end to end.",
|
|
199
|
+
task: "test:e2e:native",
|
|
200
|
+
moments: [...PR_ONWARD, "continuous"],
|
|
201
|
+
work: "flows run",
|
|
202
|
+
},
|
|
203
|
+
"generative-testing": {
|
|
204
|
+
label: "🎲 Generative Testing",
|
|
205
|
+
summary:
|
|
206
|
+
"Invariants hold against generated inputs, not just imagined ones.",
|
|
207
|
+
task: "test:property",
|
|
208
|
+
// Continuous is the point, not an option. TASC SI9: "at equal compute,
|
|
209
|
+
// re-running one suite against every change explores far less of the input
|
|
210
|
+
// space than generating new cases against a stable one." A property suite
|
|
211
|
+
// pinned to the per-change gate re-walks the same ground forever.
|
|
212
|
+
moments: [...PR_ONWARD, "continuous"],
|
|
213
|
+
work: "cases generated",
|
|
214
|
+
},
|
|
215
|
+
"structural-rules": {
|
|
216
|
+
label: "🔎 AST Grep Scan",
|
|
217
|
+
summary: "Structural rules lint cannot express are respected.",
|
|
218
|
+
task: "lint:structural",
|
|
219
|
+
// Commit-legal, corrected from push-onward against the evidence:
|
|
220
|
+
// `.lintstagedrc.json` already runs `ast-grep scan` on staged files at
|
|
221
|
+
// commit time. Declaring it push-onward made an enforcement that
|
|
222
|
+
// demonstrably happens unrepresentable in config — a registry that
|
|
223
|
+
// disagrees with the repository is worse than one that is merely
|
|
224
|
+
// permissive, because it silently discards a real gate.
|
|
225
|
+
moments: COMMIT_ONWARD,
|
|
226
|
+
work: "rules loaded",
|
|
227
|
+
},
|
|
228
|
+
"dead-code": {
|
|
229
|
+
label: "🗑️ Dead Code Detection",
|
|
230
|
+
summary: "No unused exports or dependencies.",
|
|
231
|
+
task: "check:dead-code",
|
|
232
|
+
moments: PUSH_ONWARD,
|
|
233
|
+
},
|
|
234
|
+
"credential-leakage": {
|
|
235
|
+
label: "🔐 Credential Leakage",
|
|
236
|
+
summary: "No secret enters the repository, an artifact, or a log.",
|
|
237
|
+
task: "security:check-for-leaks",
|
|
238
|
+
moments: COMMIT_ONWARD,
|
|
239
|
+
},
|
|
240
|
+
"dependency-vulnerability": {
|
|
241
|
+
label: "🔒 Security Scan",
|
|
242
|
+
summary: "No known high or critical advisory in shipped dependencies.",
|
|
243
|
+
task: "security:audit",
|
|
244
|
+
// Continuous matters most here: a CVE published today makes yesterday's
|
|
245
|
+
// green wrong with no change to trigger a re-scan.
|
|
246
|
+
moments: [...PUSH_ONWARD, "continuous"],
|
|
247
|
+
work: "manifests scanned",
|
|
248
|
+
},
|
|
249
|
+
"static-security": {
|
|
250
|
+
label: "🔍 Static Security Analysis",
|
|
251
|
+
summary: "Static analysis finds no security defect.",
|
|
252
|
+
task: "security:sast",
|
|
253
|
+
moments: [...PR_ONWARD, "continuous"],
|
|
254
|
+
work: "files analysed",
|
|
255
|
+
},
|
|
256
|
+
"runtime-web-vulnerability": {
|
|
257
|
+
label: "🕷️ DAST Baseline",
|
|
258
|
+
summary: "The running application passes a baseline dynamic scan.",
|
|
259
|
+
task: "security:dast",
|
|
260
|
+
moments: DEPLOY_ONLY,
|
|
261
|
+
work: "URLs scanned",
|
|
262
|
+
},
|
|
263
|
+
"license-compliance": {
|
|
264
|
+
label: "📜 Licence Check",
|
|
265
|
+
summary: "Every dependency licence is permitted.",
|
|
266
|
+
task: "security:licenses",
|
|
267
|
+
moments: PR_ONWARD,
|
|
268
|
+
},
|
|
269
|
+
"code-review": {
|
|
270
|
+
label: "👁️ Code Review",
|
|
271
|
+
summary: "The change was reviewed by something that read it.",
|
|
272
|
+
task: "review:local",
|
|
273
|
+
moments: ["push", "pull-request"],
|
|
274
|
+
},
|
|
275
|
+
"performance-budget": {
|
|
276
|
+
label: "⚡ Performance Budget",
|
|
277
|
+
summary: "Pages stay inside their performance budget.",
|
|
278
|
+
task: "perf:check",
|
|
279
|
+
moments: DEPLOY_ONLY,
|
|
280
|
+
work: "pages measured",
|
|
281
|
+
},
|
|
282
|
+
"load-capacity": {
|
|
283
|
+
label: "📈 Load Capacity",
|
|
284
|
+
summary: "The service holds up under its expected load.",
|
|
285
|
+
task: "perf:load",
|
|
286
|
+
moments: DEPLOY_ONLY,
|
|
287
|
+
work: "requests issued",
|
|
288
|
+
},
|
|
289
|
+
accessibility: {
|
|
290
|
+
label: "♿ Accessibility",
|
|
291
|
+
summary: "Pages meet the declared accessibility standard.",
|
|
292
|
+
task: "a11y:check",
|
|
293
|
+
moments: DEPLOY_ONLY,
|
|
294
|
+
work: "pages audited",
|
|
295
|
+
},
|
|
296
|
+
traceability: {
|
|
297
|
+
label: "🔗 Work-Item Traceability",
|
|
298
|
+
summary: "Every change is bound to a live tracker item.",
|
|
299
|
+
task: "check:work-item",
|
|
300
|
+
moments: PR_ONLY,
|
|
301
|
+
},
|
|
302
|
+
"commit-conformance": {
|
|
303
|
+
label: "📝 Commit Message",
|
|
304
|
+
summary: "Commit messages follow the declared convention.",
|
|
305
|
+
task: "check:commit-msg",
|
|
306
|
+
moments: COMMIT_ONWARD,
|
|
307
|
+
},
|
|
308
|
+
"threshold-monotonicity": {
|
|
309
|
+
label: "📐 Threshold Ratchet",
|
|
310
|
+
summary: "Quality thresholds may tighten, never loosen.",
|
|
311
|
+
task: "check:thresholds",
|
|
312
|
+
moments: PUSH_ONWARD,
|
|
313
|
+
},
|
|
314
|
+
"artifact-freshness": {
|
|
315
|
+
label: "🧾 Generated Artifacts",
|
|
316
|
+
summary: "Generated files match the source they describe.",
|
|
317
|
+
task: "check:artifacts",
|
|
318
|
+
moments: COMMIT_ONWARD,
|
|
319
|
+
},
|
|
320
|
+
"conflict-residue": {
|
|
321
|
+
label: "🩹 Conflict Markers",
|
|
322
|
+
summary: "No leftover merge-conflict markers in tracked files.",
|
|
323
|
+
task: "check:conflict-markers",
|
|
324
|
+
moments: COMMIT_ONWARD,
|
|
325
|
+
},
|
|
326
|
+
"version-duplication": {
|
|
327
|
+
label: "🧮 Duplicate Versions",
|
|
328
|
+
summary: "One declared version per dependency.",
|
|
329
|
+
task: "check:duplicate-versions",
|
|
330
|
+
moments: COMMIT_ONWARD,
|
|
331
|
+
},
|
|
332
|
+
"credential-availability": {
|
|
333
|
+
label: "🔑 Credential Readiness",
|
|
334
|
+
summary: "Every credential the work needs resolves before work is claimed.",
|
|
335
|
+
task: "readiness:secrets",
|
|
336
|
+
moments: SESSION_ONWARD,
|
|
337
|
+
},
|
|
338
|
+
"tool-availability": {
|
|
339
|
+
label: "🧰 Tooling Readiness",
|
|
340
|
+
summary: "Every CLI the work needs is present at its required version.",
|
|
341
|
+
task: "readiness:tools",
|
|
342
|
+
moments: SESSION_ONWARD,
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Guarantees the platform enforces, which no task can implement.
|
|
348
|
+
*
|
|
349
|
+
* These were briefly modelled as gates with `implementation: "lisa"`, which was
|
|
350
|
+
* a category error: nothing runs and nothing produces a verdict. They are
|
|
351
|
+
* repository *policy* — a declared state that has either drifted or not — and
|
|
352
|
+
* belong in the `policy` block, where the response to a violation is to repair
|
|
353
|
+
* the setting rather than to block a change.
|
|
354
|
+
*/
|
|
355
|
+
export const POLICY_ENFORCED = Object.freeze([
|
|
356
|
+
"review-completion",
|
|
357
|
+
"branch-protection",
|
|
358
|
+
"up-to-date-before-merge",
|
|
359
|
+
"linear-history",
|
|
360
|
+
"signed-commits",
|
|
361
|
+
]);
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Gates enforced by intercepting an action before it happens.
|
|
365
|
+
*
|
|
366
|
+
* A task cannot implement these: by the time one could run, the thing it
|
|
367
|
+
* prevents has already run. The project may say where they are enforced; it may
|
|
368
|
+
* not say what implements them.
|
|
369
|
+
*/
|
|
370
|
+
export const INTERCEPTORS = Object.freeze({
|
|
371
|
+
"verification-bypass": "Verification hooks cannot be disabled.",
|
|
372
|
+
"destructive-safety": "Destructive commands are refused where irreversible.",
|
|
373
|
+
"instruction-integrity": "Agents cannot rewrite their instruction files.",
|
|
374
|
+
"orchestration-conformance": "Lifecycle flows follow required orchestration.",
|
|
375
|
+
"structured-data-handling":
|
|
376
|
+
"Structured formats are parsed with real parsers.",
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* The shape every gate's implementation emits, whatever tool produced it.
|
|
381
|
+
*
|
|
382
|
+
* The task name alone is not an interface. If Lisa standardises only *how to
|
|
383
|
+
* invoke*, the attestation records "test:cov passed" and cites nothing — true
|
|
384
|
+
* of a scrupulous project as much as a careless one. Standardising what comes
|
|
385
|
+
* *back* is what lets jest and vitest both satisfy `coverage-adequacy`, and
|
|
386
|
+
* what lets the register quote a number instead of a verdict.
|
|
387
|
+
*
|
|
388
|
+
* Four fields, each earning its place:
|
|
389
|
+
*
|
|
390
|
+
* - `status` — three-valued. `unknown` is not a synonym for `fail`.
|
|
391
|
+
* - `work` — the count that proves it RAN. `null` forces `unknown`, which is
|
|
392
|
+
* the `passWithNoTests` hole closed structurally rather than by vigilance.
|
|
393
|
+
* - `measures` — what an attestation actually cites.
|
|
394
|
+
* - `prover` — which implementation produced it. Vendor-neutrality means Lisa
|
|
395
|
+
* does not *mandate* a tool, not that it declines to *record* one; an auditor
|
|
396
|
+
* asking "what measured this?" deserves an answer.
|
|
397
|
+
*
|
|
398
|
+
* `observed_at` and `max_age_minutes` exist for continuous gates. A gate tied
|
|
399
|
+
* to a change carries inherently fresh evidence — it ran on that diff. A gate
|
|
400
|
+
* that runs on a schedule does not, and a green from six days ago proves
|
|
401
|
+
* nothing about today. Evidence read past its bound yields `unknown`, never
|
|
402
|
+
* `pass`: a scheduler that quietly died must block promotion rather than let
|
|
403
|
+
* last week's result stand in for this week's.
|
|
404
|
+
*/
|
|
405
|
+
export const EVIDENCE_FIELDS = Object.freeze({
|
|
406
|
+
gate: "string",
|
|
407
|
+
status: "pass | fail | unknown",
|
|
408
|
+
work: "number | null",
|
|
409
|
+
measures: "object",
|
|
410
|
+
prover: "{ tool, version }",
|
|
411
|
+
observed_at: "ISO-8601",
|
|
412
|
+
max_age_minutes: "number | null",
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
/** Verdict an evidence envelope can carry. */
|
|
416
|
+
export const EVIDENCE_STATUS = ["pass", "fail", "unknown"];
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Read a verdict from an evidence envelope, honouring work and freshness.
|
|
420
|
+
*
|
|
421
|
+
* Two demotions to `unknown`, both deliberate, both cases where the naive read
|
|
422
|
+
* is `pass`:
|
|
423
|
+
*
|
|
424
|
+
* - the gate declares a work count and the implementation emitted none, so
|
|
425
|
+
* nothing shows it ran;
|
|
426
|
+
* - the evidence is older than its own bound, so it describes a state that may
|
|
427
|
+
* no longer exist.
|
|
428
|
+
*
|
|
429
|
+
* Neither is a failure and neither is a pass. Collapsing either into `pass` is
|
|
430
|
+
* the defect this whole subsystem exists to prevent; collapsing either into
|
|
431
|
+
* `fail` blames a project for a gap in observation.
|
|
432
|
+
* @param {object} evidence The emitted envelope.
|
|
433
|
+
* @param {object} [definition] Registry entry, for whether work is expected.
|
|
434
|
+
* @param {number} [nowMs] Clock, injected for tests.
|
|
435
|
+
* @returns {{status: string, reason: string|null}} Effective verdict.
|
|
436
|
+
*/
|
|
437
|
+
export function readEvidence(evidence, definition = {}, nowMs = Date.now()) {
|
|
438
|
+
if (!evidence || !EVIDENCE_STATUS.includes(evidence.status)) {
|
|
439
|
+
return { status: "unknown", reason: "no evidence was emitted" };
|
|
440
|
+
}
|
|
441
|
+
if (evidence.status !== "pass") {
|
|
442
|
+
return { status: evidence.status, reason: null };
|
|
443
|
+
}
|
|
444
|
+
if (definition.work && (evidence.work ?? null) === null) {
|
|
445
|
+
return {
|
|
446
|
+
status: "unknown",
|
|
447
|
+
reason: `reported pass but no ${definition.work} count, so nothing shows it ran`,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
const bound = evidence.max_age_minutes;
|
|
451
|
+
if (bound && evidence.observed_at) {
|
|
452
|
+
const ageMinutes = (nowMs - Date.parse(evidence.observed_at)) / 60000;
|
|
453
|
+
if (Number.isFinite(ageMinutes) && ageMinutes > bound) {
|
|
454
|
+
return {
|
|
455
|
+
status: "unknown",
|
|
456
|
+
reason: `evidence is ${Math.round(ageMinutes)} minutes old, past its ${bound}-minute bound`,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return { status: "pass", reason: null };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Description phrases that grant or deny credit for an awaited signal. */
|
|
464
|
+
export const EVIDENCE_DEFAULTS = Object.freeze({
|
|
465
|
+
// Matched strictly — whole description, case-insensitive — because a match
|
|
466
|
+
// GRANTS credit and a substring would let "review skipped" satisfy "review".
|
|
467
|
+
proof: Object.freeze([
|
|
468
|
+
"review approved",
|
|
469
|
+
"review completed",
|
|
470
|
+
"changes requested",
|
|
471
|
+
"comments posted",
|
|
472
|
+
]),
|
|
473
|
+
// Matched loosely as substrings because a match DENIES credit, and the vendor
|
|
474
|
+
// decorates its own strings: "Review rate limited (retry in 12m)".
|
|
475
|
+
no_work: Object.freeze([
|
|
476
|
+
"rate limited",
|
|
477
|
+
"review queued",
|
|
478
|
+
"review skipped",
|
|
479
|
+
"skipped",
|
|
480
|
+
"queued",
|
|
481
|
+
"waiting",
|
|
482
|
+
"in progress",
|
|
483
|
+
"no review",
|
|
484
|
+
"disabled",
|
|
485
|
+
"quota",
|
|
486
|
+
"billing",
|
|
487
|
+
]),
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Repository policy Lisa asserts.
|
|
492
|
+
*
|
|
493
|
+
* Policy differs from a gate in what failure means. A gate failing says stop
|
|
494
|
+
* the change; a policy having drifted says put the setting back. That is why
|
|
495
|
+
* `on_drift` defaults to repair and a gate never does.
|
|
496
|
+
*/
|
|
497
|
+
export const POLICY_SCHEMA = Object.freeze({
|
|
498
|
+
merge: Object.freeze({
|
|
499
|
+
squash: "boolean",
|
|
500
|
+
merge_commit: "boolean",
|
|
501
|
+
rebase: "boolean",
|
|
502
|
+
auto_merge: "boolean",
|
|
503
|
+
delete_branch_on_merge: "boolean",
|
|
504
|
+
allow_update_branch: "boolean",
|
|
505
|
+
}),
|
|
506
|
+
history: Object.freeze({
|
|
507
|
+
linear: "boolean",
|
|
508
|
+
signed_commits: "boolean",
|
|
509
|
+
commit_signoff: "boolean",
|
|
510
|
+
}),
|
|
511
|
+
protect: Object.freeze({
|
|
512
|
+
force_push: "boolean",
|
|
513
|
+
deletion: "boolean",
|
|
514
|
+
up_to_date_before_merge: "boolean",
|
|
515
|
+
conversation_resolution: "boolean",
|
|
516
|
+
dismiss_stale_reviews: "boolean",
|
|
517
|
+
require_last_push_approval: "boolean",
|
|
518
|
+
}),
|
|
519
|
+
repository: Object.freeze({
|
|
520
|
+
has_issues: "boolean",
|
|
521
|
+
has_wiki: "boolean",
|
|
522
|
+
default_branch: "string",
|
|
523
|
+
}),
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
/** How to respond when reality has drifted from declared policy. */
|
|
527
|
+
export const DRIFT_RESPONSES = ["repair", "report", "block"];
|
|
528
|
+
|
|
529
|
+
/** Top-level `.lisa.config.json` keys Lisa reads. */
|
|
530
|
+
export const KNOWN_CONFIG_KEYS = Object.freeze([
|
|
531
|
+
"harness",
|
|
532
|
+
"tracker",
|
|
533
|
+
"source",
|
|
534
|
+
"atlassian",
|
|
535
|
+
"jira",
|
|
536
|
+
"confluence",
|
|
537
|
+
"github",
|
|
538
|
+
"notion",
|
|
539
|
+
"linear",
|
|
540
|
+
"deploy",
|
|
541
|
+
"quality",
|
|
542
|
+
"gates",
|
|
543
|
+
"policy",
|
|
544
|
+
"intake",
|
|
545
|
+
"monitor",
|
|
546
|
+
"secrets",
|
|
547
|
+
"remoteEnv",
|
|
548
|
+
"automations",
|
|
549
|
+
"usage",
|
|
550
|
+
"wiki",
|
|
551
|
+
"learnings",
|
|
552
|
+
"health",
|
|
553
|
+
"verification",
|
|
554
|
+
]);
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Keys Lisa used to read, with what to say about each.
|
|
558
|
+
*
|
|
559
|
+
* Distinguished from merely unrecognised keys because Lisa *knows* what these
|
|
560
|
+
* were and can say something useful. An unrecognised key might be a typo or a
|
|
561
|
+
* deliberate annotation; a retired one is neither.
|
|
562
|
+
*/
|
|
563
|
+
export const RETIRED_CONFIG_KEYS = Object.freeze({
|
|
564
|
+
projectRulesFile:
|
|
565
|
+
"retired — host rules now live in the fixed directory .agents/rules/. " +
|
|
566
|
+
"The key is still parsed so installed projects keep applying, but nothing " +
|
|
567
|
+
"serves rules from it.",
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Read the config, splitting the runner out of the gates block.
|
|
572
|
+
* @param {string} [cwd] Directory to look in.
|
|
573
|
+
* @returns {{runner: string, gates: object, policy: object, config: object}} Parsed config.
|
|
574
|
+
*/
|
|
575
|
+
export function readGates(cwd = process.cwd()) {
|
|
576
|
+
const path = join(cwd, ".lisa.config.json");
|
|
577
|
+
if (!existsSync(path)) {
|
|
578
|
+
return { runner: "npm run", gates: {}, policy: {}, config: {} };
|
|
579
|
+
}
|
|
580
|
+
let config;
|
|
581
|
+
try {
|
|
582
|
+
config = JSON.parse(readFileSync(path, "utf8"));
|
|
583
|
+
} catch (err) {
|
|
584
|
+
throw new Error(`.lisa.config.json is not readable: ${err.message}`);
|
|
585
|
+
}
|
|
586
|
+
const { runner = "npm run", ...gates } = config.gates ?? {};
|
|
587
|
+
return { runner, gates, policy: config.policy ?? {}, config };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Whether a moment string is one Lisa understands.
|
|
592
|
+
* @param {string} moment Candidate moment.
|
|
593
|
+
* @returns {boolean} True when well-formed.
|
|
594
|
+
*/
|
|
595
|
+
export function isMoment(moment) {
|
|
596
|
+
if (MOMENTS.includes(moment)) return true;
|
|
597
|
+
const [family, environment] = moment.split(":");
|
|
598
|
+
return MOMENT_FAMILIES.includes(family) && Boolean(environment);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* The family a moment belongs to, for comparison against a gate's legal set.
|
|
603
|
+
* @param {string} moment A well-formed moment.
|
|
604
|
+
* @returns {string} `pre-deploy` for `pre-deploy:production`, else the moment.
|
|
605
|
+
*/
|
|
606
|
+
export function momentFamily(moment) {
|
|
607
|
+
const [family] = moment.split(":");
|
|
608
|
+
return MOMENT_FAMILIES.includes(family) ? family : moment;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Validate the gates block, returning every problem rather than the first.
|
|
613
|
+
* @param {object} gates The gates block, runner already removed.
|
|
614
|
+
* @returns {string[]} Problems, empty when valid.
|
|
615
|
+
*/
|
|
616
|
+
export function validateGates(gates) {
|
|
617
|
+
const problems = [];
|
|
618
|
+
for (const [id, gate] of Object.entries(gates ?? {})) {
|
|
619
|
+
problems.push(...validateGate(id, gate));
|
|
620
|
+
}
|
|
621
|
+
return problems;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Validate one gate entry.
|
|
626
|
+
* @param {string} id Gate id.
|
|
627
|
+
* @param {object} gate The entry.
|
|
628
|
+
* @returns {string[]} Problems.
|
|
629
|
+
*/
|
|
630
|
+
function validateGate(id, gate) {
|
|
631
|
+
const problems = [];
|
|
632
|
+
const known = Object.hasOwn(REGISTRY, id);
|
|
633
|
+
const interceptor = Object.hasOwn(INTERCEPTORS, id);
|
|
634
|
+
|
|
635
|
+
if (POLICY_ENFORCED.includes(id)) {
|
|
636
|
+
return [
|
|
637
|
+
`gates."${id}" is repository policy, not a gate — nothing runs and ` +
|
|
638
|
+
`nothing produces a verdict. Declare it under "policy" instead, where ` +
|
|
639
|
+
`drift is repaired rather than blocking a change.`,
|
|
640
|
+
];
|
|
641
|
+
}
|
|
642
|
+
if (!known && !interceptor && !id.startsWith(CUSTOM_PREFIX)) {
|
|
643
|
+
const near = nearest(id, [
|
|
644
|
+
...Object.keys(REGISTRY),
|
|
645
|
+
...Object.keys(INTERCEPTORS),
|
|
646
|
+
]);
|
|
647
|
+
return [
|
|
648
|
+
`gates."${id}" is not a gate Lisa knows` +
|
|
649
|
+
(near ? `. Did you mean "${near}"?` : "") +
|
|
650
|
+
` Prefix a gate of your own with "${CUSTOM_PREFIX}" — Lisa will run ` +
|
|
651
|
+
`it without pretending to understand it.`,
|
|
652
|
+
];
|
|
653
|
+
}
|
|
654
|
+
if (!gate || typeof gate !== "object" || Array.isArray(gate)) {
|
|
655
|
+
return [`gates."${id}" must be an object`];
|
|
656
|
+
}
|
|
657
|
+
if (interceptor && gate.run) {
|
|
658
|
+
problems.push(
|
|
659
|
+
`gates."${id}" names a task, but this gate intercepts an action before ` +
|
|
660
|
+
`it happens and cannot be delegated — by the time a task could run, ` +
|
|
661
|
+
`the thing it prevents has already run.`
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
problems.push(...validateNeeds(id, gate));
|
|
665
|
+
|
|
666
|
+
for (const [moment, value] of Object.entries(gate)) {
|
|
667
|
+
// A key that is neither a known field nor a well-formed moment is a typo,
|
|
668
|
+
// and skipping it silently is the hole this validator exists to close:
|
|
669
|
+
// `pull_request` with an underscore reads as a configured gate and enables
|
|
670
|
+
// nothing at all. Same shape as a misspelled gate id, one level down.
|
|
671
|
+
if (!isMoment(moment)) {
|
|
672
|
+
if (!GATE_FIELDS.has(moment)) {
|
|
673
|
+
const near = nearest(moment, [...MOMENTS, ...MOMENT_FAMILIES]);
|
|
674
|
+
problems.push(
|
|
675
|
+
`gates."${id}"."${moment}" is not a moment Lisa knows` +
|
|
676
|
+
(near ? `. Did you mean "${near}"?` : "") +
|
|
677
|
+
` Nothing runs at it, so whatever it was meant to enable is off.`
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
problems.push(
|
|
683
|
+
...validateMoment(id, moment, value, known, interceptor, gate.run)
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
return problems;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Validate one moment entry on a gate.
|
|
691
|
+
* @param {string} id Gate id.
|
|
692
|
+
* @param {string} moment The moment key.
|
|
693
|
+
* @param {*} value Level string, or a prover object.
|
|
694
|
+
* @param {boolean} known Whether the gate is in the registry.
|
|
695
|
+
* @param {boolean} interceptor Whether the gate intercepts.
|
|
696
|
+
* @param {string} [gateRun] A task declared once for the whole gate.
|
|
697
|
+
* @returns {string[]} Problems.
|
|
698
|
+
*/
|
|
699
|
+
function validateMoment(id, moment, value, known, interceptor, gateRun) {
|
|
700
|
+
const problems = [];
|
|
701
|
+
const entry = typeof value === "string" ? { level: value } : (value ?? {});
|
|
702
|
+
|
|
703
|
+
if (!LEVELS.includes(entry.level)) {
|
|
704
|
+
problems.push(
|
|
705
|
+
`gates."${id}"."${moment}" has level ${JSON.stringify(entry.level)}; ` +
|
|
706
|
+
`expected ${LEVELS.join(", ")}`
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
if (known && !REGISTRY[id].moments.includes(momentFamily(moment))) {
|
|
710
|
+
problems.push(
|
|
711
|
+
`gates."${id}" cannot run at "${moment}". ${REGISTRY[id].summary} ` +
|
|
712
|
+
`Legal moments: ${REGISTRY[id].moments.join(", ")}.`
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
if (entry.await) {
|
|
716
|
+
if (["commit", "push", "session-start", "pre-tool"].includes(moment)) {
|
|
717
|
+
problems.push(
|
|
718
|
+
`gates."${id}"."${moment}" awaits "${entry.await}", but there is no ` +
|
|
719
|
+
`pull request yet for a signal to post against. An awaited check ` +
|
|
720
|
+
`that can never fire is a declared guarantee that never runs.`
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
if (entry.run) {
|
|
724
|
+
problems.push(
|
|
725
|
+
`gates."${id}"."${moment}" declares both run and await; a moment has ` +
|
|
726
|
+
`one prover.`
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
problems.push(...validateEvidence(id, moment, entry.evidence ?? {}));
|
|
730
|
+
}
|
|
731
|
+
if (!known && !interceptor && !entry.await && !entry.run && !gateRun) {
|
|
732
|
+
problems.push(
|
|
733
|
+
`gates."${id}"."${moment}" names no prover and Lisa has no default task ` +
|
|
734
|
+
`for a custom gate, so nothing would execute.`
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
return problems;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Validate an awaited signal's evidence block.
|
|
742
|
+
* @param {string} id Gate id.
|
|
743
|
+
* @param {string} moment Moment key.
|
|
744
|
+
* @param {object} evidence The evidence block.
|
|
745
|
+
* @returns {string[]} Problems.
|
|
746
|
+
*/
|
|
747
|
+
function validateEvidence(id, moment, evidence) {
|
|
748
|
+
const problems = [];
|
|
749
|
+
const where = `gates."${id}"."${moment}".evidence`;
|
|
750
|
+
const response = evidence.on_hollow ?? "report";
|
|
751
|
+
|
|
752
|
+
if (!HOLLOW_RESPONSES.includes(response)) {
|
|
753
|
+
problems.push(
|
|
754
|
+
`${where}.on_hollow is ${JSON.stringify(response)}; expected ` +
|
|
755
|
+
`${HOLLOW_RESPONSES.join(", ")}`
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
if (response === "wait") {
|
|
759
|
+
// An unbounded wait is a pull request blocked with no signal, and the
|
|
760
|
+
// fastest way out is deleting the requirement — which is how a gate ends
|
|
761
|
+
// up removed rather than satisfied.
|
|
762
|
+
if (!Number.isFinite(evidence.wait_minutes) || evidence.wait_minutes <= 0) {
|
|
763
|
+
problems.push(
|
|
764
|
+
`${where}.on_hollow is "wait" but wait_minutes is not a positive ` +
|
|
765
|
+
`number. An unbounded wait blocks the pull request with no signal.`
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
if (!HOLLOW_RESPONSES.includes(evidence.on_timeout ?? "block")) {
|
|
769
|
+
problems.push(
|
|
770
|
+
`${where}.on_timeout is ${JSON.stringify(evidence.on_timeout)}; ` +
|
|
771
|
+
`expected ${HOLLOW_RESPONSES.join(", ")}`
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
for (const field of ["proof", "no_work"]) {
|
|
776
|
+
if (evidence[field] !== undefined && !Array.isArray(evidence[field])) {
|
|
777
|
+
problems.push(`${where}.${field} must be an array of phrases`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return problems;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Validate a gate's declared needs.
|
|
785
|
+
* @param {string} id Gate id.
|
|
786
|
+
* @param {object} gate The entry.
|
|
787
|
+
* @returns {string[]} Problems.
|
|
788
|
+
*/
|
|
789
|
+
function validateNeeds(id, gate) {
|
|
790
|
+
const problems = [];
|
|
791
|
+
if (gate.needs === undefined) return problems;
|
|
792
|
+
if (typeof gate.needs !== "object" || Array.isArray(gate.needs)) {
|
|
793
|
+
return [`gates."${id}".needs must be an object with tools and/or secrets`];
|
|
794
|
+
}
|
|
795
|
+
for (const field of ["tools", "secrets"]) {
|
|
796
|
+
if (gate.needs[field] === undefined) continue;
|
|
797
|
+
if (!Array.isArray(gate.needs[field])) {
|
|
798
|
+
problems.push(`gates."${id}".needs.${field} must be an array of names`);
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
if (field !== "secrets") continue;
|
|
802
|
+
for (const name of gate.needs.secrets) {
|
|
803
|
+
if (typeof name !== "string" || !/^[A-Z][A-Z0-9_]*$/.test(name)) {
|
|
804
|
+
problems.push(
|
|
805
|
+
`gates."${id}".needs.secrets entry ${JSON.stringify(name)} is not ` +
|
|
806
|
+
`an exact UPPER_SNAKE_CASE variable name. Lookup is never fuzzy.`
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
return problems;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Validate the policy block.
|
|
816
|
+
* @param {object} policy The policy block.
|
|
817
|
+
* @returns {string[]} Problems.
|
|
818
|
+
*/
|
|
819
|
+
export function validatePolicy(policy) {
|
|
820
|
+
const problems = [];
|
|
821
|
+
if (!policy || typeof policy !== "object") return problems;
|
|
822
|
+
|
|
823
|
+
const response = policy.on_drift ?? "repair";
|
|
824
|
+
if (!DRIFT_RESPONSES.includes(response)) {
|
|
825
|
+
problems.push(
|
|
826
|
+
`policy.on_drift is ${JSON.stringify(response)}; expected ` +
|
|
827
|
+
`${DRIFT_RESPONSES.join(", ")}`
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
for (const [section, fields] of Object.entries(policy)) {
|
|
831
|
+
if (section === "on_drift") continue;
|
|
832
|
+
if (!Object.hasOwn(POLICY_SCHEMA, section)) {
|
|
833
|
+
const near = nearest(section, Object.keys(POLICY_SCHEMA));
|
|
834
|
+
problems.push(
|
|
835
|
+
`policy."${section}" is not a policy section Lisa knows` +
|
|
836
|
+
(near ? `. Did you mean "${near}"?` : "")
|
|
837
|
+
);
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
if (!fields || typeof fields !== "object") {
|
|
841
|
+
problems.push(`policy."${section}" must be an object`);
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
845
|
+
const expected = POLICY_SCHEMA[section][field];
|
|
846
|
+
if (!expected) {
|
|
847
|
+
const near = nearest(field, Object.keys(POLICY_SCHEMA[section]));
|
|
848
|
+
problems.push(
|
|
849
|
+
`policy.${section}."${field}" is not a setting Lisa manages` +
|
|
850
|
+
(near ? `. Did you mean "${near}"?` : "")
|
|
851
|
+
);
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
if (typeof value !== expected) {
|
|
855
|
+
problems.push(
|
|
856
|
+
`policy.${section}.${field} must be a ${expected}, got ${typeof value}`
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
return problems;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Report config keys Lisa does not read.
|
|
866
|
+
*
|
|
867
|
+
* Unknown fields used to be preserved silently on round-trip, which meant a
|
|
868
|
+
* typo looked exactly like configuration and did nothing: `"trackr": "github"`
|
|
869
|
+
* produced no error, no warning, and every vendor-neutral skill failing with
|
|
870
|
+
* "'tracker' is not set". Reported rather than deleted, because you cannot know
|
|
871
|
+
* a key a human wrote was meaningless.
|
|
872
|
+
* @param {object} config Parsed config root.
|
|
873
|
+
* @returns {Array<{key: string, level: string, message: string}>} Findings.
|
|
874
|
+
*/
|
|
875
|
+
export function auditConfigKeys(config) {
|
|
876
|
+
const findings = [];
|
|
877
|
+
for (const key of Object.keys(config ?? {})) {
|
|
878
|
+
// `_` is Lisa's own metadata namespace (`_lisaSync`); `x-` is the
|
|
879
|
+
// project's. Both are deliberate, neither is a typo.
|
|
880
|
+
if (key.startsWith("_") || key.startsWith(CUSTOM_PREFIX)) continue;
|
|
881
|
+
if (Object.hasOwn(RETIRED_CONFIG_KEYS, key)) {
|
|
882
|
+
findings.push({
|
|
883
|
+
key,
|
|
884
|
+
level: "warn",
|
|
885
|
+
message: `${key} is ${RETIRED_CONFIG_KEYS[key]}`,
|
|
886
|
+
});
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
if (KNOWN_CONFIG_KEYS.includes(key)) continue;
|
|
890
|
+
const near = nearest(key, KNOWN_CONFIG_KEYS);
|
|
891
|
+
findings.push({
|
|
892
|
+
key,
|
|
893
|
+
level: "warn",
|
|
894
|
+
message:
|
|
895
|
+
`${key} is not a key Lisa reads` +
|
|
896
|
+
(near ? `. Did you mean "${near}"?` : "") +
|
|
897
|
+
` Nothing consumes it, so whatever it was meant to configure is unset.`,
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
return findings;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Resolve which gates run at one moment, and how.
|
|
905
|
+
* @param {object} options Resolution inputs.
|
|
906
|
+
* @param {object} options.gates The gates block.
|
|
907
|
+
* @param {string} options.moment The moment to resolve.
|
|
908
|
+
* @param {string} [options.runner] Task-runner prefix.
|
|
909
|
+
* @returns {Array<{id: string, level: string, mode: string, awaits: string|null, task: string|null, command: string|null, label: string, work: string|null, evidence: {proof: string[], no_work: string[], on_hollow: string, wait_minutes: number|null, on_timeout: string}|null}>} Resolved provers, sorted by gate id.
|
|
910
|
+
*/
|
|
911
|
+
export function resolveMoment({ gates, moment, runner = "npm run" }) {
|
|
912
|
+
const resolved = [];
|
|
913
|
+
for (const [id, gate] of Object.entries(gates ?? {})) {
|
|
914
|
+
const raw = gate?.[moment];
|
|
915
|
+
if (raw === undefined) continue;
|
|
916
|
+
const entry = typeof raw === "string" ? { level: raw } : raw;
|
|
917
|
+
if (!entry?.level || entry.level === "off") continue;
|
|
918
|
+
|
|
919
|
+
const definition = REGISTRY[id];
|
|
920
|
+
const task = entry.run ?? gate.run ?? definition?.task ?? null;
|
|
921
|
+
const intercepts = Object.hasOwn(INTERCEPTORS, id);
|
|
922
|
+
|
|
923
|
+
resolved.push({
|
|
924
|
+
id,
|
|
925
|
+
level: entry.level,
|
|
926
|
+
mode: entry.await ? "await" : intercepts ? "intercept" : "run",
|
|
927
|
+
awaits: entry.await ?? null,
|
|
928
|
+
task: entry.await || intercepts ? null : task,
|
|
929
|
+
command: entry.await || intercepts || !task ? null : `${runner} ${task}`,
|
|
930
|
+
label: definition?.label ?? id,
|
|
931
|
+
work: definition?.work ?? null,
|
|
932
|
+
evidence: entry.await ? mergeEvidence(entry.evidence) : null,
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
return resolved.sort((left, right) => left.id.localeCompare(right.id));
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Merge a project's evidence phrases onto the defaults.
|
|
940
|
+
*
|
|
941
|
+
* Extend, never replace. Removing a `no_work` phrase narrows detection with no
|
|
942
|
+
* signal that it was narrowed — a green that looks reviewed and is not. A
|
|
943
|
+
* shipped default that misfires for some vendor is an upstream bug to fix once,
|
|
944
|
+
* not something each project suppresses locally.
|
|
945
|
+
* @param {object} [evidence] Project evidence block.
|
|
946
|
+
* @returns {object} Merged evidence.
|
|
947
|
+
*/
|
|
948
|
+
function mergeEvidence(evidence = {}) {
|
|
949
|
+
return {
|
|
950
|
+
proof: [...EVIDENCE_DEFAULTS.proof, ...(evidence.proof ?? [])],
|
|
951
|
+
no_work: [...EVIDENCE_DEFAULTS.no_work, ...(evidence.no_work ?? [])],
|
|
952
|
+
on_hollow: evidence.on_hollow ?? "report",
|
|
953
|
+
wait_minutes: evidence.wait_minutes ?? null,
|
|
954
|
+
on_timeout: evidence.on_timeout ?? "block",
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* The tools and credentials the gates at one moment require.
|
|
960
|
+
*
|
|
961
|
+
* This replaces guessing a floor from `tracker` and `secrets.provider`. A gate
|
|
962
|
+
* states what it needs; whatever runs at this moment therefore needs the union.
|
|
963
|
+
* Surface never enters into it — the requirement follows the work.
|
|
964
|
+
* @param {object} options Resolution inputs.
|
|
965
|
+
* @param {object} options.gates The gates block.
|
|
966
|
+
* @param {string} options.moment The moment to resolve.
|
|
967
|
+
* @returns {{tools: string[], secrets: string[], reasons: Record<string, string>}} Union.
|
|
968
|
+
*/
|
|
969
|
+
export function needsAt({ gates, moment }) {
|
|
970
|
+
const tools = new Set();
|
|
971
|
+
const secrets = new Set();
|
|
972
|
+
const reasons = {};
|
|
973
|
+
for (const gate of resolveMoment({ gates, moment })) {
|
|
974
|
+
const declared = gates[gate.id]?.needs ?? {};
|
|
975
|
+
for (const tool of declared.tools ?? []) {
|
|
976
|
+
tools.add(tool);
|
|
977
|
+
reasons[tool] = `the ${gate.id} gate runs at ${moment}`;
|
|
978
|
+
}
|
|
979
|
+
for (const secret of declared.secrets ?? []) {
|
|
980
|
+
secrets.add(secret);
|
|
981
|
+
reasons[secret] = `the ${gate.id} gate runs at ${moment}`;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return {
|
|
985
|
+
tools: [...tools].sort((a, b) => a.localeCompare(b)),
|
|
986
|
+
secrets: [...secrets].sort((a, b) => a.localeCompare(b)),
|
|
987
|
+
reasons,
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* The branch-protection contexts a gates block implies at one moment.
|
|
993
|
+
*
|
|
994
|
+
* Derived rather than transcribed. The hand-maintained snapshot this replaces
|
|
995
|
+
* shipped empty, carried a 90-day expiry, and had already been measured wrong
|
|
996
|
+
* in both directions — because nothing could derive the truth and nothing could
|
|
997
|
+
* tell you the copy had gone stale.
|
|
998
|
+
*
|
|
999
|
+
* Only `required` produces a context, and that is what makes `optional` and
|
|
1000
|
+
* `off` safe: a skipped required check counts as satisfied on GitHub, so a gate
|
|
1001
|
+
* that is off must never appear here — and because the job condition and this
|
|
1002
|
+
* list come from one declaration, it cannot.
|
|
1003
|
+
* @param {object} gates The gates block.
|
|
1004
|
+
* @param {object} [options] Context options.
|
|
1005
|
+
* @returns {string[]} Sorted, de-duplicated contexts.
|
|
1006
|
+
*/
|
|
1007
|
+
export function contextsFor(gates, options = {}) {
|
|
1008
|
+
const {
|
|
1009
|
+
moment = "pull-request",
|
|
1010
|
+
workflowName = "🔍 Quality Checks",
|
|
1011
|
+
previousLabels = [],
|
|
1012
|
+
} = options;
|
|
1013
|
+
|
|
1014
|
+
const contexts = resolveMoment({ gates, moment })
|
|
1015
|
+
.filter(gate => gate.level === "required")
|
|
1016
|
+
// An awaited signal posts under its own name; a run job posts under the
|
|
1017
|
+
// calling workflow's.
|
|
1018
|
+
.map(gate =>
|
|
1019
|
+
gate.mode === "await" ? gate.awaits : `${workflowName} / ${gate.label}`
|
|
1020
|
+
);
|
|
1021
|
+
|
|
1022
|
+
for (const label of previousLabels) {
|
|
1023
|
+
contexts.push(`${workflowName} / ${label}`);
|
|
1024
|
+
}
|
|
1025
|
+
return [...new Set(contexts)].sort((a, b) => a.localeCompare(b));
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* The closest known name, for a did-you-mean suggestion.
|
|
1030
|
+
* @param {string} value The unrecognised value.
|
|
1031
|
+
* @param {string[]} candidates Known names.
|
|
1032
|
+
* @returns {string|null} A near match, or null when nothing is close.
|
|
1033
|
+
*/
|
|
1034
|
+
function nearest(value, candidates) {
|
|
1035
|
+
let best = null;
|
|
1036
|
+
let bestScore = Infinity;
|
|
1037
|
+
for (const candidate of candidates) {
|
|
1038
|
+
const score = distance(value, candidate);
|
|
1039
|
+
if (score < bestScore) {
|
|
1040
|
+
bestScore = score;
|
|
1041
|
+
best = candidate;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
return bestScore <= Math.max(2, Math.floor(value.length / 3)) ? best : null;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Levenshtein distance.
|
|
1049
|
+
* @param {string} a First string.
|
|
1050
|
+
* @param {string} b Second string.
|
|
1051
|
+
* @returns {number} Edit distance.
|
|
1052
|
+
*/
|
|
1053
|
+
function distance(a, b) {
|
|
1054
|
+
const previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
1055
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
1056
|
+
let diagonal = previous[0];
|
|
1057
|
+
previous[0] = i;
|
|
1058
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
1059
|
+
const carry = previous[j];
|
|
1060
|
+
previous[j] = Math.min(
|
|
1061
|
+
previous[j] + 1,
|
|
1062
|
+
previous[j - 1] + 1,
|
|
1063
|
+
diagonal + (a[i - 1] === b[j - 1] ? 0 : 1)
|
|
1064
|
+
);
|
|
1065
|
+
diagonal = carry;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
return previous[b.length];
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* CLI entry point.
|
|
1073
|
+
*/
|
|
1074
|
+
function main() {
|
|
1075
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
1076
|
+
const flag = name => {
|
|
1077
|
+
const hit = rest.find(arg => arg.startsWith(`--${name}=`));
|
|
1078
|
+
return hit ? hit.slice(name.length + 3) : null;
|
|
1079
|
+
};
|
|
1080
|
+
const { runner, gates, policy, config } = readGates();
|
|
1081
|
+
|
|
1082
|
+
if (command === "validate") {
|
|
1083
|
+
const blocking = [...validateGates(gates), ...validatePolicy(policy)];
|
|
1084
|
+
const advisory = auditConfigKeys(config).map(finding => finding.message);
|
|
1085
|
+
for (const problem of [...blocking, ...advisory]) {
|
|
1086
|
+
console.error(` ${problem}`);
|
|
1087
|
+
}
|
|
1088
|
+
if (blocking.length) {
|
|
1089
|
+
console.error(`\n${blocking.length} blocking configuration problem(s).`);
|
|
1090
|
+
process.exit(1);
|
|
1091
|
+
}
|
|
1092
|
+
if (advisory.length) {
|
|
1093
|
+
console.log(
|
|
1094
|
+
`\n${advisory.length} advisory finding(s); nothing blocking.`
|
|
1095
|
+
);
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
console.log("gates and policy: configuration is valid");
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
if (command === "list") {
|
|
1103
|
+
const moment = flag("moment");
|
|
1104
|
+
if (!moment)
|
|
1105
|
+
throw new Error("usage: lisa-gates.mjs list --moment=<moment>");
|
|
1106
|
+
const resolved = resolveMoment({ gates, moment, runner });
|
|
1107
|
+
if (rest.includes("--json")) {
|
|
1108
|
+
console.log(JSON.stringify(resolved, null, 2));
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
for (const gate of resolved) {
|
|
1112
|
+
const how =
|
|
1113
|
+
gate.mode === "await"
|
|
1114
|
+
? `await ${gate.awaits}`
|
|
1115
|
+
: (gate.command ?? "(intercepted by Lisa)");
|
|
1116
|
+
console.log(`${gate.level.padEnd(9)} ${gate.id.padEnd(28)} ${how}`);
|
|
1117
|
+
}
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
if (command === "needs") {
|
|
1122
|
+
const moment = flag("moment") ?? "session-start";
|
|
1123
|
+
console.log(JSON.stringify(needsAt({ gates, moment }), null, 2));
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
if (command === "contexts") {
|
|
1128
|
+
const previousLabels = (flag("previous") ?? "")
|
|
1129
|
+
.split(",")
|
|
1130
|
+
.map(entry => entry.trim())
|
|
1131
|
+
.filter(Boolean);
|
|
1132
|
+
console.log(
|
|
1133
|
+
JSON.stringify(
|
|
1134
|
+
contextsFor(gates, {
|
|
1135
|
+
moment: flag("moment") ?? "pull-request",
|
|
1136
|
+
workflowName: flag("workflow") ?? "🔍 Quality Checks",
|
|
1137
|
+
previousLabels,
|
|
1138
|
+
}),
|
|
1139
|
+
null,
|
|
1140
|
+
2
|
|
1141
|
+
)
|
|
1142
|
+
);
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
if (command === "audit-config") {
|
|
1147
|
+
const findings = auditConfigKeys(config);
|
|
1148
|
+
for (const finding of findings) console.error(` ${finding.message}`);
|
|
1149
|
+
console.log(`${findings.length} config key finding(s).`);
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
throw new Error(
|
|
1154
|
+
"usage: lisa-gates.mjs validate|list|needs|contexts|audit-config"
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
1159
|
+
try {
|
|
1160
|
+
main();
|
|
1161
|
+
} catch (err) {
|
|
1162
|
+
console.error(err.message);
|
|
1163
|
+
process.exit(1);
|
|
1164
|
+
}
|
|
1165
|
+
}
|