@intentic/sandbox-contract 1.233.0 → 1.235.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 +4 -3
- package/dist/agent-catalog.d.ts +2 -1
- package/dist/agent-catalog.d.ts.map +1 -1
- package/dist/agent-catalog.js +19 -13
- package/dist/agent-catalog.js.map +1 -1
- package/dist/chores/chores.d.ts.map +1 -1
- package/dist/chores/chores.js +36 -1
- package/dist/chores/chores.js.map +1 -1
- package/dist/chores/probes.d.ts.map +1 -1
- package/dist/chores/probes.js +70 -0
- package/dist/chores/probes.js.map +1 -1
- package/dist/command-classes.d.ts +5 -2
- package/dist/command-classes.d.ts.map +1 -1
- package/dist/command-classes.js +34 -13
- package/dist/command-classes.js.map +1 -1
- package/dist/contracts/automations.contract.d.ts +30 -0
- package/dist/contracts/automations.contract.d.ts.map +1 -1
- package/dist/contracts/chores.contract.d.ts +17 -0
- package/dist/contracts/chores.contract.d.ts.map +1 -1
- package/dist/contracts/extensions.contract.d.ts +2 -0
- package/dist/contracts/extensions.contract.d.ts.map +1 -1
- package/dist/contracts/extensions.contract.js.map +1 -1
- package/dist/contracts/issues.contract.d.ts +89 -0
- package/dist/contracts/issues.contract.d.ts.map +1 -0
- package/dist/contracts/issues.contract.js +50 -0
- package/dist/contracts/issues.contract.js.map +1 -0
- package/dist/contracts/runner.contract.d.ts +102 -102
- package/dist/contracts/settings.contract.d.ts +12 -0
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.d.ts +12 -0
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.js +10 -1
- package/dist/contracts/system.contract.js.map +1 -1
- package/dist/credential-material.d.ts +2 -0
- package/dist/credential-material.d.ts.map +1 -0
- package/dist/credential-material.js +36 -0
- package/dist/credential-material.js.map +1 -0
- package/dist/definition.d.ts +8 -0
- package/dist/definition.d.ts.map +1 -1
- package/dist/history-state.d.ts.map +1 -1
- package/dist/history-state.js +1 -0
- package/dist/history-state.js.map +1 -1
- package/dist/hostnames.d.ts +2 -0
- package/dist/hostnames.d.ts.map +1 -1
- package/dist/hostnames.js +3 -1
- package/dist/hostnames.js.map +1 -1
- package/dist/index.d.ts +280 -116
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/ingress-contract.d.ts +23 -0
- package/dist/ingress-contract.d.ts.map +1 -0
- package/dist/ingress-contract.js +43 -0
- package/dist/ingress-contract.js.map +1 -0
- package/dist/runtime-state.d.ts +9 -0
- package/dist/runtime-state.d.ts.map +1 -1
- package/dist/runtime-state.js +3 -0
- package/dist/runtime-state.js.map +1 -1
- package/dist/schemas/agent.d.ts +6 -0
- package/dist/schemas/agent.d.ts.map +1 -1
- package/dist/schemas/agent.js +2 -1
- package/dist/schemas/agent.js.map +1 -1
- package/dist/schemas/automations.d.ts +45 -0
- package/dist/schemas/automations.d.ts.map +1 -1
- package/dist/schemas/automations.js +6 -1
- package/dist/schemas/automations.js.map +1 -1
- package/dist/schemas/computers.d.ts +41 -0
- package/dist/schemas/computers.d.ts.map +1 -1
- package/dist/schemas/computers.js +16 -0
- package/dist/schemas/computers.js.map +1 -1
- package/dist/schemas/extension-updates.d.ts +2 -0
- package/dist/schemas/extension-updates.d.ts.map +1 -1
- package/dist/schemas/extension-updates.js +3 -1
- package/dist/schemas/extension-updates.js.map +1 -1
- package/dist/schemas/issues.d.ts +324 -0
- package/dist/schemas/issues.d.ts.map +1 -0
- package/dist/schemas/issues.js +107 -0
- package/dist/schemas/issues.js.map +1 -0
- package/dist/schemas/maintenance.d.ts +62 -1
- package/dist/schemas/maintenance.d.ts.map +1 -1
- package/dist/schemas/maintenance.js +22 -1
- package/dist/schemas/maintenance.js.map +1 -1
- package/dist/schemas/settings.d.ts +6 -0
- package/dist/schemas/settings.d.ts.map +1 -1
- package/dist/schemas/settings.js +4 -0
- package/dist/schemas/settings.js.map +1 -1
- package/dist/schemas/terminal.d.ts.map +1 -1
- package/dist/schemas/terminal.js.map +1 -1
- package/dist/schemas/webext.d.ts +24 -0
- package/dist/schemas/webext.d.ts.map +1 -1
- package/dist/schemas/webext.js +9 -0
- package/dist/schemas/webext.js.map +1 -1
- package/dist/webext-links.d.ts +1 -0
- package/dist/webext-links.d.ts.map +1 -1
- package/dist/webext-links.js +1 -0
- package/dist/webext-links.js.map +1 -1
- package/dist/workspace-state.d.ts +10 -6
- package/dist/workspace-state.d.ts.map +1 -1
- package/dist/workspace-state.js +13 -8
- package/dist/workspace-state.js.map +1 -1
- package/package.json +30 -8
- package/src/agent-catalog.test.ts +55 -54
- package/src/agent-catalog.ts +29 -22
- package/src/capability-ledger.test.ts +11 -2
- package/src/chores/chores.test.ts +1 -1
- package/src/chores/chores.ts +66 -1
- package/src/chores/probes.test.ts +65 -0
- package/src/chores/probes.ts +133 -1
- package/src/chores/verdict.test.ts +29 -19
- package/src/command-classes.test.ts +71 -4
- package/src/command-classes.ts +112 -22
- package/src/contracts/extensions.contract.ts +3 -2
- package/src/contracts/issues.contract.ts +60 -0
- package/src/contracts/system.contract.ts +22 -1
- package/src/credential-material.test.ts +120 -0
- package/src/credential-material.ts +100 -0
- package/src/history-state.ts +12 -0
- package/src/hostnames.ts +18 -2
- package/src/index.ts +5 -0
- package/src/ingress-contract.test.ts +65 -0
- package/src/ingress-contract.ts +155 -0
- package/src/routes.test.ts +4 -2
- package/src/runtime-state.ts +16 -0
- package/src/schemas/agent.ts +15 -2
- package/src/schemas/automations.ts +14 -2
- package/src/schemas/computers.ts +60 -0
- package/src/schemas/extension-updates.ts +3 -1
- package/src/schemas/issues.ts +279 -0
- package/src/schemas/maintenance.ts +51 -1
- package/src/schemas/settings.ts +32 -0
- package/src/schemas/terminal.ts +6 -4
- package/src/schemas/webext.ts +29 -0
- package/src/tunnel-ids.test.ts +3 -1
- package/src/webext-links.ts +10 -0
- package/src/workspace-state.test.ts +3 -1
- package/src/workspace-state.ts +27 -37
|
@@ -88,9 +88,10 @@ describe("the pi provider", () => {
|
|
|
88
88
|
expect(pi.steering).toBe(true);
|
|
89
89
|
expect(pi.effort).toBe(true);
|
|
90
90
|
expect(pi.commands).toBe(true);
|
|
91
|
+
expect(pi.mcp).toBe("none");
|
|
92
|
+
expect(pi.terminals).toBe(false);
|
|
91
93
|
const limitations = limitationsOf(pi);
|
|
92
|
-
expect(limitations).
|
|
93
|
-
expect(limitations).toContain("no terminal panel");
|
|
94
|
+
expect(limitations.length).toBeGreaterThan(0);
|
|
94
95
|
expect(limitations).not.toContain("no mid-turn steering");
|
|
95
96
|
expect(limitations).not.toContain("no effort control");
|
|
96
97
|
expect(limitations).not.toContain("no slash commands");
|
|
@@ -152,22 +153,23 @@ test("a mode the runtime can't hold falls back to the one it runs; one it can ho
|
|
|
152
153
|
test("the ceiling has nothing to disclose; a floor names what it lacks", () => {
|
|
153
154
|
expect(limitationsOf(capabilitiesOf("claude", "native"))).toEqual([]);
|
|
154
155
|
|
|
155
|
-
const
|
|
156
|
-
expect(
|
|
157
|
-
expect(
|
|
158
|
-
expect(
|
|
159
|
-
expect(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const acp = limitationsOf(
|
|
164
|
-
expect(acp).
|
|
165
|
-
expect(
|
|
166
|
-
expect(
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
expect(
|
|
170
|
-
expect(
|
|
156
|
+
const grokCaps = capabilitiesOf("grok", "native");
|
|
157
|
+
expect(grokCaps.permissions).toBe("plan");
|
|
158
|
+
expect(grokCaps.steering).toBe(false);
|
|
159
|
+
expect(grokCaps.isolation).toBe("cwd");
|
|
160
|
+
expect(limitationsOf(grokCaps).length).toBeGreaterThan(0);
|
|
161
|
+
|
|
162
|
+
const acpCaps = capabilitiesOf("some-installed-agent", "native");
|
|
163
|
+
expect(acpCaps.mcp).toBe("http");
|
|
164
|
+
const acp = limitationsOf(acpCaps);
|
|
165
|
+
expect(acp.some((line) => line.includes("MCP"))).toBe(true);
|
|
166
|
+
expect(acpCaps.commands).toBe(true);
|
|
167
|
+
expect(acpCaps.terminals).toBe(true);
|
|
168
|
+
|
|
169
|
+
const codexCaps = capabilitiesOf("codex", "native");
|
|
170
|
+
expect(codexCaps.mcp).toBe("browser");
|
|
171
|
+
expect(limitationsOf(codexCaps).some((line) => line.includes("browser"))).toBe(true);
|
|
172
|
+
expect(limitationsOf(codexCaps).length).toBeLessThan(limitationsOf({ ...grokCaps, mcp: "none" }).length + 5);
|
|
171
173
|
});
|
|
172
174
|
|
|
173
175
|
test("every axis a record can lack has words for it", () => {
|
|
@@ -185,6 +187,7 @@ test("every axis a record can lack has words for it", () => {
|
|
|
185
187
|
terminals: false,
|
|
186
188
|
recovery: false,
|
|
187
189
|
instructions: "none",
|
|
190
|
+
skillDiscovery: "prompt",
|
|
188
191
|
rulebook: "none",
|
|
189
192
|
secrets: "none",
|
|
190
193
|
};
|
|
@@ -201,33 +204,27 @@ test("every axis a record can lack has words for it", () => {
|
|
|
201
204
|
* has a middle value, and disclosing the floor's words for it would tell a Codex user their rules are ignored
|
|
202
205
|
* when they are in fact being applied to everything Codex asks about. */
|
|
203
206
|
test("the safety axes disclose the middle answer differently from the floor", () => {
|
|
204
|
-
const
|
|
205
|
-
const
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
expect(
|
|
210
|
-
expect(
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
expect(
|
|
214
|
-
expect(
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
expect(
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
expect(grok).not.toContain("aren't applied");
|
|
224
|
-
expect(grok).not.toContain("only to calls this agent asks about");
|
|
225
|
-
// Gemini rides the same loop, so it must read the same way.
|
|
226
|
-
expect(limitationsOf(capabilitiesOf("gemini", "native")).join(" ")).toBe(grok);
|
|
227
|
-
|
|
228
|
-
// Masking is binary and structural, so every non-Claude runtime says the same thing.
|
|
207
|
+
const claudeCaps = capabilitiesOf("claude", "native");
|
|
208
|
+
const codexCaps = capabilitiesOf("codex", "native");
|
|
209
|
+
const piCaps = capabilitiesOf("pi", "native");
|
|
210
|
+
const grokCaps = capabilitiesOf("grok", "native");
|
|
211
|
+
|
|
212
|
+
expect(claudeCaps.rulebook).toBe("hooks");
|
|
213
|
+
expect(claudeCaps.secrets).toBe("masked");
|
|
214
|
+
|
|
215
|
+
expect(codexCaps.rulebook).toBe("approval");
|
|
216
|
+
expect(piCaps.rulebook).toBe("none");
|
|
217
|
+
expect(grokCaps.rulebook).toBe("refuse-only");
|
|
218
|
+
|
|
219
|
+
expect(limitationsOf(claudeCaps).join(" ")).not.toContain("command rules");
|
|
220
|
+
expect(limitationsOf(claudeCaps).join(" ")).not.toContain("stored secrets");
|
|
221
|
+
|
|
222
|
+
expect(limitationsOf(codexCaps).length).toBeGreaterThan(limitationsOf(claudeCaps).length);
|
|
223
|
+
expect(limitationsOf(piCaps).length).toBeGreaterThan(limitationsOf(claudeCaps).length);
|
|
224
|
+
expect(limitationsOf(grokCaps).join(" ")).toBe(limitationsOf(capabilitiesOf("gemini", "native")).join(" "));
|
|
225
|
+
|
|
229
226
|
for (const provider of ["codex", "grok", "gemini", "pi", "some-installed-agent"] as const) {
|
|
230
|
-
expect(
|
|
227
|
+
expect(capabilitiesOf(provider, "native").secrets).toBe("none");
|
|
231
228
|
}
|
|
232
229
|
});
|
|
233
230
|
|
|
@@ -235,14 +232,16 @@ test("the safety axes disclose the middle answer differently from the floor", ()
|
|
|
235
232
|
* count above cannot check: a middle value that discloses the same words as the floor would tell a Grok user
|
|
236
233
|
* their prompt is ignored when it is in fact being sent. */
|
|
237
234
|
test("the instruction axis discloses its two weaker answers, differently", () => {
|
|
238
|
-
const
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
expect(
|
|
243
|
-
expect(
|
|
244
|
-
|
|
245
|
-
|
|
235
|
+
const grokCaps = capabilitiesOf("grok", "native");
|
|
236
|
+
const acpCaps = capabilitiesOf("some-installed-agent", "native");
|
|
237
|
+
const codexCaps = capabilitiesOf("codex", "native");
|
|
238
|
+
|
|
239
|
+
expect(grokCaps.instructions).toBe("append");
|
|
240
|
+
expect(acpCaps.instructions).toBe("none");
|
|
241
|
+
expect(codexCaps.instructions).toBe("replace");
|
|
242
|
+
|
|
243
|
+
expect(limitationsOf(grokCaps).length).toBeGreaterThan(limitationsOf(codexCaps).length);
|
|
244
|
+
expect(limitationsOf(acpCaps).length).toBeGreaterThan(limitationsOf(codexCaps).length);
|
|
246
245
|
});
|
|
247
246
|
|
|
248
247
|
/* The JS backend is hosted by the one loop the daemon can put its own execution seam through. Pinned as a test
|
|
@@ -366,8 +365,10 @@ describe("the free providers", () => {
|
|
|
366
365
|
// empty headline or a dangling sentence in front of the user who has connected nothing.
|
|
367
366
|
for (const provider of FREE_PROVIDERS) {
|
|
368
367
|
const access = accessFor(provider);
|
|
369
|
-
|
|
370
|
-
|
|
368
|
+
// Both are sentences the gate PUTS ON SCREEN, so what they have to be is text with something in
|
|
369
|
+
// it. Truthiness passes a whitespace-only string, which renders as the empty headline this is about.
|
|
370
|
+
expect(access?.requirement).toEqual(expect.stringMatching(/\S/));
|
|
371
|
+
expect(access?.runs).toEqual(expect.stringMatching(/\S/));
|
|
371
372
|
}
|
|
372
373
|
});
|
|
373
374
|
});
|
package/src/agent-catalog.ts
CHANGED
|
@@ -140,7 +140,7 @@ export const TRIAL_MODEL_ID = "auto";
|
|
|
140
140
|
// What the picker calls it, and the sentence the surfaces put underneath. One wording, so the composer's notice
|
|
141
141
|
// and the picker's row cannot end up describing different bargains.
|
|
142
142
|
export const TRIAL_LABEL = "Free trial";
|
|
143
|
-
export const TRIAL_NOTICE = "Trial messages pass through intentic
|
|
143
|
+
export const TRIAL_NOTICE = "Trial messages pass through intentic. Connect an account to chat directly.";
|
|
144
144
|
|
|
145
145
|
export const ENDPOINT_PROVIDER_PREFIX = "endpoint/";
|
|
146
146
|
export const endpointProvider = (id: string): AgentProvider => `${ENDPOINT_PROVIDER_PREFIX}${id}`;
|
|
@@ -300,6 +300,16 @@ export interface AgentCapabilities {
|
|
|
300
300
|
* only the Claude Code loop's: Codex's own base describes Codex's own tools, so swapping it for a prompt
|
|
301
301
|
* written about another harness is the owner's deliberate act (their custom text), never ours. */
|
|
302
302
|
readonly instructions: "replace" | "append" | "none";
|
|
303
|
+
/* HOW THIS RUNTIME DISCOVERS THE WORKSPACE'S LOADED SKILLS.
|
|
304
|
+
*
|
|
305
|
+
* "native", the runtime scans one of the filesystem projections itself: `.agents/skills/` for Codex,
|
|
306
|
+
* `.claude/skills/` for the Claude Code loop. Its own loader injects the catalogue and reads
|
|
307
|
+
* the matching SKILL.md on demand, so adding our own note would duplicate it.
|
|
308
|
+
* "prompt", the runtime has no loader the daemon can rely on. turn-plan.ts puts the same name,
|
|
309
|
+
* description and absolute SKILL.md path into the opening user-message preamble. This is a
|
|
310
|
+
* separate axis from `instructions`: Pi and ACP take no system prompt at all, while OpenCode
|
|
311
|
+
* and Cursor take an append, but all four still need skill discovery. */
|
|
312
|
+
readonly skillDiscovery: "native" | "prompt";
|
|
303
313
|
/* WHETHER THE OWNER'S COMMAND RULEBOOK REACHES THIS RUNTIME (SandboxSettings.commandRules, decided by
|
|
304
314
|
* guard/actions.ts commandRun, delivered by guard/command-gate.ts).
|
|
305
315
|
*
|
|
@@ -364,6 +374,7 @@ const CLAUDE_CODE: AgentCapabilities = {
|
|
|
364
374
|
terminals: true,
|
|
365
375
|
recovery: true,
|
|
366
376
|
instructions: "replace",
|
|
377
|
+
skillDiscovery: "native",
|
|
367
378
|
// The only runtime with a pre-execution hook of its own, which is why it is the only one where a HOLD can
|
|
368
379
|
// park the call and wait for a card rather than having to refuse it.
|
|
369
380
|
rulebook: "hooks",
|
|
@@ -397,6 +408,7 @@ const CODEX: AgentCapabilities = {
|
|
|
397
408
|
* message ahead of its skills and team blocks. Verified against codex-cli 0.147 by reading what actually
|
|
398
409
|
* reached the wire, the keys are undocumented, and a strings dump proves only that they parse. */
|
|
399
410
|
instructions: "replace",
|
|
411
|
+
skillDiscovery: "native",
|
|
400
412
|
/* App-server publishes `item/commandExecution/requestApproval`, whose params carry the command text, and
|
|
401
413
|
* takes `accept`/`decline` back (codex-cli 0.147's own generated JSON Schema, read with
|
|
402
414
|
* `codex app-server generate-json-schema`). The daemon only asks Codex to raise those requests when the
|
|
@@ -425,6 +437,7 @@ const OPENCODE: AgentCapabilities = {
|
|
|
425
437
|
// replacing that, so a custom prompt lands here as extra instructions, and the settings page says so
|
|
426
438
|
// rather than letting "replaces everything" quietly mean something else on two providers.
|
|
427
439
|
instructions: "append",
|
|
440
|
+
skillDiscovery: "prompt",
|
|
428
441
|
/* OpenCode asks over its own permission channel (`permission.updated`, replied on
|
|
429
442
|
* `/session/{id}/permissions/{permissionID}`, vocabulary once/always/reject), and the daemon judges what it
|
|
430
443
|
* raises with the same decide fn every other runtime uses.
|
|
@@ -474,6 +487,7 @@ const ACP: AgentCapabilities = {
|
|
|
474
487
|
// ACP's `session/new` and `session/prompt` carry no system field: the agent owns its own instructions the
|
|
475
488
|
// same way it owns its model and its permission posture. The persona note takes the user message instead.
|
|
476
489
|
instructions: "none",
|
|
490
|
+
skillDiscovery: "prompt",
|
|
477
491
|
/* `session/request_permission` is in the protocol floor, so every conforming agent has the channel and the
|
|
478
492
|
* daemon answers it from the rulebook (acp/acp-permissions.ts). The caveat the "approval" value already
|
|
479
493
|
* carries is at its widest here: WHICH calls an agent asks about is entirely the agent's choice, and one
|
|
@@ -510,6 +524,7 @@ const PI: AgentCapabilities = {
|
|
|
510
524
|
// Pi's RPC opens a session with a prompt and steers it; nothing in that protocol sets standing
|
|
511
525
|
// instructions, so like ACP it hears the persona note through the user message.
|
|
512
526
|
instructions: "none",
|
|
527
|
+
skillDiscovery: "prompt",
|
|
513
528
|
/* THE ONE RUNTIME WITH NO SEAM AT ALL. Pi runs bash in-process and its RPC publishes no approval request,
|
|
514
529
|
* so there is nothing to consult before a command runs and no rule the owner writes can apply here. Said
|
|
515
530
|
* out loud rather than left to be discovered: limitationsOf renders it, and the taint floor treats a "none"
|
|
@@ -582,6 +597,7 @@ const CURSOR: AgentCapabilities = {
|
|
|
582
597
|
* `additional_context` that is folded into the request. So the owner's prompt and the persona note DO reach
|
|
583
598
|
* the model, on top of Cursor's own base prompt, and nothing can replace that base. */
|
|
584
599
|
instructions: "append",
|
|
600
|
+
skillDiscovery: "prompt",
|
|
585
601
|
/* THE FULL HOOK TIER, the only foreign runtime that reaches it. Cursor reads `.cursor/hooks.json` in its
|
|
586
602
|
* local runtime, and `beforeShellExecution` answers with `allow` / `deny` / `ask` plus the messages that
|
|
587
603
|
* explain it, with `failClosed` available so a crashed gate blocks instead of waving the command through.
|
|
@@ -663,35 +679,26 @@ export const limitationsOf = (capabilities: AgentCapabilities): string[] => [
|
|
|
663
679
|
...(capabilities.mcp === "none"
|
|
664
680
|
? ["no MCP tools or plugins"]
|
|
665
681
|
: capabilities.mcp === "http"
|
|
666
|
-
? ["MCP tools only
|
|
682
|
+
? ["MCP tools only, no plugins or browser"]
|
|
667
683
|
: capabilities.mcp === "browser"
|
|
668
|
-
? ["browser tools only
|
|
684
|
+
? ["browser tools only, no other MCP"]
|
|
669
685
|
: capabilities.mcp === "tools"
|
|
670
|
-
? ["no plugins
|
|
686
|
+
? ["no plugins"]
|
|
671
687
|
: []),
|
|
672
|
-
...(capabilities.execution.includes("js") ? [] : ["no code runs,
|
|
688
|
+
...(capabilities.execution.includes("js") ? [] : ["no code runs, shell only"]),
|
|
673
689
|
...(capabilities.effort ? [] : ["no effort control"]),
|
|
674
690
|
...(capabilities.commands ? [] : ["no slash commands"]),
|
|
675
691
|
...(capabilities.terminals ? [] : ["no terminal panel"]),
|
|
676
|
-
...(capabilities.isolation === "namespace" ? [] : ["worktree by
|
|
677
|
-
...(capabilities.recovery ? [] : ["no auto-resume after
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
...(capabilities.instructions === "append" ? ["your system prompt is added to theirs, not replacing it"] : []),
|
|
682
|
-
...(capabilities.instructions === "none" ? ["your system prompt isn't applied"] : []),
|
|
683
|
-
/* THE TWO SAFETY AXES, phrased as what the OWNER loses rather than as which seam is missing, because both
|
|
684
|
-
* describe something they configured on a settings page and would otherwise assume was in force everywhere.
|
|
685
|
-
*
|
|
686
|
-
* "hooks" and "masked" are the ceiling and disclose nothing. The "approval" middle answer discloses the one
|
|
687
|
-
* thing that genuinely differs from a hook: the vendor picks which calls it asks about, so a rule can only
|
|
688
|
-
* reach what it chose to raise. */
|
|
689
|
-
...(capabilities.rulebook === "approval" ? ["your command rules apply only to calls this agent asks about"] : []),
|
|
692
|
+
...(capabilities.isolation === "namespace" ? [] : ["worktree by cwd only"]),
|
|
693
|
+
...(capabilities.recovery ? [] : ["no auto-resume after outage"]),
|
|
694
|
+
...(capabilities.instructions === "append" ? ["system prompt appended, not replaced"] : []),
|
|
695
|
+
...(capabilities.instructions === "none" ? ["system prompt not applied"] : []),
|
|
696
|
+
...(capabilities.rulebook === "approval" ? ["command rules apply only to calls this agent raises"] : []),
|
|
690
697
|
...(capabilities.rulebook === "refuse-only"
|
|
691
|
-
? ["
|
|
698
|
+
? ["command rules can refuse but not hold"]
|
|
692
699
|
: []),
|
|
693
|
-
...(capabilities.rulebook === "none" ? ["
|
|
694
|
-
...(capabilities.secrets === "none" ? ["
|
|
700
|
+
...(capabilities.rulebook === "none" ? ["command rules not applied"] : []),
|
|
701
|
+
...(capabilities.secrets === "none" ? ["secrets reach the model unmasked"] : []),
|
|
695
702
|
];
|
|
696
703
|
|
|
697
704
|
// Claude's compile-time model floor, shared by the daemon's catalog (claude-models.ts, its last rung, reached
|
|
@@ -54,6 +54,9 @@ const LEDGER: Record<keyof AgentCapabilities, Backing> = {
|
|
|
54
54
|
* that produced it: before the field existed, every runtime was composed for as though it were the Claude
|
|
55
55
|
* Code loop, and five of the six silently dropped the owner's prompt. */
|
|
56
56
|
instructions: "enforced",
|
|
57
|
+
// turn-plan.ts reads this before an opening turn: a "prompt" runtime receives the generated catalogue as
|
|
58
|
+
// a typed note, while a "native" runtime is left to its own loader so the same list never arrives twice.
|
|
59
|
+
skillDiscovery: "enforced",
|
|
57
60
|
// turn-plan.ts (honoured) plans the JS backend only where "js" is declared, so a runtime without it is
|
|
58
61
|
// handed no `jsExecution` and mounts no Code tool: the same drop-what-you-can't-honour rule as `effort`.
|
|
59
62
|
execution: "enforced",
|
|
@@ -111,8 +114,14 @@ describe("a descriptive claim reaches the user", () => {
|
|
|
111
114
|
const DIMINISHED: Partial<Record<keyof AgentCapabilities, unknown>> = { secrets: "none" };
|
|
112
115
|
|
|
113
116
|
it.each(fieldsWhere("descriptive"))("%s puts its own sentence in the picker when the runtime lacks it", (field) => {
|
|
114
|
-
const
|
|
115
|
-
|
|
117
|
+
const boolean = typeof CEILING[field] === "boolean";
|
|
118
|
+
// A boolean's floor is `false` and needs no entry; every other descriptive field has to name its own.
|
|
119
|
+
// Asserted against the KEYS of DIMINISHED so the failure prints the floors that do exist, which is what
|
|
120
|
+
// tells "this field was never added" apart from "this field was renamed and its entry left behind".
|
|
121
|
+
expect(boolean ? [field] : Object.keys(DIMINISHED), `${field} is descriptive and not a boolean: add its weakest value to DIMINISHED above`).toContain(
|
|
122
|
+
field,
|
|
123
|
+
);
|
|
124
|
+
const floor = boolean ? false : DIMINISHED[field];
|
|
116
125
|
|
|
117
126
|
const lacking: AgentCapabilities = { ...CEILING, [field]: floor };
|
|
118
127
|
|
|
@@ -19,7 +19,7 @@ describe(`the chore book's order`, () => {
|
|
|
19
19
|
// The sort cannot drop an entry, but a future refactor to a filter-into-groups could, so the count is
|
|
20
20
|
// pinned to the ids rather than to a number, which says which one went missing.
|
|
21
21
|
expect(new Set(CHORES.map((chore) => chore.id)).size).toBe(CHORES.length);
|
|
22
|
-
expect(CHORES.length).toBe(
|
|
22
|
+
expect(CHORES.length).toBe(18);
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
it(`says "surveying" exactly when a chore is a survey`, () => {
|
package/src/chores/chores.ts
CHANGED
|
@@ -430,6 +430,70 @@ const duplication: Chore = {
|
|
|
430
430
|
done: `Done when every clone in the report has either a named extraction or a one-line reason it should stay.`,
|
|
431
431
|
};
|
|
432
432
|
|
|
433
|
+
/* TEST STRENGTH. The one chore whose evidence is about the tests rather than the code, and it exists because
|
|
434
|
+
* nothing else in this workspace can produce it.
|
|
435
|
+
*
|
|
436
|
+
* A green suite is not evidence that the code is checked. Coverage says a line RAN; it cannot say an assertion
|
|
437
|
+
* depended on what the line produced. The gap between those two is where a model's tests live: they execute
|
|
438
|
+
* everything and assert almost nothing, and every gate here says yes to them — they type-check, they lint, they
|
|
439
|
+
* pass.
|
|
440
|
+
*
|
|
441
|
+
* MEASURED IN THIS REPOSITORY, on sandbox-contract's own chore module: 109 hand-written tests, and 16 of 58
|
|
442
|
+
* injected faults survived. The one worth reading is in `bucketOf`, whose comment in digest.ts argues at length
|
|
443
|
+
* that the zero boundary is load-bearing. Move that boundary and the suite stays green, because the test holding
|
|
444
|
+
* it is written relationally — `expect(bucketOf(0)).not.toBe(bucketOf(1))` — and with the boundary moved the two
|
|
445
|
+
* values are still different. The careful, un-brittle assertion is precisely the one that cannot see the change.
|
|
446
|
+
*
|
|
447
|
+
* THE FLOOR IS LOW ON PURPOSE. 60% is where Stryker's own default report turns red, and it is far under what a
|
|
448
|
+
* well-tested module scores, because this chore is looking for suites that are decorative rather than suites that
|
|
449
|
+
* are imperfect. A threshold near the good number would badge every honest package in the repo, which is how a
|
|
450
|
+
* maintenance surface teaches people to ignore it. */
|
|
451
|
+
const MUTATION_FLOOR = 60;
|
|
452
|
+
|
|
453
|
+
const testStrength: Chore = {
|
|
454
|
+
id: `test-strength`,
|
|
455
|
+
title: `Strengthen tests that would not notice a bug`,
|
|
456
|
+
icon: `list-check`,
|
|
457
|
+
description: `Whether the suite would actually fail if the code broke, which is a different question from whether it passes.`,
|
|
458
|
+
kind: `accruing`,
|
|
459
|
+
criterion: `Stryker's mutation score for the repo is under ${MUTATION_FLOOR}%.`,
|
|
460
|
+
stance: `act`,
|
|
461
|
+
needs: [`mutation`],
|
|
462
|
+
// Quarterly rather than monthly: a mutation score moves when tests are rewritten, which is not a weekly event,
|
|
463
|
+
// and the probe behind it is the most expensive one here.
|
|
464
|
+
cadenceMs: 90 * DAY_MS,
|
|
465
|
+
assess: (context) => {
|
|
466
|
+
const facts = factsOf(context, `mutation`);
|
|
467
|
+
if (facts === undefined || facts.mutation.score >= MUTATION_FLOOR) {
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
const { score, killed, survived, survivors } = facts.mutation;
|
|
471
|
+
return {
|
|
472
|
+
headline: `${survived} injected faults went unnoticed, ${score}% of them caught`,
|
|
473
|
+
// The survivors themselves, not the score. A percentage is a mood; a named line with the change that
|
|
474
|
+
// nothing objected to is a morning's work with the answer already in it.
|
|
475
|
+
detail: survivors.map((one) => `${one.file}:${one.line} · ${one.mutator} → ${one.replacement} · survived`),
|
|
476
|
+
/* Bucketed, via the same helper the other counting chores use, so ordinary drift does not read as
|
|
477
|
+
* news: a score moving 54 → 55 is not a thing to interrupt anyone about. The survivors' IDENTITIES
|
|
478
|
+
* ride along, so a NEW weak spot appearing speaks even while the number holds steady — which is the
|
|
479
|
+
* case that matters, because that is a test somebody just wrote. */
|
|
480
|
+
digest: digestOf(`bucket:${bucketOf(100 - score)}`, ...survivors.map((one) => `${one.file}:${one.line}`).toSorted()),
|
|
481
|
+
severity: `info`,
|
|
482
|
+
why:
|
|
483
|
+
`Stryker caught ${killed} of ${killed + survived} injected faults in ${repoLabel(context.repo)} (${score}%), under the ${MUTATION_FLOOR}% floor. ` +
|
|
484
|
+
`Code that can be changed with every test still green: ${survivors.map((one) => `${one.file}:${one.line} (${one.mutator} → ${one.replacement})`).join(`; `)}.`,
|
|
485
|
+
};
|
|
486
|
+
},
|
|
487
|
+
diagnosis: `Tests that run the code without checking what it produced pass whether or not the code is right, and no other check in this repository can tell the difference.`,
|
|
488
|
+
goal:
|
|
489
|
+
`Take the survivors one at a time and, for each, decide which of two things it is. Either the mutation changes behaviour somebody ` +
|
|
490
|
+
`depends on, in which case add the assertion that would have failed — usually at a BOUNDARY, and usually exact where the existing ` +
|
|
491
|
+
`test was relational: pinning \`bucketOf(0)\` to its value catches what \`not.toBe(bucketOf(1))\` cannot. Or it is an equivalent ` +
|
|
492
|
+
`mutant, code whose change genuinely cannot be observed, in which case say so and leave it. Do not chase the percentage: adding an ` +
|
|
493
|
+
`assertion nobody needs to satisfy a number is exactly the ceremony this is meant to detect.`,
|
|
494
|
+
done: `Done when every named survivor has either a new assertion that fails without the change, or a one-line note saying why it cannot be observed.`,
|
|
495
|
+
};
|
|
496
|
+
|
|
433
497
|
/* DOCUMENTATION. The evidence is a package with no README, which IS its architecture document in this
|
|
434
498
|
* workspace, so this is a stat on the package directory rather than a lookup in a parallel tree. It sounds like
|
|
435
499
|
* a coverage statistic
|
|
@@ -597,7 +661,7 @@ const runtime: Chore = {
|
|
|
597
661
|
},
|
|
598
662
|
diagnosis: `An unsupported runtime stops receiving security patches, so every advisory against it stays open permanently.`,
|
|
599
663
|
goal:
|
|
600
|
-
`Establish what actually pins this runtime: the image's own base, the workspace's
|
|
664
|
+
`Establish what actually pins this runtime: the image's own base, the workspace's nodeVersion, and each package's engines ` +
|
|
601
665
|
`range. Propose the smallest move to a supported LTS, which of those pins have to change, in what order, and what is likely to ` +
|
|
602
666
|
`break at that boundary. Make the pin changes that are mechanical; do NOT attempt the image rebuild itself.`,
|
|
603
667
|
done: `Done when the pins name a supported release, the repository's type-check and tests pass on it, and anything needing a rebuild is named as such.`,
|
|
@@ -1205,6 +1269,7 @@ const BOOK: readonly Chore[] = [
|
|
|
1205
1269
|
dependencies,
|
|
1206
1270
|
deadCode,
|
|
1207
1271
|
complexity,
|
|
1272
|
+
testStrength,
|
|
1208
1273
|
bundleWeight,
|
|
1209
1274
|
frameworkIdiom,
|
|
1210
1275
|
documentation,
|
|
@@ -179,6 +179,71 @@ describe(`jscpd`, () => {
|
|
|
179
179
|
});
|
|
180
180
|
});
|
|
181
181
|
|
|
182
|
+
/* The mutation report is the cross-tool "mutation testing report schema", and the whole risk in this parser is
|
|
183
|
+
* the arithmetic rather than the shape: two of the eight statuses land on the counter-intuitive side, and getting
|
|
184
|
+
* either one backwards produces a number that looks plausible and is wrong. So the statuses are tested by name. */
|
|
185
|
+
describe(`mutation`, () => {
|
|
186
|
+
const report = (...mutants: readonly Record<string, unknown>[]) =>
|
|
187
|
+
JSON.stringify({
|
|
188
|
+
schemaVersion: `2.0`,
|
|
189
|
+
files: { "src/digest.ts": { language: `typescript`, source: `…`, mutants } },
|
|
190
|
+
});
|
|
191
|
+
const mutant = (status: string, line = 1) => ({
|
|
192
|
+
id: `${status}-${line}`,
|
|
193
|
+
mutatorName: `ConditionalExpression`,
|
|
194
|
+
replacement: `false`,
|
|
195
|
+
location: { start: { line, column: 1 }, end: { line, column: 9 } },
|
|
196
|
+
status,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test(`counts Killed and Timeout as caught, Survived and NoCoverage as missed`, () => {
|
|
200
|
+
// Stryker's own arithmetic, restated as a test because both halves read backwards at a glance: a mutant
|
|
201
|
+
// that HANGS the suite was noticed by it, and a mutant nothing ran cannot have been.
|
|
202
|
+
const facts = parse(`mutation`, report(mutant(`Killed`), mutant(`Timeout`), mutant(`Survived`), mutant(`NoCoverage`)));
|
|
203
|
+
expect(facts).toMatchObject({ id: `mutation`, mutation: { killed: 2, survived: 2, score: 50 } });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test(`leaves mutants that never got a verdict out of the score entirely`, () => {
|
|
207
|
+
// One caught, one missed, and three with no answer: the score is 50%, not 20% and not 80%. Folding the
|
|
208
|
+
// undecided into either column is the mistake this pins.
|
|
209
|
+
const facts = parse(`mutation`, report(mutant(`Killed`), mutant(`Survived`), mutant(`CompileError`), mutant(`RuntimeError`), mutant(`Ignored`)));
|
|
210
|
+
expect(facts).toMatchObject({ id: `mutation`, mutation: { killed: 1, survived: 1, inconclusive: 3, score: 50 } });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test(`names each survivor with the change that went unnoticed`, () => {
|
|
214
|
+
const facts = parse(`mutation`, report(mutant(`Survived`, 43)));
|
|
215
|
+
expect(facts).toMatchObject({
|
|
216
|
+
mutation: { survivors: [{ file: `src/digest.ts`, line: 43, mutator: `ConditionalExpression`, replacement: `false` }] },
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// A mutator that DELETES an expression reports an empty replacement, which would otherwise render as a blank
|
|
221
|
+
// in the panel and read as a missing field rather than as the removal it is.
|
|
222
|
+
test(`renders a deleted expression as a removal rather than as nothing`, () => {
|
|
223
|
+
const facts = parse(`mutation`, report({ ...mutant(`Survived`), replacement: `` }));
|
|
224
|
+
expect(facts).toMatchObject({ mutation: { survivors: [{ replacement: `(removed)` }] } });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test(`a run with nothing to mutate is 100%, not a division by zero`, () => {
|
|
228
|
+
expect(parse(`mutation`, JSON.stringify({ schemaVersion: `2.0`, files: {} }))).toMatchObject({
|
|
229
|
+
mutation: { score: 100, killed: 0, survived: 0 },
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// The distinction the runner depends on: an empty `files` map is a real measurement, a MISSING one is output
|
|
234
|
+
// this parser did not understand, and reporting the second as a clean 100% is the one answer it must never give.
|
|
235
|
+
test(`output without a files map is a failure, not a clean repository`, () => {
|
|
236
|
+
expect(parse(`mutation`, JSON.stringify({ schemaVersion: `2.0` }))).toBeUndefined();
|
|
237
|
+
expect(parse(`mutation`, ``)).toBeUndefined();
|
|
238
|
+
expect(parse(`mutation`, JSON.stringify({ files: [] }))).toBeUndefined();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test(`survives a malformed mutant without losing the rest of the file's numbers`, () => {
|
|
242
|
+
const facts = parse(`mutation`, report(mutant(`Killed`), { nonsense: true }, mutant(`Survived`)));
|
|
243
|
+
expect(facts).toMatchObject({ mutation: { killed: 1, survived: 1, inconclusive: 1 } });
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
182
247
|
/* The UI sweep is the one probe whose output we produce ourselves, which removes the "their JSON moved" failure
|
|
183
248
|
* and replaces it with a worse one: a command of eleven piped ripgreps in which any single stage can silently
|
|
184
249
|
* contribute nothing. The marker line is what tells those two apart, and most of what is below is about it. */
|
package/src/chores/probes.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Advisory, Bundle, DeadCode, Duplication, OutdatedPackage, ProbeFacts, ProbeId, UiScan } from "../schemas/maintenance.js";
|
|
1
|
+
import type { Advisory, Bundle, DeadCode, Duplication, MutationScore, OutdatedPackage, ProbeFacts, ProbeId, UiScan } from "../schemas/maintenance.js";
|
|
2
2
|
import type { IdiomRule } from "./stack.js";
|
|
3
3
|
import {
|
|
4
4
|
BYPASS_PATTERN,
|
|
@@ -227,6 +227,103 @@ const parseJscpd = (stdout: string): ProbeFacts | undefined => {
|
|
|
227
227
|
return { id: `jscpd`, duplication };
|
|
228
228
|
};
|
|
229
229
|
|
|
230
|
+
/* WHAT THE SUITE WOULD NOTICE IF THE CODE BROKE, from Stryker's report.
|
|
231
|
+
*
|
|
232
|
+
* The report is the cross-tool "mutation testing report schema": `files` maps a path to `{ mutants: [{ mutatorName,
|
|
233
|
+
* replacement, location.start.line, status }] }`. Statuses are Killed, Survived, NoCoverage, Timeout, CompileError,
|
|
234
|
+
* RuntimeError, Ignored, Pending.
|
|
235
|
+
*
|
|
236
|
+
* THE ARITHMETIC IS STRYKER'S, NOT OURS, and that is a deliberate constraint rather than laziness. A timeout counts
|
|
237
|
+
* as DETECTED (a mutant that hangs the suite is one the suite noticed) and NoCoverage counts as UNDETECTED (nothing
|
|
238
|
+
* ran, so nothing could have caught it). Both are counter-intuitive enough to be worth restating here, and both are
|
|
239
|
+
* what the tool's own score means — a row that recomputed them differently would be quietly arguing with the
|
|
240
|
+
* evidence printed underneath it.
|
|
241
|
+
*
|
|
242
|
+
* `Pending` is treated as inconclusive rather than dropped: an interrupted incremental run leaves them, and
|
|
243
|
+
* counting them either way would move the score for a reason that has nothing to do with the tests. */
|
|
244
|
+
const SURVIVOR_SAMPLE = 8;
|
|
245
|
+
const DETECTED = new Set([`Killed`, `Timeout`]);
|
|
246
|
+
const UNDETECTED = new Set([`Survived`, `NoCoverage`]);
|
|
247
|
+
|
|
248
|
+
type Survivor = MutationScore["survivors"][number];
|
|
249
|
+
|
|
250
|
+
// One file's mutant list, or nothing. Every shape this cannot recognise reads as "no mutants here" rather than
|
|
251
|
+
// throwing: a report with one malformed entry is still worth the rest of its numbers.
|
|
252
|
+
const mutantsOf = (raw: unknown): readonly Record<string, unknown>[] => {
|
|
253
|
+
if (typeof raw !== `object` || raw === null) {
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
const mutants = (raw as Record<string, unknown>)[`mutants`];
|
|
257
|
+
return Array.isArray(mutants) ? mutants.filter((entry): entry is Record<string, unknown> => typeof entry === `object` && entry !== null) : [];
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const lineOf = (mutant: Record<string, unknown>): number => {
|
|
261
|
+
const location = mutant[`location`];
|
|
262
|
+
const start = typeof location === `object` && location !== null ? (location as Record<string, unknown>)[`start`] : undefined;
|
|
263
|
+
const line = typeof start === `object` && start !== null ? (start as Record<string, unknown>)[`line`] : undefined;
|
|
264
|
+
return typeof line === `number` ? line : 0;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const survivorOf = (file: string, mutant: Record<string, unknown>): Survivor => ({
|
|
268
|
+
file,
|
|
269
|
+
line: lineOf(mutant),
|
|
270
|
+
mutator: asString(mutant[`mutatorName`]) ?? `?`,
|
|
271
|
+
// An empty replacement is meaningful — the mutator deleted the expression — so it is rendered as the removal
|
|
272
|
+
// it is rather than left blank for a reader to puzzle over.
|
|
273
|
+
replacement: asString(mutant[`replacement`]) ?? `(removed)`,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
interface Tally {
|
|
277
|
+
killed: number;
|
|
278
|
+
survived: number;
|
|
279
|
+
inconclusive: number;
|
|
280
|
+
readonly survivors: Survivor[];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const tallyMutants = (files: Record<string, unknown>): Tally => {
|
|
284
|
+
const tally: Tally = { killed: 0, survived: 0, inconclusive: 0, survivors: [] };
|
|
285
|
+
for (const [file, raw] of Object.entries(files)) {
|
|
286
|
+
for (const mutant of mutantsOf(raw)) {
|
|
287
|
+
const status = asString(mutant[`status`]) ?? ``;
|
|
288
|
+
if (DETECTED.has(status)) {
|
|
289
|
+
tally.killed++;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
// Anything that is neither detected nor undetected never got a verdict — it would not compile, or the
|
|
293
|
+
// configuration ignored it. Counting it either way would move the score for a reason that has nothing
|
|
294
|
+
// to do with the tests.
|
|
295
|
+
if (!UNDETECTED.has(status)) {
|
|
296
|
+
tally.inconclusive++;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
tally.survived++;
|
|
300
|
+
tally.survivors.push(survivorOf(file, mutant));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return tally;
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const parseMutation = (stdout: string): ProbeFacts | undefined => {
|
|
307
|
+
const files = asObject(stdout)?.[`files`];
|
|
308
|
+
// No `files` key at all means this is not a mutation report. An EMPTY one is a real answer (nothing was
|
|
309
|
+
// mutated) and a missing one is a parse failure, and the runner has to be able to tell them apart.
|
|
310
|
+
if (typeof files !== `object` || files === null || Array.isArray(files)) {
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
const tally = tallyMutants(files as Record<string, unknown>);
|
|
314
|
+
const valid = tally.killed + tally.survived;
|
|
315
|
+
const mutation: MutationScore = {
|
|
316
|
+
// Nothing to mutate scores 100 rather than NaN: there is no undetected fault in it, which is the honest
|
|
317
|
+
// reading and the one that keeps the chore quiet instead of dividing by zero.
|
|
318
|
+
score: valid === 0 ? 100 : Math.round((tally.killed / valid) * 100),
|
|
319
|
+
killed: tally.killed,
|
|
320
|
+
survived: tally.survived,
|
|
321
|
+
inconclusive: tally.inconclusive,
|
|
322
|
+
survivors: tally.survivors.slice(0, SURVIVOR_SAMPLE),
|
|
323
|
+
};
|
|
324
|
+
return { id: `mutation`, mutation };
|
|
325
|
+
};
|
|
326
|
+
|
|
230
327
|
/* THE UI SWEEP. The only probe here whose command is COMPOSED rather than written out, because its subject is a
|
|
231
328
|
* table (stack.ts) that will grow and a hand-written command would be a second copy of it going stale.
|
|
232
329
|
*
|
|
@@ -370,6 +467,9 @@ const parseBundle = (stdout: string): ProbeFacts | undefined => {
|
|
|
370
467
|
// immediately after, never something to keep, the cached ProbeResult is the artefact that survives. The same
|
|
371
468
|
// path the scheduled form of this chore uses (chores.ts), so a workspace running both keeps one copy.
|
|
372
469
|
const JSCPD_DIR = `/tmp/intentic-chore-jscpd`;
|
|
470
|
+
// Where Stryker's json reporter writes, which is its own default and not configurable per run on the CLI. Read
|
|
471
|
+
// from the repo directory the probe runs in, like jscpd's report above.
|
|
472
|
+
const MUTATION_REPORT = `reports/mutation/mutation.json`;
|
|
373
473
|
|
|
374
474
|
export const PROBES: readonly ProbeSpec[] = [
|
|
375
475
|
{
|
|
@@ -430,6 +530,38 @@ export const PROBES: readonly ProbeSpec[] = [
|
|
|
430
530
|
`cat ${JSCPD_DIR}/jscpd-report.json 2>/dev/null`,
|
|
431
531
|
parse: parseJscpd,
|
|
432
532
|
},
|
|
533
|
+
{
|
|
534
|
+
id: `mutation`,
|
|
535
|
+
title: `Test strength`,
|
|
536
|
+
measures: `how much of the code could break with every test still green`,
|
|
537
|
+
/* THE MOST EXPENSIVE PROBE HERE, and the tier does not really capture it: this runs the suite once per
|
|
538
|
+
* injected fault. `--incremental` is what makes it liveable — Stryker keeps a result file and re-runs only
|
|
539
|
+
* the mutants whose code or tests changed, so the first run costs what a full run costs and every one
|
|
540
|
+
* after it costs the diff. The monthly TTL is set against the FIRST run, not the incremental ones.
|
|
541
|
+
*
|
|
542
|
+
* Opt-in per repository, by the presence of a `stryker.conf.json`, and that is the load-bearing part of
|
|
543
|
+
* the design rather than a convenience. This monorepo has 65 packages with their own vitest configs and no
|
|
544
|
+
* root suite; mutating all of them unasked would be hours of CPU nobody agreed to spend. A repo says which
|
|
545
|
+
* code is worth the measurement by writing that file, which is also where `mutate` narrows the scope. */
|
|
546
|
+
tier: 2,
|
|
547
|
+
ttlMs: 30 * DAY_MS,
|
|
548
|
+
timeoutMs: 90 * 60_000,
|
|
549
|
+
/* Two conditions, and the config is the one that matters: Stryker being installed says the tool COULD run,
|
|
550
|
+
* the config says somebody decided it should. Without the second this would report "unavailable" on every
|
|
551
|
+
* repo that merely shares this monorepo's lockfile.
|
|
552
|
+
*
|
|
553
|
+
* The repo's OWN Stryker, never a floating one, for a reason measured rather than assumed: under
|
|
554
|
+
* `pnpm dlx` the plugin loader resolves from the working directory and finds nothing — "Cannot find
|
|
555
|
+
* TestRunner plugin \"vitest\". In fact, no TestRunner plugins were loaded" — and the runner has to agree
|
|
556
|
+
* with the vitest the repo actually runs. Same argument as knip above. */
|
|
557
|
+
available: `{ test -f stryker.conf.mjs || test -f stryker.conf.json; } && pnpm exec stryker --version >/dev/null 2>&1`,
|
|
558
|
+
unavailable: `no stryker config in this repo`,
|
|
559
|
+
// `|| true` for the same reason the others carry it: Stryker exits non-zero when the score is under its
|
|
560
|
+
// own threshold, which is an opinion about the number, not a failure to produce one. The judgement is the
|
|
561
|
+
// chore's, made from the score, and the runner decides by whether the parser recognised the report.
|
|
562
|
+
command: `pnpm exec stryker run --reporters json --incremental >/dev/null 2>&1 || true; cat ${MUTATION_REPORT} 2>/dev/null`,
|
|
563
|
+
parse: parseMutation,
|
|
564
|
+
},
|
|
433
565
|
{
|
|
434
566
|
id: `ui`,
|
|
435
567
|
title: `Front-end source`,
|