@intentic/sandbox-contract 1.176.2 → 1.209.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 +65 -0
- package/dist/agent-catalog.d.ts +15 -2
- package/dist/agent-catalog.d.ts.map +1 -1
- package/dist/agent-catalog.js +50 -1
- package/dist/agent-catalog.js.map +1 -1
- package/dist/agent-run-model.d.ts +3 -0
- package/dist/agent-run-model.d.ts.map +1 -0
- package/dist/agent-run-model.js +10 -0
- package/dist/agent-run-model.js.map +1 -0
- package/dist/capability-env.d.ts +2 -0
- package/dist/capability-env.d.ts.map +1 -0
- package/dist/capability-env.js +2 -0
- package/dist/capability-env.js.map +1 -0
- package/dist/chores/chores.d.ts +9 -0
- package/dist/chores/chores.d.ts.map +1 -1
- package/dist/chores/chores.js +257 -18
- package/dist/chores/chores.js.map +1 -1
- package/dist/chores/digest.js +0 -0
- package/dist/chores/digest.js.map +1 -1
- package/dist/chores/extension-update.d.ts +9 -0
- package/dist/chores/extension-update.d.ts.map +1 -0
- package/dist/chores/extension-update.js +15 -0
- package/dist/chores/extension-update.js.map +1 -0
- package/dist/chores/fix-deps.d.ts +10 -0
- package/dist/chores/fix-deps.d.ts.map +1 -0
- package/dist/chores/fix-deps.js +18 -0
- package/dist/chores/fix-deps.js.map +1 -0
- package/dist/chores/index.d.ts +7 -2
- package/dist/chores/index.d.ts.map +1 -1
- package/dist/chores/index.js +4 -1
- package/dist/chores/index.js.map +1 -1
- package/dist/chores/probes.d.ts +1 -0
- package/dist/chores/probes.d.ts.map +1 -1
- package/dist/chores/probes.js +131 -3
- package/dist/chores/probes.js.map +1 -1
- package/dist/chores/stack.d.ts +27 -0
- package/dist/chores/stack.d.ts.map +1 -0
- package/dist/chores/stack.js +121 -0
- package/dist/chores/stack.js.map +1 -0
- package/dist/chores/verdict.d.ts +2 -1
- package/dist/chores/verdict.d.ts.map +1 -1
- package/dist/chores/verdict.js +57 -7
- package/dist/chores/verdict.js.map +1 -1
- package/dist/contract-lock.d.ts +3 -0
- package/dist/contract-lock.d.ts.map +1 -0
- package/dist/contract-lock.js +35 -0
- package/dist/contract-lock.js.map +1 -0
- package/dist/contracts/activity.contract.d.ts +10 -0
- package/dist/contracts/activity.contract.d.ts.map +1 -1
- package/dist/contracts/agent.contract.d.ts +230 -20
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agent.contract.js +2 -1
- package/dist/contracts/agent.contract.js.map +1 -1
- package/dist/contracts/agents.contract.d.ts +847 -1
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.js +3 -1
- package/dist/contracts/agents.contract.js.map +1 -1
- package/dist/contracts/automations.contract.d.ts +89 -0
- package/dist/contracts/automations.contract.d.ts.map +1 -1
- package/dist/contracts/automations.contract.js +3 -1
- package/dist/contracts/automations.contract.js.map +1 -1
- package/dist/contracts/capabilities.contract.d.ts +108 -32
- package/dist/contracts/capabilities.contract.d.ts.map +1 -1
- package/dist/contracts/capabilities.contract.js +5 -1
- package/dist/contracts/capabilities.contract.js.map +1 -1
- package/dist/contracts/chores.contract.d.ts +43 -0
- package/dist/contracts/chores.contract.d.ts.map +1 -1
- package/dist/contracts/ci.contract.d.ts +5 -0
- package/dist/contracts/ci.contract.d.ts.map +1 -1
- package/dist/contracts/ci.contract.js +2 -2
- package/dist/contracts/ci.contract.js.map +1 -1
- package/dist/contracts/claude.contract.d.ts +3 -14
- package/dist/contracts/claude.contract.d.ts.map +1 -1
- package/dist/contracts/claude.contract.js +2 -3
- package/dist/contracts/claude.contract.js.map +1 -1
- package/dist/contracts/drafts.contract.d.ts +4 -0
- package/dist/contracts/drafts.contract.d.ts.map +1 -1
- package/dist/contracts/endpoints.contract.d.ts +28 -12
- package/dist/contracts/endpoints.contract.d.ts.map +1 -1
- package/dist/contracts/endpoints.contract.js +9 -0
- package/dist/contracts/endpoints.contract.js.map +1 -1
- package/dist/contracts/extensions.contract.d.ts +399 -69
- package/dist/contracts/extensions.contract.d.ts.map +1 -1
- package/dist/contracts/extensions.contract.js +15 -1
- package/dist/contracts/extensions.contract.js.map +1 -1
- package/dist/contracts/git.contract.d.ts +209 -11
- package/dist/contracts/git.contract.d.ts.map +1 -1
- package/dist/contracts/git.contract.js +12 -2
- package/dist/contracts/git.contract.js.map +1 -1
- package/dist/contracts/grok.contract.d.ts +0 -13
- package/dist/contracts/grok.contract.d.ts.map +1 -1
- package/dist/contracts/grok.contract.js +1 -2
- package/dist/contracts/grok.contract.js.map +1 -1
- package/dist/contracts/host.contract.d.ts +39 -0
- package/dist/contracts/host.contract.d.ts.map +1 -1
- package/dist/contracts/host.contract.js +3 -2
- package/dist/contracts/host.contract.js.map +1 -1
- package/dist/contracts/loops.contract.d.ts +343 -0
- package/dist/contracts/loops.contract.d.ts.map +1 -0
- package/dist/contracts/loops.contract.js +11 -0
- package/dist/contracts/loops.contract.js.map +1 -0
- package/dist/contracts/panels.contract.d.ts +6 -0
- package/dist/contracts/panels.contract.d.ts.map +1 -1
- package/dist/contracts/personas.contract.d.ts +112 -0
- package/dist/contracts/personas.contract.d.ts.map +1 -0
- package/dist/contracts/personas.contract.js +13 -0
- package/dist/contracts/personas.contract.js.map +1 -0
- package/dist/contracts/ports.contract.d.ts +1 -0
- package/dist/contracts/ports.contract.d.ts.map +1 -1
- package/dist/contracts/prepush.contract.d.ts +1 -0
- package/dist/contracts/prepush.contract.d.ts.map +1 -1
- package/dist/contracts/prepush.contract.js.map +1 -1
- package/dist/contracts/{codex.contract.d.ts → providers.contract.d.ts} +11 -3
- package/dist/contracts/providers.contract.d.ts.map +1 -0
- package/dist/contracts/providers.contract.js +6 -0
- package/dist/contracts/providers.contract.js.map +1 -0
- package/dist/contracts/public.contract.d.ts +24 -0
- package/dist/contracts/public.contract.d.ts.map +1 -0
- package/dist/contracts/public.contract.js +8 -0
- package/dist/contracts/public.contract.js.map +1 -0
- package/dist/contracts/push.contract.d.ts +3 -3
- package/dist/contracts/secrets.contract.d.ts +9 -0
- package/dist/contracts/secrets.contract.d.ts.map +1 -1
- package/dist/contracts/sessions.contract.d.ts +16 -1
- package/dist/contracts/sessions.contract.d.ts.map +1 -1
- package/dist/contracts/sessions.contract.js +1 -1
- package/dist/contracts/sessions.contract.js.map +1 -1
- package/dist/contracts/settings.contract.d.ts +352 -58
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.js +2 -1
- package/dist/contracts/settings.contract.js.map +1 -1
- package/dist/contracts/share.contract.d.ts +55 -0
- package/dist/contracts/share.contract.d.ts.map +1 -0
- package/dist/contracts/share.contract.js +9 -0
- package/dist/contracts/share.contract.js.map +1 -0
- package/dist/contracts/skills.contract.d.ts +41 -0
- package/dist/contracts/skills.contract.d.ts.map +1 -0
- package/dist/contracts/skills.contract.js +9 -0
- package/dist/contracts/skills.contract.js.map +1 -0
- package/dist/contracts/system.contract.d.ts +218 -22
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.js +10 -2
- package/dist/contracts/system.contract.js.map +1 -1
- package/dist/contracts/workflows.contract.d.ts +575 -0
- package/dist/contracts/workflows.contract.d.ts.map +1 -0
- package/dist/contracts/workflows.contract.js +13 -0
- package/dist/contracts/workflows.contract.js.map +1 -0
- package/dist/contracts/workspace.contract.d.ts +18 -4
- package/dist/contracts/workspace.contract.d.ts.map +1 -1
- package/dist/contracts/workspace.contract.js +6 -3
- package/dist/contracts/workspace.contract.js.map +1 -1
- package/dist/conversation-ids.d.ts +2 -0
- package/dist/conversation-ids.d.ts.map +1 -0
- package/dist/conversation-ids.js +113 -0
- package/dist/conversation-ids.js.map +1 -0
- package/dist/events.d.ts +523 -17
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +106 -13
- package/dist/events.js.map +1 -1
- package/dist/history-state.d.ts +3 -0
- package/dist/history-state.d.ts.map +1 -0
- package/dist/history-state.js +53 -0
- package/dist/history-state.js.map +1 -0
- package/dist/hostnames.d.ts +4 -0
- package/dist/hostnames.d.ts.map +1 -1
- package/dist/hostnames.js +7 -2
- package/dist/hostnames.js.map +1 -1
- package/dist/index.d.ts +3793 -478
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +41 -17
- package/dist/index.js.map +1 -1
- package/dist/listener-protocol.d.ts +77 -0
- package/dist/listener-protocol.d.ts.map +1 -0
- package/dist/listener-protocol.js +33 -0
- package/dist/listener-protocol.js.map +1 -0
- package/dist/model-order.d.ts +1 -0
- package/dist/model-order.d.ts.map +1 -1
- package/dist/model-order.js +16 -0
- package/dist/model-order.js.map +1 -1
- package/dist/output-fields.d.ts +30 -0
- package/dist/output-fields.d.ts.map +1 -0
- package/dist/output-fields.js +57 -0
- package/dist/output-fields.js.map +1 -0
- package/dist/path-refs.d.ts.map +1 -1
- package/dist/path-refs.js.map +1 -1
- package/dist/publish-drafts.d.ts +7 -0
- package/dist/publish-drafts.d.ts.map +1 -0
- package/dist/publish-drafts.js +20 -0
- package/dist/publish-drafts.js.map +1 -0
- package/dist/quick-model.d.ts +2 -1
- package/dist/quick-model.d.ts.map +1 -1
- package/dist/quick-model.js +18 -14
- package/dist/quick-model.js.map +1 -1
- package/dist/routes.d.ts +3 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +63 -0
- package/dist/routes.js.map +1 -1
- package/dist/runtime-state.d.ts +33 -0
- package/dist/runtime-state.d.ts.map +1 -0
- package/dist/runtime-state.js +14 -0
- package/dist/runtime-state.js.map +1 -0
- package/dist/schemas.d.ts +4938 -866
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +1011 -192
- package/dist/schemas.js.map +1 -1
- package/dist/search-globs.d.ts +6 -0
- package/dist/search-globs.d.ts.map +1 -0
- package/dist/search-globs.js +39 -0
- package/dist/search-globs.js.map +1 -0
- package/dist/share-paths.d.ts +8 -0
- package/dist/share-paths.d.ts.map +1 -0
- package/dist/share-paths.js +21 -0
- package/dist/share-paths.js.map +1 -0
- package/dist/state-portability.d.ts +8 -0
- package/dist/state-portability.d.ts.map +1 -0
- package/dist/state-portability.js +4 -0
- package/dist/state-portability.js.map +1 -0
- package/dist/title.d.ts.map +1 -1
- package/dist/title.js +1 -1
- package/dist/title.js.map +1 -1
- package/dist/tunnel-ids.d.ts +1 -0
- package/dist/tunnel-ids.d.ts.map +1 -1
- package/dist/tunnel-ids.js +1 -0
- package/dist/tunnel-ids.js.map +1 -1
- package/dist/versions.d.ts +4 -0
- package/dist/versions.d.ts.map +1 -0
- package/dist/versions.js +15 -0
- package/dist/versions.js.map +1 -0
- package/dist/workflow-faults.d.ts +4 -0
- package/dist/workflow-faults.d.ts.map +1 -0
- package/dist/workflow-faults.js +115 -0
- package/dist/workflow-faults.js.map +1 -0
- package/dist/workspace-state.d.ts +282 -3
- package/dist/workspace-state.d.ts.map +1 -1
- package/dist/workspace-state.js +198 -12
- package/dist/workspace-state.js.map +1 -1
- package/package.json +30 -6
- package/src/agent-catalog.test.ts +147 -7
- package/src/agent-catalog.ts +233 -21
- package/src/agent-run-model.test.ts +60 -0
- package/src/agent-run-model.ts +51 -0
- package/src/capability-env.ts +10 -0
- package/src/capability-ledger.test.ts +110 -0
- package/src/chores/chores.test.ts +32 -0
- package/src/chores/chores.ts +456 -32
- package/src/chores/digest.ts +0 -0
- package/src/chores/extension-update.ts +37 -0
- package/src/chores/fix-deps.ts +40 -0
- package/src/chores/index.ts +7 -2
- package/src/chores/probes.test.ts +155 -3
- package/src/chores/probes.ts +209 -4
- package/src/chores/stack.test.ts +126 -0
- package/src/chores/stack.ts +244 -0
- package/src/chores/verdict.test.ts +357 -19
- package/src/chores/verdict.ts +102 -22
- package/src/contract-lock.test.ts +17 -0
- package/src/contract-lock.ts +66 -0
- package/src/contracts/agent.contract.ts +6 -0
- package/src/contracts/agents.contract.ts +12 -0
- package/src/contracts/automations.contract.ts +17 -1
- package/src/contracts/capabilities.contract.ts +22 -0
- package/src/contracts/ci.contract.ts +10 -2
- package/src/contracts/claude.contract.ts +8 -4
- package/src/contracts/endpoints.contract.ts +21 -0
- package/src/contracts/extensions.contract.ts +42 -0
- package/src/contracts/git.contract.ts +33 -6
- package/src/contracts/grok.contract.ts +4 -3
- package/src/contracts/host.contract.ts +13 -2
- package/src/contracts/loops.contract.ts +62 -0
- package/src/contracts/personas.contract.ts +56 -0
- package/src/contracts/prepush.contract.ts +4 -3
- package/src/contracts/providers.contract.ts +23 -0
- package/src/contracts/public.contract.ts +14 -0
- package/src/contracts/sessions.contract.ts +5 -1
- package/src/contracts/settings.contract.ts +5 -0
- package/src/contracts/share.contract.ts +20 -0
- package/src/contracts/skills.contract.ts +24 -0
- package/src/contracts/system.contract.ts +27 -3
- package/src/contracts/workflows.contract.ts +78 -0
- package/src/contracts/workspace.contract.ts +10 -2
- package/src/conversation-ids.test.ts +25 -0
- package/src/conversation-ids.ts +160 -0
- package/src/events.test.ts +20 -1
- package/src/events.ts +404 -43
- package/src/history-state.ts +129 -0
- package/src/hostnames.ts +20 -6
- package/src/index.ts +60 -17
- package/src/listener-protocol.ts +96 -0
- package/src/model-order.test.ts +32 -1
- package/src/model-order.ts +56 -3
- package/src/output-fields.ts +111 -0
- package/src/path-refs.test.ts +4 -4
- package/src/path-refs.ts +5 -5
- package/src/publish-drafts.ts +59 -0
- package/src/quick-model.test.ts +70 -27
- package/src/quick-model.ts +76 -40
- package/src/routes.test.ts +91 -3
- package/src/routes.ts +135 -0
- package/src/runtime-state.test.ts +45 -0
- package/src/runtime-state.ts +124 -0
- package/src/schemas.test.ts +16 -29
- package/src/schemas.ts +3526 -553
- package/src/search-globs.test.ts +46 -0
- package/src/search-globs.ts +76 -0
- package/src/share-paths.test.ts +48 -0
- package/src/share-paths.ts +68 -0
- package/src/state-portability.ts +56 -0
- package/src/title.test.ts +20 -1
- package/src/title.ts +12 -3
- package/src/tunnel-ids.ts +15 -1
- package/src/versions.test.ts +39 -0
- package/src/versions.ts +48 -0
- package/src/workflow-faults.test.ts +127 -0
- package/src/workflow-faults.ts +188 -0
- package/src/workspace-state.test.ts +189 -17
- package/src/workspace-state.ts +500 -30
- package/dist/contracts/codex.contract.d.ts.map +0 -1
- package/dist/contracts/codex.contract.js +0 -6
- package/dist/contracts/codex.contract.js.map +0 -1
- package/dist/contracts/gemini.contract.d.ts +0 -16
- package/dist/contracts/gemini.contract.d.ts.map +0 -1
- package/dist/contracts/gemini.contract.js +0 -6
- package/dist/contracts/gemini.contract.js.map +0 -1
- package/dist/contracts/kimi.contract.d.ts +0 -16
- package/dist/contracts/kimi.contract.d.ts.map +0 -1
- package/dist/contracts/kimi.contract.js +0 -6
- package/dist/contracts/kimi.contract.js.map +0 -1
- package/dist/contracts/komodo.contract.d.ts +0 -125
- package/dist/contracts/komodo.contract.d.ts.map +0 -1
- package/dist/contracts/komodo.contract.js +0 -14
- package/dist/contracts/komodo.contract.js.map +0 -1
- package/dist/contracts/memory.contract.d.ts +0 -34
- package/dist/contracts/memory.contract.d.ts.map +0 -1
- package/dist/contracts/memory.contract.js +0 -9
- package/dist/contracts/memory.contract.js.map +0 -1
- package/src/contracts/codex.contract.ts +0 -10
- package/src/contracts/gemini.contract.ts +0 -11
- package/src/contracts/kimi.contract.ts +0 -9
- package/src/contracts/komodo.contract.ts +0 -36
- package/src/contracts/memory.contract.ts +0 -13
package/src/chores/digest.ts
CHANGED
|
Binary file
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { composeAsk } from "./prompt.js";
|
|
2
|
+
|
|
3
|
+
/* AN EXTENSION UPDATE, READ AS A DIFF. The commit that is installed was approved once already — re-reading all
|
|
4
|
+
* of it would bury the one question an update asks: what is different, and did any of it change the deal? So
|
|
5
|
+
* the turn's subject is the diff between the two commits, and the manifest's delta leads, because a new entry
|
|
6
|
+
* in `permissions.sandbox` is reach the owner never approved, arriving dressed as an update.
|
|
7
|
+
*
|
|
8
|
+
* Here in the contract's chores rather than in the web app, because two callers build it: the update card's
|
|
9
|
+
* "read the diff" button, and the daemon's agent-prepared update policy — which runs this exact read
|
|
10
|
+
* unprompted when the registry lists a new sha, so the owner opens a finished account instead of starting one. */
|
|
11
|
+
const UPDATE_INVARIANTS =
|
|
12
|
+
`This turn reads and reports; it changes nothing and installs nothing. Clone into a scratch directory ` +
|
|
13
|
+
`outside the workspace and read the diff between the two commits — the installed code was approved once ` +
|
|
14
|
+
`already, so what is between them is the whole subject. Lead with the manifest's delta: any route added to ` +
|
|
15
|
+
`\`permissions.sandbox\` is reach the owner never approved and the headline whatever else changed. Then the ` +
|
|
16
|
+
`code: what behaviour changed, in the owner's terms, citing file and line.`;
|
|
17
|
+
|
|
18
|
+
export interface UpdateBrief {
|
|
19
|
+
// The listing's display name, or the repository when it is being installed straight from a URL.
|
|
20
|
+
readonly label: string;
|
|
21
|
+
readonly url: string;
|
|
22
|
+
// What is installed and what the update proposes — both full shas, both facts, neither a branch.
|
|
23
|
+
readonly fromRef: string;
|
|
24
|
+
readonly toRef: string;
|
|
25
|
+
// Subdirectory inside the repository, for a monorepo source. Empty for a repo of its own.
|
|
26
|
+
readonly path: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const updateBrief = ({ label, url, fromRef, toRef, path }: UpdateBrief): string =>
|
|
30
|
+
composeAsk({
|
|
31
|
+
subject: `Read what changed in the ${label} extension before it is updated here: ${url}, from ${fromRef} to ${toRef}${path === `` ? `` : `, in ${path}`}.`,
|
|
32
|
+
why: `The installed commit was approved once already; the update replaces it wholesale, because the sha is the identity and there is no build step between the pushed bytes and the code that runs.`,
|
|
33
|
+
diagnosis: `The manifest (intentic-extension.json at the extension root) is the contract on both sides of the diff, so its delta is readable exactly like the code's.`,
|
|
34
|
+
goal: `Read the diff and say what the update actually is: the manifest delta first, then what the code now does that it did not, and what it stopped doing.`,
|
|
35
|
+
invariants: UPDATE_INVARIANTS,
|
|
36
|
+
done: `Done when you end on a recommendation the owner can act on — update, update and watch something named, or stay on ${fromRef.slice(0, 7)} — with the change that decided it cited by file and line.`,
|
|
37
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/* THE FIX CHORE — one definition for the two surfaces that must agree on it.
|
|
2
|
+
*
|
|
3
|
+
* The daemon seeds this automation into a workspace (default-automations.ts in the sandbox package) and the
|
|
4
|
+
* automations extension offers it as a recipe for anyone who deleted it and wants it back. Written once here
|
|
5
|
+
* for the chore book's own reason: two copies of a prompt drift, and only one of them gets fixed when we
|
|
6
|
+
* learn how to phrase it.
|
|
7
|
+
*
|
|
8
|
+
* It wakes on `deps.broken` — the dependency verifier's edge event: a landed change drifted the installed
|
|
9
|
+
* dependencies, the daemon reinstalled them, ran the tree's own checks, and they came back red. The payload
|
|
10
|
+
* is that event: `deps.project` is the project whose checks failed, `deps.command` the exact command that
|
|
11
|
+
* judged it, `deps.exitCode` and `deps.logTail` what it said, and `deps.attempt` which consecutive red this
|
|
12
|
+
* is since the last green.
|
|
13
|
+
*
|
|
14
|
+
* THE GUARD IS THE LOOP CAP, and it lives in guard shell — one visible, owner-editable line — rather than in
|
|
15
|
+
* daemon code, because whoever tunes "how many tries before a human looks" must be able to see the number.
|
|
16
|
+
* Attempt 1 is the breakage, attempt 2 is one landed fix that still failed; past that the loop stops and the
|
|
17
|
+
* standing red is the owner's to read (the activity feed has been narrating every step).
|
|
18
|
+
*
|
|
19
|
+
* THE HOLD IS THE CONSENT. This automation is seeded enabled — the one exception to "the owner started it",
|
|
20
|
+
* made deliberately: a broken tree costs every conversation that builds on it, and the countdown is what
|
|
21
|
+
* keeps the owner in charge — each fire is held, visibly, for this many seconds on the Automations page
|
|
22
|
+
* before it starts, cancellable the whole way, and it never starts while another agent is mid-turn. */
|
|
23
|
+
export const FIX_DEPS_AUTOMATION = {
|
|
24
|
+
id: "fix-dependency-breakage",
|
|
25
|
+
title: "Fix what a dependency change broke",
|
|
26
|
+
event: "deps.broken",
|
|
27
|
+
holdForSeconds: 60,
|
|
28
|
+
guard: `test "$(printf '%s' "$AUTOMATION_PAYLOAD" | jq -r '.deps.attempt // 1')" -le 2`,
|
|
29
|
+
guardNote: "stops after 2 attempts",
|
|
30
|
+
prompt:
|
|
31
|
+
"A landed change drifted this workspace's dependencies; the daemon reinstalled them and ran the project's own checks, " +
|
|
32
|
+
"and they failed. The payload names the project (`deps.project`), the check command (`deps.command`), how it exited " +
|
|
33
|
+
"(`deps.exitCode`) and the tail of its output (`deps.logTail`); the full log is in the project's `--verify` terminal.\n\n" +
|
|
34
|
+
"Re-run the check yourself to see the failure first-hand, then fix the ROOT CAUSE. That usually means updating call " +
|
|
35
|
+
"sites, types or tests to match what actually changed — never loosening or deleting the checks, pinning or downgrading " +
|
|
36
|
+
"dependencies just to silence them, or editing generated files. If the breakage needs a decision only the owner can " +
|
|
37
|
+
"make (an intentional breaking upgrade, a license change, a dependency that should be dropped), stop and say exactly " +
|
|
38
|
+
"that instead of guessing.\n\n" +
|
|
39
|
+
"Finish by running the same check command and reporting what was broken, what you changed, and whether it is green now.",
|
|
40
|
+
} as const;
|
package/src/chores/index.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
export { CHORES, choreAutomationPrompt, choreById, chorePrompt, repoLabel } from "./chores.js";
|
|
2
|
-
export
|
|
1
|
+
export { CHORE_KINDS, CHORES, choreAutomationPrompt, choreById, chorePrompt, repoLabel, repoName } from "./chores.js";
|
|
2
|
+
export { FIX_DEPS_AUTOMATION } from "./fix-deps.js";
|
|
3
|
+
export type { Chore, ChoreContext, ChoreFinding, ChoreKind, ChoreKindSpec, ChoreStance } from "./chores.js";
|
|
3
4
|
export { bucketOf, digestOf } from "./digest.js";
|
|
4
5
|
export { CHORE_INVARIANTS, composeAsk, REFACTOR_INVARIANTS, REPORT_INVARIANTS, TRIAGE_NOTE } from "./prompt.js";
|
|
5
6
|
export type { Ask } from "./prompt.js";
|
|
7
|
+
export { updateBrief } from "./extension-update.js";
|
|
8
|
+
export type { UpdateBrief } from "./extension-update.js";
|
|
6
9
|
export { PROBES, probeSpec } from "./probes.js";
|
|
7
10
|
export type { ProbeSpec } from "./probes.js";
|
|
11
|
+
export { componentStem, frameworksOf, IDIOM_RULES, idiomRule, normalizePath, UI_FRAMEWORKS, usesTailwind } from "./stack.js";
|
|
12
|
+
export type { IdiomRule, UiFramework } from "./stack.js";
|
|
8
13
|
export { assessChore, assessReport, ledgerKey, unseenVerdicts } from "./verdict.js";
|
|
9
14
|
export type { ChoreState, ChoreVerdict } from "./verdict.js";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
2
|
import { probeSpec } from "./probes.js";
|
|
3
|
+
import { IDIOM_RULES } from "./stack.js";
|
|
3
4
|
|
|
4
5
|
/* The parsers are the part of this library that faces someone else's output, so they are tested the way that
|
|
5
6
|
* output actually arrives: real shapes, then the shapes that have historically broken things — a tool that
|
|
@@ -30,8 +31,14 @@ describe(`outdated`, () => {
|
|
|
30
31
|
});
|
|
31
32
|
|
|
32
33
|
test(`skips entries pnpm could not resolve, rather than inventing a version for them`, () => {
|
|
33
|
-
const facts = parse(
|
|
34
|
-
|
|
34
|
+
const facts = parse(
|
|
35
|
+
`outdated`,
|
|
36
|
+
JSON.stringify({ ok: { current: `1.0.0`, latest: `2.0.0` }, broken: { current: `1.0.0` }, alsoBroken: null }),
|
|
37
|
+
);
|
|
38
|
+
expect(facts).toEqual({
|
|
39
|
+
id: `outdated`,
|
|
40
|
+
packages: [{ name: `ok`, current: `1.0.0`, latest: `2.0.0`, kind: `major`, section: `dependencies` }],
|
|
41
|
+
});
|
|
35
42
|
});
|
|
36
43
|
|
|
37
44
|
// pnpm prints deprecation and lockfile notices on the same stream in some versions; the JSON still has to be
|
|
@@ -94,7 +101,14 @@ describe(`knip`, () => {
|
|
|
94
101
|
`knip`,
|
|
95
102
|
JSON.stringify({
|
|
96
103
|
issues: [
|
|
97
|
-
{
|
|
104
|
+
{
|
|
105
|
+
file: `src/a.ts`,
|
|
106
|
+
exports: [{ name: `x` }, { name: `y` }],
|
|
107
|
+
types: [{ name: `T` }],
|
|
108
|
+
dependencies: [{ name: `lodash` }],
|
|
109
|
+
devDependencies: [],
|
|
110
|
+
files: [],
|
|
111
|
+
},
|
|
98
112
|
{ file: `src/b.ts`, exports: [{ name: `z` }], types: [], dependencies: [], devDependencies: [{ name: `jest` }], files: [] },
|
|
99
113
|
{ file: `src/old.ts`, exports: [], files: [{ name: `src/old.ts` }] },
|
|
100
114
|
{ file: `src/older.ts`, exports: [], files: [{ name: `src/older.ts` }] },
|
|
@@ -164,3 +178,141 @@ describe(`jscpd`, () => {
|
|
|
164
178
|
expect(parse(`jscpd`, ``)).toBeUndefined();
|
|
165
179
|
});
|
|
166
180
|
});
|
|
181
|
+
|
|
182
|
+
/* The UI sweep is the one probe whose output we produce ourselves, which removes the "their JSON moved" failure
|
|
183
|
+
* and replaces it with a worse one: a command of eleven piped ripgreps in which any single stage can silently
|
|
184
|
+
* contribute nothing. The marker line is what tells those two apart, and most of what is below is about it. */
|
|
185
|
+
describe(`ui`, () => {
|
|
186
|
+
const sweep = (...lines: readonly string[]) => [`UI`, ...lines].join(`\n`);
|
|
187
|
+
|
|
188
|
+
test(`sorts the labelled lines into an inventory, per-file counts and idioms`, () => {
|
|
189
|
+
const facts = parse(
|
|
190
|
+
`ui`,
|
|
191
|
+
sweep(
|
|
192
|
+
`COMPONENT\tsrc/Button.vue`,
|
|
193
|
+
`COMPONENT\tsrc/Card.tsx`,
|
|
194
|
+
`BYPASS\tsrc/Button.vue:3`,
|
|
195
|
+
`IDIOM\tvue-options-api\tsrc/Button.vue`,
|
|
196
|
+
`IDIOM\tvue-2-lifecycle\tsrc/Button.vue`,
|
|
197
|
+
`IDIOM\tvue-options-api\tsrc/Old.vue`,
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
expect(facts).toEqual({
|
|
201
|
+
id: `ui`,
|
|
202
|
+
scan: {
|
|
203
|
+
components: [`src/Button.vue`, `src/Card.tsx`],
|
|
204
|
+
bypasses: [{ path: `src/Button.vue`, count: 3 }],
|
|
205
|
+
idioms: [
|
|
206
|
+
{ id: `vue-options-api`, files: [`src/Button.vue`, `src/Old.vue`] },
|
|
207
|
+
{ id: `vue-2-lifecycle`, files: [`src/Button.vue`] },
|
|
208
|
+
],
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
/* The distinction the marker exists for, and the one this whole probe would get wrong without it: a
|
|
214
|
+
* repository with no components and no findings emits exactly the marker, while a sweep that never ran emits
|
|
215
|
+
* nothing. Collapsing them would report a spotless front-end for a command that failed to start. */
|
|
216
|
+
test(`the marker alone is a clean repository`, () => {
|
|
217
|
+
expect(parse(`ui`, sweep())).toEqual({ id: `ui`, scan: { components: [], bypasses: [], idioms: [] } });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test(`output with no marker is a failure, however much of it there is`, () => {
|
|
221
|
+
expect(parse(`ui`, ``)).toBeUndefined();
|
|
222
|
+
expect(parse(`ui`, `COMPONENT\tsrc/Button.vue`)).toBeUndefined();
|
|
223
|
+
expect(parse(`ui`, `rg: unrecognized flag --count-matches`)).toBeUndefined();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// A path with a colon in it is legal and rare; the count is always the digits after the last one.
|
|
227
|
+
test(`splits a count off the end of a path that contains a colon`, () => {
|
|
228
|
+
expect(parse(`ui`, sweep(`BYPASS\tsrc/weird:name.vue:7`))).toMatchObject({ scan: { bypasses: [{ path: `src/weird:name.vue`, count: 7 }] } });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test(`a line that is not a count is dropped rather than counted as zero`, () => {
|
|
232
|
+
expect(parse(`ui`, sweep(`BYPASS\tsrc/Button.vue`, `BYPASS\tsrc/Card.tsx:notanumber`))).toMatchObject({ scan: { bypasses: [] } });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
/* Every path the sweep prints wears a `./`, because every ripgrep in it is handed `.` to walk. Downstream this
|
|
236
|
+
* would have to be remembered at each comparison — jscpd's paths against the component list, a bypass against
|
|
237
|
+
* a component — so it is spent once, here, and one spelling of a path leaves the parser. */
|
|
238
|
+
test(`strips the prefix ripgrep prints for a path it was told to walk`, () => {
|
|
239
|
+
expect(parse(`ui`, sweep(`COMPONENT\t./src/Button.vue`, `BYPASS\t./src/Button.vue:3`, `IDIOM\tvue-options-api\t./src/Old.vue`))).toEqual({
|
|
240
|
+
id: `ui`,
|
|
241
|
+
scan: {
|
|
242
|
+
components: [`src/Button.vue`],
|
|
243
|
+
bypasses: [{ path: `src/Button.vue`, count: 3 }],
|
|
244
|
+
idioms: [{ id: `vue-options-api`, files: [`src/Old.vue`] }],
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test(`an idiom line naming no file is dropped rather than recorded as an empty path`, () => {
|
|
250
|
+
expect(parse(`ui`, sweep(`IDIOM\tvue-options-api`, `IDIOM\tvue-options-api\t`))).toMatchObject({ scan: { idioms: [] } });
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
/* THE COMMAND ITSELF, which for this probe is generated and therefore the thing to test. Both cases below are
|
|
255
|
+
* bugs that reached a real repository and could not be seen in the output: one made the sweep silently empty, the
|
|
256
|
+
* other made it depend on a ripgrep feature that is a compile-time option. */
|
|
257
|
+
describe(`the sweep's composed command`, () => {
|
|
258
|
+
const stages = (): string[] => probeSpec(`ui`).command.split(`; `);
|
|
259
|
+
|
|
260
|
+
/* Given no path, ripgrep searches STDIN whenever stdin is not a TTY — which is exactly how a probe is spawned.
|
|
261
|
+
* The sweep exited 0, printed its marker and matched nothing, in every repository, forever, which the marker
|
|
262
|
+
* line cannot catch because the sweep really did run. It reproduces from a child process and never from an
|
|
263
|
+
* interactive shell, so the command is the only place it is visible. */
|
|
264
|
+
test(`every ripgrep is given a path to walk, including the availability gate`, () => {
|
|
265
|
+
const searches = stages().filter((stage) => stage.startsWith(`rg `));
|
|
266
|
+
expect(searches).toHaveLength(IDIOM_RULES.length + 2);
|
|
267
|
+
for (const search of searches) {
|
|
268
|
+
expect(search.split(`2>/dev/null`)[0], search).toMatch(/ \.\s*$/);
|
|
269
|
+
}
|
|
270
|
+
expect(probeSpec(`ui`).available).toMatch(/ \. >\/dev\/null$/);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test(`an absent rule asks which files do NOT match, and no rule reaches for PCRE2`, () => {
|
|
274
|
+
for (const rule of IDIOM_RULES) {
|
|
275
|
+
const stage = stages().find((part) => part.includes(`"IDIOM\\t${rule.id}\\t"`));
|
|
276
|
+
expect(stage, rule.id).toContain(rule.absent === undefined ? `rg --no-messages -l ` : `rg --no-messages --files-without-match `);
|
|
277
|
+
}
|
|
278
|
+
expect(probeSpec(`ui`).command).not.toContain(`-P `);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe(`bundle`, () => {
|
|
283
|
+
test(`reads the directory and each asset's raw and gzipped size`, () => {
|
|
284
|
+
const facts = parse(
|
|
285
|
+
`bundle`,
|
|
286
|
+
[`DIR\tdist`, `ASSET\t54038\t41096\tdist/assets/vendor-abc.js`, `ASSET\t2704\t2103\tdist/assets/style.css`].join(`\n`),
|
|
287
|
+
);
|
|
288
|
+
expect(facts).toEqual({
|
|
289
|
+
id: `bundle`,
|
|
290
|
+
bundle: {
|
|
291
|
+
dir: `dist`,
|
|
292
|
+
totalBytes: 56742,
|
|
293
|
+
totalGzip: 43199,
|
|
294
|
+
assets: [
|
|
295
|
+
{ path: `dist/assets/vendor-abc.js`, bytes: 54038, gzip: 41096 },
|
|
296
|
+
{ path: `dist/assets/style.css`, bytes: 2704, gzip: 2103 },
|
|
297
|
+
],
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// The `find` prints nothing for a directory that exists but holds no assets. `available` is supposed to catch
|
|
303
|
+
// that, and this is the second line of defence — a zero-byte bundle would otherwise read as a fact.
|
|
304
|
+
test(`a directory line with no assets is an empty build, not a failure`, () => {
|
|
305
|
+
expect(parse(`bundle`, `DIR\tbuild`)).toMatchObject({ bundle: { dir: `build`, assets: [], totalBytes: 0, totalGzip: 0 } });
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test(`no directory line is a failure`, () => {
|
|
309
|
+
expect(parse(`bundle`, ``)).toBeUndefined();
|
|
310
|
+
expect(parse(`bundle`, `find: dist: No such file or directory`)).toBeUndefined();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test(`an asset whose sizes did not come through is skipped, never counted as zero bytes`, () => {
|
|
314
|
+
expect(parse(`bundle`, [`DIR\tdist`, `ASSET\t\t\tdist/broken.js`, `ASSET\t10\t5\tdist/ok.js`].join(`\n`))).toMatchObject({
|
|
315
|
+
bundle: { assets: [{ path: `dist/ok.js`, bytes: 10, gzip: 5 }] },
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
});
|
package/src/chores/probes.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
import type { Advisory, DeadCode, Duplication, OutdatedPackage, ProbeFacts, ProbeId } from "../schemas.js";
|
|
1
|
+
import type { Advisory, Bundle, DeadCode, Duplication, OutdatedPackage, ProbeFacts, ProbeId, UiScan } from "../schemas.js";
|
|
2
|
+
import type { IdiomRule } from "./stack.js";
|
|
3
|
+
import {
|
|
4
|
+
BYPASS_PATTERN,
|
|
5
|
+
COMPONENT_GLOBS,
|
|
6
|
+
IDIOM_RULES,
|
|
7
|
+
MARKUP_GLOBS,
|
|
8
|
+
normalizePath,
|
|
9
|
+
SCAN_IGNORES,
|
|
10
|
+
UI_FRAMEWORKS,
|
|
11
|
+
TAILWIND_PACKAGES,
|
|
12
|
+
} from "./stack.js";
|
|
2
13
|
|
|
3
14
|
/* THE PROBES — the measurements that cost a subprocess, declared once so the daemon that runs them and the panel
|
|
4
15
|
* that explains them cannot disagree about what "outdated" meant.
|
|
@@ -36,6 +47,11 @@ export interface ProbeSpec {
|
|
|
36
47
|
readonly timeoutMs: number;
|
|
37
48
|
// Exit 0 ⇒ this repo can be measured. Runs in the repo's own directory, like the command.
|
|
38
49
|
readonly available: string;
|
|
50
|
+
/* What is MISSING when `available` says no, named here rather than derived from the title. The obvious
|
|
51
|
+
* derivation — "this repository has no security advisories to measure" — states the one thing an unmeasured
|
|
52
|
+
* probe must never claim, that there are none, and it is the same conflation the block above exists to
|
|
53
|
+
* prevent. Phrased as a bare clause ("no lockfile"), because the panel groups these under its own lead-in. */
|
|
54
|
+
readonly unavailable: string;
|
|
39
55
|
// `sh -c`, in the repo's directory. Stdout is the parser's input; a non-zero exit is NOT a failure by itself
|
|
40
56
|
// (pnpm outdated and pnpm audit both exit non-zero precisely when they have something to report), so the
|
|
41
57
|
// runner judges by whether the parser recognised the output.
|
|
@@ -63,7 +79,11 @@ const countOf = (value: unknown): number => (Array.isArray(value) ? value.length
|
|
|
63
79
|
|
|
64
80
|
// Which semver step separates two versions. Compared as leading integers rather than by a semver library: the
|
|
65
81
|
// only question is which position first differs, and prerelease/build metadata cannot change that answer.
|
|
66
|
-
const versionParts = (version: string): number[] =>
|
|
82
|
+
const versionParts = (version: string): number[] =>
|
|
83
|
+
version
|
|
84
|
+
.replace(/^[^\d]*/, ``)
|
|
85
|
+
.split(`.`)
|
|
86
|
+
.map((part) => Number.parseInt(part, 10) || 0);
|
|
67
87
|
|
|
68
88
|
const semverKind = (current: string, latest: string): OutdatedPackage["kind"] => {
|
|
69
89
|
const [currentMajor = 0, currentMinor = 0] = versionParts(current);
|
|
@@ -190,18 +210,162 @@ const parseJscpd = (stdout: string): ProbeFacts | undefined => {
|
|
|
190
210
|
const total = (statistics as Record<string, unknown>)[`total`];
|
|
191
211
|
const percentage = typeof total === `object` && total !== null ? (total as Record<string, unknown>)[`percentage`] : undefined;
|
|
192
212
|
const duplicates = Array.isArray(root?.[`duplicates`]) ? (root[`duplicates`] as Record<string, unknown>[]) : [];
|
|
193
|
-
const pathOf = (side: unknown): string =>
|
|
213
|
+
const pathOf = (side: unknown): string =>
|
|
214
|
+
typeof side === `object` && side !== null ? (asString((side as Record<string, unknown>)[`name`]) ?? `?`) : `?`;
|
|
194
215
|
const duplication: Duplication = {
|
|
195
216
|
percentage: typeof percentage === `number` ? percentage : 0,
|
|
196
217
|
clones: duplicates.length,
|
|
197
218
|
top: duplicates
|
|
198
|
-
.map((clone) => ({
|
|
219
|
+
.map((clone) => ({
|
|
220
|
+
lines: typeof clone[`lines`] === `number` ? clone[`lines`] : 0,
|
|
221
|
+
first: pathOf(clone[`firstFile`]),
|
|
222
|
+
second: pathOf(clone[`secondFile`]),
|
|
223
|
+
}))
|
|
199
224
|
.toSorted((left, right) => right.lines - left.lines)
|
|
200
225
|
.slice(0, DUPLICATION_SAMPLE),
|
|
201
226
|
};
|
|
202
227
|
return { id: `jscpd`, duplication };
|
|
203
228
|
};
|
|
204
229
|
|
|
230
|
+
/* THE UI SWEEP. The only probe here whose command is COMPOSED rather than written out, because its subject is a
|
|
231
|
+
* table (stack.ts) that will grow and a hand-written command would be a second copy of it going stale.
|
|
232
|
+
*
|
|
233
|
+
* Everything it emits is a labelled, tab-separated line, and the first line is always the bare marker `UI`. That
|
|
234
|
+
* marker is the whole reason this parser can tell "the sweep ran and this repository is clean" from "the sweep
|
|
235
|
+
* never ran": every other line is optional, so without it an empty stdout and a spotless codebase are the same
|
|
236
|
+
* string — and reporting the second when it was the first is the one thing probes.ts exists to prevent. */
|
|
237
|
+
const UI_MARKER = `UI`;
|
|
238
|
+
// Caps, applied after `sort` so truncation is alphabetical and therefore identical between runs — an unsorted
|
|
239
|
+
// truncation would mint a new digest on every sweep and badge forever. What is dropped is genuinely dropped: a
|
|
240
|
+
// component past the cap cannot join a family, and the chore says so rather than implying it saw everything.
|
|
241
|
+
const COMPONENT_LIMIT = 2000;
|
|
242
|
+
const RULE_FILE_LIMIT = 500;
|
|
243
|
+
|
|
244
|
+
/* THE `.` IS LOAD-BEARING, and leaving it off cost this probe every finding it will ever have. Given no path,
|
|
245
|
+
* ripgrep searches the tree only when stdin is a TTY — otherwise it reads STDIN, which is exactly how the runner
|
|
246
|
+
* spawns a probe. The sweep therefore ran, exited 0, printed its marker and matched nothing, in every repository,
|
|
247
|
+
* forever: the precise failure the marker line was introduced to make impossible, arriving through the one door it
|
|
248
|
+
* does not cover. It reproduces from Node and not from an interactive shell, which is why it survived being read.
|
|
249
|
+
*
|
|
250
|
+
* The prefix that comes back with it (`./src/Button.vue`) is normalised away at the parse, so one spelling of a
|
|
251
|
+
* path reaches the chores no matter which tool produced it. */
|
|
252
|
+
const SCAN_ROOT = `.`;
|
|
253
|
+
|
|
254
|
+
const globArgs = (globs: readonly string[]): string => [...globs, ...SCAN_IGNORES].map((glob) => `-g '${glob}'`).join(` `);
|
|
255
|
+
|
|
256
|
+
// `path:count` from `rg --count-matches`, normalised. Split at the LAST colon: a path may contain one, a count is
|
|
257
|
+
// always the digits at the end.
|
|
258
|
+
const splitCount = (text: string): { path: string; count: number } | undefined => {
|
|
259
|
+
const at = text.lastIndexOf(`:`);
|
|
260
|
+
if (at <= 0) {
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
const count = Number.parseInt(text.slice(at + 1), 10);
|
|
264
|
+
return Number.isNaN(count) || count <= 0 ? undefined : { path: normalizePath(text.slice(0, at)), count };
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/* An idiom's line is a PATH AND NOTHING ELSE, which is what lets one line shape carry both kinds of rule. A
|
|
268
|
+
* present rule asks ripgrep which files match (`-l`); an absent one asks which files do not (`--files-without-
|
|
269
|
+
* match`), and neither has a count to report. Nothing downstream ever wanted one: a file is on the old idiom or
|
|
270
|
+
* it is not, and how many times it says so within itself is not a fact anyone would act on differently. */
|
|
271
|
+
const idiomCommand = (rule: IdiomRule): string =>
|
|
272
|
+
`rg --no-messages ${rule.absent === undefined ? `-l` : `--files-without-match`} -e '${rule.pattern}' ${globArgs(rule.globs)} ${SCAN_ROOT} 2>/dev/null ` +
|
|
273
|
+
`| sort | head -n ${RULE_FILE_LIMIT} | awk '{print "IDIOM\\t${rule.id}\\t" $0}'`;
|
|
274
|
+
|
|
275
|
+
const scanCommand = (): string =>
|
|
276
|
+
[
|
|
277
|
+
`echo ${UI_MARKER}`,
|
|
278
|
+
`rg --files ${globArgs(COMPONENT_GLOBS)} ${SCAN_ROOT} 2>/dev/null | sort | head -n ${COMPONENT_LIMIT} | awk '{print "COMPONENT\\t" $0}'`,
|
|
279
|
+
`rg --no-messages --count-matches -e '${BYPASS_PATTERN}' ${globArgs(MARKUP_GLOBS)} ${SCAN_ROOT} 2>/dev/null ` +
|
|
280
|
+
`| sort | head -n ${RULE_FILE_LIMIT} | awk '{print "BYPASS\\t" $0}'`,
|
|
281
|
+
...IDIOM_RULES.map(idiomCommand),
|
|
282
|
+
// Every rg above exits 1 when it matches nothing, which is the healthy case and must not read as a broken
|
|
283
|
+
// command. The runner judges by the parse, but leaving the script's own status at 1 would be a lie.
|
|
284
|
+
`true`,
|
|
285
|
+
].join(`; `);
|
|
286
|
+
|
|
287
|
+
const parseUi = (stdout: string): ProbeFacts | undefined => {
|
|
288
|
+
const lines = stdout.split(`\n`).map((line) => line.trim());
|
|
289
|
+
if (lines.find((line) => line !== ``) !== UI_MARKER) {
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
const components: string[] = [];
|
|
293
|
+
const bypasses: UiScan["bypasses"] = [];
|
|
294
|
+
const byIdiom = new Map<string, string[]>();
|
|
295
|
+
for (const line of lines) {
|
|
296
|
+
const [label, ...rest] = line.split(`\t`);
|
|
297
|
+
if (label === `COMPONENT` && rest[0] !== undefined) {
|
|
298
|
+
components.push(normalizePath(rest[0]));
|
|
299
|
+
} else if (label === `BYPASS`) {
|
|
300
|
+
const hit = splitCount(rest.join(`\t`));
|
|
301
|
+
if (hit !== undefined) {
|
|
302
|
+
bypasses.push(hit);
|
|
303
|
+
}
|
|
304
|
+
} else if (label === `IDIOM` && rest[0] !== undefined) {
|
|
305
|
+
const path = normalizePath(rest.slice(1).join(`\t`));
|
|
306
|
+
if (path !== ``) {
|
|
307
|
+
byIdiom.set(rest[0], [...(byIdiom.get(rest[0]) ?? []), path]);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const scan: UiScan = { components, bypasses, idioms: [...byIdiom].map(([id, files]) => ({ id, files })) };
|
|
312
|
+
return { id: `ui`, scan };
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/* THE BUILD OUTPUT, measured where it already is. See BundleSchema for why this never runs the build; the
|
|
316
|
+
* consequence here is that `available` is a question about the filesystem rather than about the toolchain, and a
|
|
317
|
+
* repository whose only builds happen in CI reports `unavailable` rather than a wrong number. */
|
|
318
|
+
const BUILD_DIRS = [`dist`, `build`, `out`, `public/build`];
|
|
319
|
+
const BUNDLE_MARKER = `DIR`;
|
|
320
|
+
// Enough of the ranking to see the shape of a build. Past this the assets are the long tail of lazy chunks, and
|
|
321
|
+
// carrying four hundred of them on a route the rail badge polls would cost more than the finding is worth.
|
|
322
|
+
const ASSET_LIMIT = 40;
|
|
323
|
+
|
|
324
|
+
const bundleCommand = (): string =>
|
|
325
|
+
[
|
|
326
|
+
`dir=""`,
|
|
327
|
+
`for d in ${BUILD_DIRS.join(` `)}; do if [ -d "$d" ]; then dir="$d"; break; fi; done`,
|
|
328
|
+
`[ -n "$dir" ] || exit 0`,
|
|
329
|
+
`printf '${BUNDLE_MARKER}\\t%s\\n' "$dir"`,
|
|
330
|
+
// `-exec ... {} +` rather than a `for` over command substitution: a hashed asset name will not contain a
|
|
331
|
+
// space, but a build that copies user content into the output can, and a probe is not the place to find
|
|
332
|
+
// out. Sorted by raw bytes so the head is the ranking rather than whatever order the walk returned.
|
|
333
|
+
`find "$dir" -type f \\( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' -o -name '*.css' \\) ` +
|
|
334
|
+
`-exec sh -c 'for f; do printf "ASSET\\t%s\\t%s\\t%s\\n" "$(wc -c <"$f")" "$(gzip -c "$f" | wc -c)" "$f"; done' _ {} + ` +
|
|
335
|
+
`2>/dev/null | sort -k2 -rn | head -n ${ASSET_LIMIT}`,
|
|
336
|
+
].join(`; `);
|
|
337
|
+
|
|
338
|
+
const parseBundle = (stdout: string): ProbeFacts | undefined => {
|
|
339
|
+
const lines = stdout.split(`\n`).map((line) => line.trim());
|
|
340
|
+
const dirLine = lines.find((line) => line.startsWith(`${BUNDLE_MARKER}\t`));
|
|
341
|
+
if (dirLine === undefined) {
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
const assets: Bundle["assets"] = [];
|
|
345
|
+
for (const line of lines) {
|
|
346
|
+
const [label, rawBytes, rawGzip, ...path] = line.split(`\t`);
|
|
347
|
+
if (label !== `ASSET` || path.length === 0) {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const bytes = Number.parseInt(rawBytes ?? ``, 10);
|
|
351
|
+
const gzip = Number.parseInt(rawGzip ?? ``, 10);
|
|
352
|
+
if (Number.isNaN(bytes) || Number.isNaN(gzip)) {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
assets.push({ path: path.join(`\t`), bytes, gzip });
|
|
356
|
+
}
|
|
357
|
+
const bundle: Bundle = {
|
|
358
|
+
dir: dirLine.slice(BUNDLE_MARKER.length + 1),
|
|
359
|
+
// Of the assets CARRIED, which is the top of the ranking rather than the whole build. The chore says so
|
|
360
|
+
// when it quotes the number: a total that silently excluded the tail would be the more misleading of the
|
|
361
|
+
// two, and re-walking the tree to sum it would double the probe's cost for a figure nobody splits on.
|
|
362
|
+
totalBytes: assets.reduce((sum, asset) => sum + asset.bytes, 0),
|
|
363
|
+
totalGzip: assets.reduce((sum, asset) => sum + asset.gzip, 0),
|
|
364
|
+
assets,
|
|
365
|
+
};
|
|
366
|
+
return { id: `bundle`, bundle };
|
|
367
|
+
};
|
|
368
|
+
|
|
205
369
|
// Where the tier-2 tools leave their reports. Under /tmp because they are inputs to a parse that happens
|
|
206
370
|
// immediately after, never something to keep — the cached ProbeResult is the artefact that survives. The same
|
|
207
371
|
// path the scheduled form of this chore uses (chores.ts), so a workspace running both keeps one copy.
|
|
@@ -216,6 +380,7 @@ export const PROBES: readonly ProbeSpec[] = [
|
|
|
216
380
|
ttlMs: DAY_MS,
|
|
217
381
|
timeoutMs: 5 * 60_000,
|
|
218
382
|
available: `test -f package.json`,
|
|
383
|
+
unavailable: `no package.json`,
|
|
219
384
|
// `-r` so a monorepo reports every workspace package, not just the root's own handful. `|| true` because
|
|
220
385
|
// pnpm exits non-zero exactly when it HAS findings, and the runner judges by whether the parse succeeded.
|
|
221
386
|
command: `pnpm outdated -r --json 2>/dev/null || true`,
|
|
@@ -231,6 +396,7 @@ export const PROBES: readonly ProbeSpec[] = [
|
|
|
231
396
|
// A lockfile, not a package.json: auditing resolves the actual installed tree, and without one pnpm
|
|
232
397
|
// reports against nothing.
|
|
233
398
|
available: `test -f pnpm-lock.yaml || test -f package-lock.json`,
|
|
399
|
+
unavailable: `no lockfile`,
|
|
234
400
|
command: `pnpm audit --json 2>/dev/null || true`,
|
|
235
401
|
parse: parseAudit,
|
|
236
402
|
},
|
|
@@ -244,6 +410,7 @@ export const PROBES: readonly ProbeSpec[] = [
|
|
|
244
410
|
// The repo's OWN knip, never a floating one: `pnpm dlx knip` would download a version that disagrees with
|
|
245
411
|
// the repo's knip.json about what counts as an entry point, and then report its whole public API as dead.
|
|
246
412
|
available: `pnpm exec knip --version >/dev/null 2>&1`,
|
|
413
|
+
unavailable: `knip is not a devDependency`,
|
|
247
414
|
command: `pnpm exec knip --reporter json --no-exit-code 2>/dev/null || true`,
|
|
248
415
|
parse: parseKnip,
|
|
249
416
|
},
|
|
@@ -255,6 +422,7 @@ export const PROBES: readonly ProbeSpec[] = [
|
|
|
255
422
|
ttlMs: 7 * DAY_MS,
|
|
256
423
|
timeoutMs: 20 * 60_000,
|
|
257
424
|
available: `test -f package.json`,
|
|
425
|
+
unavailable: `no package.json`,
|
|
258
426
|
// `--threshold 100` so jscpd never fails the command on its own opinion of what is too much duplication —
|
|
259
427
|
// that judgement is the chore's, made from the percentage, not the tool's exit code.
|
|
260
428
|
command:
|
|
@@ -262,6 +430,43 @@ export const PROBES: readonly ProbeSpec[] = [
|
|
|
262
430
|
`cat ${JSCPD_DIR}/jscpd-report.json 2>/dev/null`,
|
|
263
431
|
parse: parseJscpd,
|
|
264
432
|
},
|
|
433
|
+
{
|
|
434
|
+
id: `ui`,
|
|
435
|
+
title: `Front-end source`,
|
|
436
|
+
measures: `components, hard-coded styles and idioms the framework has replaced`,
|
|
437
|
+
/* Tier 1 despite reading the whole tree, and the placement is a judgement rather than an oversight. The
|
|
438
|
+
* tier is about COST: this is a dozen ripgrep walks, seconds on a large monorepo, against knip
|
|
439
|
+
* type-checking the tree and jscpd tokenizing every file for minutes. A weekly TTL would also make it the
|
|
440
|
+
* wrong shape — its findings move whenever someone writes a component, which is daily. */
|
|
441
|
+
tier: 1,
|
|
442
|
+
ttlMs: DAY_MS,
|
|
443
|
+
timeoutMs: 5 * 60_000,
|
|
444
|
+
// Any manifest in the repo declaring a UI framework or Tailwind, not just the root's — a monorepo keeps
|
|
445
|
+
// React in the app package and the root manifest is a handful of build tools.
|
|
446
|
+
available:
|
|
447
|
+
`rg -l --no-messages -g '**/package.json' -g '!**/node_modules/**' ` +
|
|
448
|
+
`-e '[\\x22](${[...UI_FRAMEWORKS.flatMap((framework) => framework.packages), ...TAILWIND_PACKAGES].join(`|`)})[\\x22]\\s*:' . >/dev/null`,
|
|
449
|
+
unavailable: `no package here declares a UI framework or Tailwind`,
|
|
450
|
+
command: scanCommand(),
|
|
451
|
+
parse: parseUi,
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
id: `bundle`,
|
|
455
|
+
title: `Build output`,
|
|
456
|
+
measures: `what the last build put on disk for a browser to download`,
|
|
457
|
+
tier: 1,
|
|
458
|
+
ttlMs: DAY_MS,
|
|
459
|
+
timeoutMs: 5 * 60_000,
|
|
460
|
+
// A build directory that actually contains something a browser would download. `-d` alone would pass on
|
|
461
|
+
// the empty `dist/` a cleaned checkout leaves behind, and the measurement would report a zero-byte bundle
|
|
462
|
+
// as a fact about the application.
|
|
463
|
+
available: `find ${BUILD_DIRS.join(` `)} -maxdepth 4 -type f \\( -name '*.js' -o -name '*.mjs' -o -name '*.css' \\) 2>/dev/null | head -n 1 | grep -q .`,
|
|
464
|
+
// Says what is missing AND that this never builds, because the obvious reading of "no build output" is
|
|
465
|
+
// that we tried and it failed. The owner running their own build once is the whole fix.
|
|
466
|
+
unavailable: `no build output on disk — this reads the last build, it never runs one`,
|
|
467
|
+
command: bundleCommand(),
|
|
468
|
+
parse: parseBundle,
|
|
469
|
+
},
|
|
265
470
|
];
|
|
266
471
|
|
|
267
472
|
export const probeSpec = (id: ProbeId): ProbeSpec => {
|