@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/chores.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Advisory, ChoreSignals, OutdatedPackage, ProbeId, ProbeResult } from "../schemas.js";
|
|
2
2
|
import { bucketOf, digestOf } from "./digest.js";
|
|
3
3
|
import { CHORE_INVARIANTS, composeAsk, REPORT_INVARIANTS, TRIAGE_NOTE } from "./prompt.js";
|
|
4
|
+
import { componentStem, frameworksOf, idiomRule, normalizePath, UI_FRAMEWORKS, usesTailwind } from "./stack.js";
|
|
4
5
|
|
|
5
6
|
/* THE CHORE BOOK — what routine maintenance a repository is owed, and what has to be TRUE before we say so.
|
|
6
7
|
*
|
|
@@ -36,6 +37,11 @@ import { CHORE_INVARIANTS, composeAsk, REPORT_INVARIANTS, TRIAGE_NOTE } from "./
|
|
|
36
37
|
|
|
37
38
|
export type ChoreStance = "act" | "report";
|
|
38
39
|
|
|
40
|
+
/* WHAT KIND OF CLAIM A CHORE MAKES ON SOMEONE'S ATTENTION. Four of them, ordered from "this is a risk you are
|
|
41
|
+
* carrying right now" to "this is worth thinking about this quarter" — see CHORE_KINDS at the foot of this file,
|
|
42
|
+
* which carries the argument and the words the panel groups under. */
|
|
43
|
+
export type ChoreKind = "carrying" | "accruing" | "drifting" | "surveying";
|
|
44
|
+
|
|
39
45
|
export interface ChoreContext {
|
|
40
46
|
// Root-relative repo dir; the empty string is the workspace's own root repo.
|
|
41
47
|
readonly repo: string;
|
|
@@ -70,6 +76,11 @@ export interface Chore {
|
|
|
70
76
|
readonly icon: string;
|
|
71
77
|
// The one-line standing description, shown whether or not the chore is currently due.
|
|
72
78
|
readonly description: string;
|
|
79
|
+
/* WHICH OF THE FOUR KINDS OF CLAIM THIS IS (CHORE_KINDS, at the foot of this file). It decides the book's
|
|
80
|
+
* order and the panel's grouping, and it is a FIELD rather than a comment above the array for exactly that
|
|
81
|
+
* reason: the reading order is the one editorial claim this surface makes, and a claim spelled as a comment
|
|
82
|
+
* beside a hand-maintained list is one nobody can check and the compiler cannot keep. */
|
|
83
|
+
readonly kind: ChoreKind;
|
|
73
84
|
/* THE RULE, in words — what has to be true for this chore to be due, stated so a reader can check it against
|
|
74
85
|
* the evidence below it and disagree.
|
|
75
86
|
*
|
|
@@ -82,14 +93,21 @@ export interface Chore {
|
|
|
82
93
|
* cannot check English. The rule for writing one: say the THRESHOLD, not the subject. "Duplication is high"
|
|
83
94
|
* is a topic; "more than 5% of the tree is duplicated" is a criterion. */
|
|
84
95
|
readonly criterion: string;
|
|
85
|
-
/* WHETHER THIS IS A QUESTION WORTH ASKING OF THIS REPOSITORY AT ALL — returns undefined when it is, and
|
|
86
|
-
*
|
|
96
|
+
/* WHETHER THIS IS A QUESTION WORTH ASKING OF THIS REPOSITORY AT ALL — returns undefined when it is, and what
|
|
97
|
+
* is MISSING when it is not.
|
|
87
98
|
*
|
|
88
99
|
* Distinct from `assess`, and the distinction is the whole point: `assess` asks whether the answer is yes,
|
|
89
100
|
* this asks whether the question makes sense. "Re-read the documentation against the code" in a repository
|
|
90
101
|
* with no documentation is not a chore that is currently clear — it is one that will never apply here, and
|
|
91
102
|
* showing it as clear says we checked something we cannot check. A chore that does not apply is dropped from
|
|
92
|
-
* the panel entirely;
|
|
103
|
+
* the panel entirely; a line in the scope strip records that it was considered.
|
|
104
|
+
*
|
|
105
|
+
* A BARE CAUSE — "no Dockerfile", never "this repository ships no Dockerfile, so there is no image to slim".
|
|
106
|
+
* Same spelling as `ProbeSpec.unavailable`, and for the same reason both surfaces need: one absent
|
|
107
|
+
* package.json rules out five chores, and five sentences saying so at length is the wall of text this phrasing
|
|
108
|
+
* exists to prevent. The panel groups by this string, so the CONSEQUENCE — which chores it costs — is the list
|
|
109
|
+
* beside it rather than a clause repeated inside every entry. Identical causes must be spelled identically or
|
|
110
|
+
* they group apart.
|
|
93
111
|
*
|
|
94
112
|
* Reads `signals` rather than probes on purpose: applicability is about what the repository IS, which is a
|
|
95
113
|
* fact the daemon holds without measuring anything. If a gate needed a probe it would be describing the
|
|
@@ -152,6 +170,11 @@ const JSCPD_REPORT = `${JSCPD_DIR}/jscpd-report.json`;
|
|
|
152
170
|
// called "root" — so it is spelled out here, once, rather than at every call site that builds a prompt.
|
|
153
171
|
export const repoLabel = (repo: string): string => (repo === `root` || repo === `` ? `the workspace root repository` : repo);
|
|
154
172
|
|
|
173
|
+
// The same repository, named for a surface that has a 16rem column or a chip to say it in. `repoLabel` is prose
|
|
174
|
+
// and reads as prose inside a sentence ("update dependencies in the workspace root repository"); a rail row wants
|
|
175
|
+
// the name on its own, and "the workspace root repository" truncates to "the workspace root reposi…" there.
|
|
176
|
+
export const repoName = (repo: string): string => (repo === `root` || repo === `` ? `workspace root` : repo);
|
|
177
|
+
|
|
155
178
|
const plural = (count: number, one: string, many = `${one}s`): string => `${count} ${count === 1 ? one : many}`;
|
|
156
179
|
|
|
157
180
|
// One outdated dependency, as the panel lists it. The semver step leads, because it is what decides whether the
|
|
@@ -182,8 +205,9 @@ const security: Chore = {
|
|
|
182
205
|
title: `Patch security advisories`,
|
|
183
206
|
icon: `shield`,
|
|
184
207
|
description: `Published advisories against this dependency tree, and the ones whose fix is a version bump.`,
|
|
208
|
+
kind: `carrying`,
|
|
185
209
|
criterion: `pnpm audit reports an advisory of high or critical severity against the resolved tree.`,
|
|
186
|
-
applies: (signals) => (signals.shape.lockfile ? undefined : `
|
|
210
|
+
applies: (signals) => (signals.shape.lockfile ? undefined : `no lockfile`),
|
|
187
211
|
stance: `act`,
|
|
188
212
|
needs: [`audit`],
|
|
189
213
|
cadenceMs: 0,
|
|
@@ -211,7 +235,10 @@ const security: Chore = {
|
|
|
211
235
|
headline: `${plural(blocking.length, `advisory`, `advisories`)}, ${patchable.length} with a published fix`,
|
|
212
236
|
detail: blocking
|
|
213
237
|
.toSorted((left, right) => left.name.localeCompare(right.name))
|
|
214
|
-
.map(
|
|
238
|
+
.map(
|
|
239
|
+
(advisory) =>
|
|
240
|
+
`${advisory.severity} · ${advisory.name} — ${advisory.title}${advisory.patched === undefined ? ` (no patch yet)` : ``}`,
|
|
241
|
+
),
|
|
215
242
|
// Identities, not counts: every advisory that appears or is fixed is genuinely news, and there is no
|
|
216
243
|
// ordinary drift here to absorb.
|
|
217
244
|
digest: digestOf(...blocking.map((advisory) => `${advisory.name}@${advisory.severity}`).toSorted()),
|
|
@@ -245,8 +272,9 @@ const dependencies: Chore = {
|
|
|
245
272
|
title: `Update dependencies`,
|
|
246
273
|
icon: `arrow-circle-up`,
|
|
247
274
|
description: `How far behind the registry this tree has drifted, and which majors are waiting.`,
|
|
275
|
+
kind: `accruing`,
|
|
248
276
|
criterion: `A dependency is a major version behind, or more than 20 are behind by any amount.`,
|
|
249
|
-
applies: (signals) => (signals.shape.packageManifest ? undefined : `
|
|
277
|
+
applies: (signals) => (signals.shape.packageManifest ? undefined : `no package.json`),
|
|
250
278
|
stance: `act`,
|
|
251
279
|
needs: [`outdated`],
|
|
252
280
|
cadenceMs: 30 * DAY_MS,
|
|
@@ -294,8 +322,9 @@ const deadCode: Chore = {
|
|
|
294
322
|
title: `Clear out dead code`,
|
|
295
323
|
icon: `trash`,
|
|
296
324
|
description: `Files, exports and dependencies nothing in this repository references any more.`,
|
|
325
|
+
kind: `accruing`,
|
|
297
326
|
criterion: `knip reports at least one unreferenced file, export or dependency.`,
|
|
298
|
-
applies: (signals) => (signals.shape.packageManifest ? undefined : `
|
|
327
|
+
applies: (signals) => (signals.shape.packageManifest ? undefined : `no package.json`),
|
|
299
328
|
stance: `act`,
|
|
300
329
|
needs: [`knip`],
|
|
301
330
|
cadenceMs: 14 * DAY_MS,
|
|
@@ -358,6 +387,7 @@ const duplication: Chore = {
|
|
|
358
387
|
title: `Find duplication worth collapsing`,
|
|
359
388
|
icon: `clone`,
|
|
360
389
|
description: `Copy-paste that has grown past a fifth of a percent of the tree. Reports only — extracting is a design call.`,
|
|
390
|
+
kind: `drifting`,
|
|
361
391
|
criterion: `jscpd reports more than 5% of the scanned tree duplicated.`,
|
|
362
392
|
stance: `report`,
|
|
363
393
|
needs: [`jscpd`],
|
|
@@ -400,7 +430,9 @@ const duplication: Chore = {
|
|
|
400
430
|
done: `Done when every clone in the report has either a named extraction or a one-line reason it should stay.`,
|
|
401
431
|
};
|
|
402
432
|
|
|
403
|
-
/* DOCUMENTATION. The evidence is a package with no
|
|
433
|
+
/* DOCUMENTATION. The evidence is a package with no README — which IS its architecture document in this
|
|
434
|
+
* workspace, so this is a stat on the package directory rather than a lookup in a parallel tree. It sounds like
|
|
435
|
+
* a coverage statistic
|
|
404
436
|
* and would be one if the rail read it directly. It does not: the digest is the SET of undocumented package
|
|
405
437
|
* directories, so a long-standing backlog goes quiet after it is seen once, and a package appearing that nothing
|
|
406
438
|
* explains is an event that speaks. That is the whole difference between this being useful and being a nag. */
|
|
@@ -408,9 +440,10 @@ const documentation: Chore = {
|
|
|
408
440
|
id: `documentation-refresh`,
|
|
409
441
|
title: `Document what nothing explains`,
|
|
410
442
|
icon: `file-edit`,
|
|
411
|
-
description: `Packages in this repository with no
|
|
412
|
-
|
|
413
|
-
|
|
443
|
+
description: `Packages in this repository with no README — new ones first.`,
|
|
444
|
+
kind: `drifting`,
|
|
445
|
+
criterion: `A workspace package has no README.`,
|
|
446
|
+
applies: (signals) => (signals.packages.length > 0 ? undefined : `not a workspace`),
|
|
414
447
|
stance: `act`,
|
|
415
448
|
needs: [],
|
|
416
449
|
cadenceMs: 90 * DAY_MS,
|
|
@@ -426,7 +459,7 @@ const documentation: Chore = {
|
|
|
426
459
|
severity: `info`,
|
|
427
460
|
why:
|
|
428
461
|
`${plural(undocumented.length, `package`)} of ${context.signals.packages.length} in ${repoLabel(context.repo)} have no ` +
|
|
429
|
-
`
|
|
462
|
+
`README: ${undocumented.map((entry) => entry.dir).join(`, `)}.`,
|
|
430
463
|
};
|
|
431
464
|
},
|
|
432
465
|
diagnosis: `A package nobody can read the shape of gets worked in by guesswork, and the guesses accumulate.`,
|
|
@@ -464,6 +497,7 @@ const complexity: Chore = {
|
|
|
464
497
|
title: `Simplify what everything waits on`,
|
|
465
498
|
icon: `wave-pulse`,
|
|
466
499
|
description: `Files that both churn and carry the repository — where edits are slow and ripple outward.`,
|
|
500
|
+
kind: `accruing`,
|
|
467
501
|
criterion: `A file in the hotspot ranking is also a key module, or its branching is three times the median of that ranking.`,
|
|
468
502
|
stance: `act`,
|
|
469
503
|
needs: [],
|
|
@@ -524,8 +558,9 @@ const runtime: Chore = {
|
|
|
524
558
|
title: `Move off an end-of-life runtime`,
|
|
525
559
|
icon: `bolt`,
|
|
526
560
|
description: `Whether the Node this sandbox runs still receives security patches.`,
|
|
561
|
+
kind: `carrying`,
|
|
527
562
|
criterion: `The Node release this sandbox runs is past its end-of-life date, or within 90 days of it.`,
|
|
528
|
-
applies: (signals) => (signals.shape.packageManifest ? undefined : `
|
|
563
|
+
applies: (signals) => (signals.shape.packageManifest ? undefined : `no package.json`),
|
|
529
564
|
stance: `act`,
|
|
530
565
|
needs: [],
|
|
531
566
|
cadenceMs: 0,
|
|
@@ -544,7 +579,9 @@ const runtime: Chore = {
|
|
|
544
579
|
// Which packages would have to be argued with, so the finding names the work rather than only the fact.
|
|
545
580
|
const pinned = context.signals.packages.filter((entry) => entry.engines?.[`node`] !== undefined);
|
|
546
581
|
return {
|
|
547
|
-
headline: past
|
|
582
|
+
headline: past
|
|
583
|
+
? `Node ${major} stopped receiving security patches ${days} days ago`
|
|
584
|
+
: `Node ${major} reaches end of life in ${days} days`,
|
|
548
585
|
detail: [
|
|
549
586
|
`running · ${context.node}`,
|
|
550
587
|
`end of life · ${eol}`,
|
|
@@ -586,8 +623,9 @@ const libraries: Chore = {
|
|
|
586
623
|
title: `Settle on one library per job`,
|
|
587
624
|
icon: `box`,
|
|
588
625
|
description: `Two dependencies solving the same problem — both shipped, both maintained, one picked at random.`,
|
|
626
|
+
kind: `drifting`,
|
|
589
627
|
criterion: `Two or more installed dependencies do the same job.`,
|
|
590
|
-
applies: (signals) => (signals.packages.length > 0 ? undefined : `
|
|
628
|
+
applies: (signals) => (signals.packages.length > 0 ? undefined : `not a workspace`),
|
|
591
629
|
stance: `report`,
|
|
592
630
|
needs: [],
|
|
593
631
|
cadenceMs: 90 * DAY_MS,
|
|
@@ -618,6 +656,351 @@ const libraries: Chore = {
|
|
|
618
656
|
done: `Done when every overlapping pair has a recommendation with a call-site count behind it, or a reason the overlap is fine.`,
|
|
619
657
|
};
|
|
620
658
|
|
|
659
|
+
/* ---- THE FRONT-END CHORES -------------------------------------------------------------------------------------
|
|
660
|
+
*
|
|
661
|
+
* Four chores that only exist where a UI framework does, kept together because they share one gate and one
|
|
662
|
+
* probe — and split across the reading order in CHORES, since where a row belongs is decided by what KIND of
|
|
663
|
+
* finding it is, not by which file paragraph it was written in.
|
|
664
|
+
*
|
|
665
|
+
* They gate on `shape.deps` rather than on `signals.packages`, and that is not interchangeable. `packages` is
|
|
666
|
+
* populated from pnpm-workspace.yaml, so it is EMPTY for a repository that is not a monorepo — which is what a
|
|
667
|
+
* Vite app, a Next app and an Angular CLI project all are. A framework gate reading it would be permanently dark
|
|
668
|
+
* in the overwhelming majority of the repositories these four were written for, and dark silently: the chores
|
|
669
|
+
* would not appear, the footer would say the repository has no packages, and nothing would look broken.
|
|
670
|
+
*
|
|
671
|
+
* All four also say something the rest of the book does not have to. A component, a class name and a bundle chunk
|
|
672
|
+
* are things nobody sees the whole of — you read one component at a time, and the tenth copy of a button looks
|
|
673
|
+
* exactly like the first nine did. That is the same argument the whole surface rests on, just further from the
|
|
674
|
+
* places a compiler will ever help. */
|
|
675
|
+
|
|
676
|
+
// How many rows of evidence a UI finding lists before it is a wall rather than a list. The standing count still
|
|
677
|
+
// leads the headline; this only bounds what is enumerated underneath it.
|
|
678
|
+
const DETAIL_LIMIT = 8;
|
|
679
|
+
|
|
680
|
+
const FRAMEWORK_LABELS = UI_FRAMEWORKS.map((framework) => framework.label).join(`, `);
|
|
681
|
+
|
|
682
|
+
// One gate, one cause, four chores. Built from the table so that a framework added to stack.ts cannot leave a
|
|
683
|
+
// stale list of names behind in a reason nobody re-reads.
|
|
684
|
+
const needsFramework = (signals: ChoreSignals): string | undefined =>
|
|
685
|
+
frameworksOf(signals.shape.deps).length > 0 ? undefined : `no ${FRAMEWORK_LABELS}`;
|
|
686
|
+
|
|
687
|
+
const bytesLabel = (bytes: number): string => (bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.round(bytes / 1024)} kB`);
|
|
688
|
+
|
|
689
|
+
/* BUNDLE. What a browser downloads before anything appears, which is the fact about a front-end that is furthest
|
|
690
|
+
* from anything visible in an editor: every dependency looks the same size in an import statement.
|
|
691
|
+
*
|
|
692
|
+
* The criterion is a SHARE, and that is deliberate — it is the second exception to the book's leader-relative
|
|
693
|
+
* rule, and it earns the same defence duplication's 5% does. A byte threshold would need a different value for a
|
|
694
|
+
* marketing page and an IDE, would be argued about forever, and would be wrong the moment either one grew. "One
|
|
695
|
+
* chunk is more than half of everything you ship" needs no calibration: it says the build is not split, which is
|
|
696
|
+
* true or false at any size. A well-split app has its largest chunk well under this whatever it weighs, and a
|
|
697
|
+
* small app that genuinely is one chunk trips it and is right to — that IS its entire download.
|
|
698
|
+
*
|
|
699
|
+
* Report-stance. Where the split boundaries go is a routing and product decision, and an agent that lazily
|
|
700
|
+
* imported things unattended at three in the morning would be making it. */
|
|
701
|
+
const BUNDLE_SHARE_FLOOR = 50;
|
|
702
|
+
// Below this there is no ranking to be an outlier in — two files cannot tell you anything about how a build is
|
|
703
|
+
// divided, and the largest of them is over half by arithmetic rather than by fault.
|
|
704
|
+
const BUNDLE_MIN_ASSETS = 3;
|
|
705
|
+
|
|
706
|
+
/* An asset's name with its content hash taken out — `assets/vendor-DlAUqK2U.js` becomes `assets/vendor.js`.
|
|
707
|
+
*
|
|
708
|
+
* Without this the digest changes on every single build, because a content hash changing is the entire point of a
|
|
709
|
+
* content hash. The chore would badge after every `pnpm build` while reporting nothing new, which is precisely
|
|
710
|
+
* the lit-every-day failure the digest exists to prevent.
|
|
711
|
+
*
|
|
712
|
+
* Eight or more characters containing a digit, immediately before the final extension: long enough to leave
|
|
713
|
+
* `vendor-react.js` and `.min.js` alone, specific enough to catch Vite's `-DlAUqK2U` and webpack's `.9f2a1b0c`. */
|
|
714
|
+
const stableAsset = (path: string): string => path.replace(/[.-](?=[A-Za-z0-9_-]*[0-9])[A-Za-z0-9_-]{8,}(\.[a-z0-9]+)$/, `$1`);
|
|
715
|
+
|
|
716
|
+
const bundleWeight: Chore = {
|
|
717
|
+
id: `bundle-weight`,
|
|
718
|
+
title: `Split what the browser downloads first`,
|
|
719
|
+
icon: `download`,
|
|
720
|
+
description: `What the last build put on disk, and whether it arrives as one download or several.`,
|
|
721
|
+
kind: `accruing`,
|
|
722
|
+
criterion: `A single asset is more than half of the build's total transfer size.`,
|
|
723
|
+
applies: needsFramework,
|
|
724
|
+
stance: `report`,
|
|
725
|
+
needs: [`bundle`],
|
|
726
|
+
cadenceMs: 30 * DAY_MS,
|
|
727
|
+
assess: (context) => {
|
|
728
|
+
const facts = factsOf(context, `bundle`);
|
|
729
|
+
if (facts === undefined) {
|
|
730
|
+
return undefined;
|
|
731
|
+
}
|
|
732
|
+
const { assets, totalGzip, dir } = facts.bundle;
|
|
733
|
+
if (assets.length < BUNDLE_MIN_ASSETS || totalGzip === 0) {
|
|
734
|
+
return undefined;
|
|
735
|
+
}
|
|
736
|
+
// By GZIP, not by raw bytes. What is on disk is not what crosses the wire, and a large but highly
|
|
737
|
+
// compressible asset — a source map comment, a big JSON blob — is not the download this is about.
|
|
738
|
+
const ranked = assets.toSorted((left, right) => right.gzip - left.gzip);
|
|
739
|
+
const largest = ranked[0];
|
|
740
|
+
if (largest === undefined) {
|
|
741
|
+
return undefined;
|
|
742
|
+
}
|
|
743
|
+
const share = (largest.gzip / totalGzip) * 100;
|
|
744
|
+
if (share < BUNDLE_SHARE_FLOOR) {
|
|
745
|
+
return undefined;
|
|
746
|
+
}
|
|
747
|
+
return {
|
|
748
|
+
headline: `${largest.path} is ${Math.round(share)}% of the ${bytesLabel(totalGzip)} this build ships`,
|
|
749
|
+
detail: ranked
|
|
750
|
+
.slice(0, DETAIL_LIMIT)
|
|
751
|
+
.map((asset) => `${bytesLabel(asset.gzip)} gzipped · ${asset.path} (${bytesLabel(asset.bytes)} on disk)`),
|
|
752
|
+
// The bucketed total and the hash-stripped identities of the biggest chunks. A rebuild of the same
|
|
753
|
+
// code is silent; a new heavy chunk appearing, or the whole thing doubling, is not.
|
|
754
|
+
digest: digestOf(
|
|
755
|
+
`total:${bucketOf(totalGzip)}`,
|
|
756
|
+
...ranked
|
|
757
|
+
.slice(0, 5)
|
|
758
|
+
.map((asset) => stableAsset(asset.path))
|
|
759
|
+
.toSorted(),
|
|
760
|
+
),
|
|
761
|
+
// Not a risk being carried, however large. `warning` is reserved for something with a clock on it.
|
|
762
|
+
severity: `info`,
|
|
763
|
+
why:
|
|
764
|
+
`The build output in ${dir}/ of ${repoLabel(context.repo)} is ${bytesLabel(totalGzip)} gzipped across ` +
|
|
765
|
+
`${plural(assets.length, `asset`)}, and ${largest.path} alone is ${bytesLabel(largest.gzip)} of it — ${Math.round(share)}%. ` +
|
|
766
|
+
`The next largest are ${ranked
|
|
767
|
+
.slice(1, 4)
|
|
768
|
+
.map((asset) => `${asset.path} (${bytesLabel(asset.gzip)})`)
|
|
769
|
+
.join(`, `)}. ` +
|
|
770
|
+
`This is the last build someone ran, read off disk; nothing rebuilt it to measure.`,
|
|
771
|
+
};
|
|
772
|
+
},
|
|
773
|
+
diagnosis: `Everything in the first chunk is downloaded and parsed before anything renders, whether or not the visitor needed it.`,
|
|
774
|
+
goal:
|
|
775
|
+
`Find out what is actually IN the dominant chunk before proposing anything — the repository's own bundler can report this, and a ` +
|
|
776
|
+
`recommendation made without it is guesswork. Then report the split worth making: which routes or features could load on demand, ` +
|
|
777
|
+
`which dependencies are pulled in wholesale for one function, and which are only used behind an interaction nobody has yet had. ` +
|
|
778
|
+
`Name the boundary for each and estimate what it saves. Where the chunk is genuinely all first-paint code, say so and close it.`,
|
|
779
|
+
done: `Done when every recommendation names a specific import boundary and the bytes it would move out of the first download.`,
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
/* FRAMEWORK IDIOMS. A migration nobody finished, which is the most ordinary state for a front-end of any age: the
|
|
783
|
+
* new way arrived, the new files use it, and the old files keep working — so nothing ever forces the rest.
|
|
784
|
+
*
|
|
785
|
+
* The digest is the one place this chore differs in shape from its neighbours, and it has to. Digesting the file
|
|
786
|
+
* identities, the way the documentation chore does, would re-badge every time anyone touched any of two hundred
|
|
787
|
+
* files, because a migration in progress is a set that changes constantly. So it digests the BUCKETED COUNT per
|
|
788
|
+
* idiom instead: a kind of legacy code appearing where there was none speaks, real progress through a bucket
|
|
789
|
+
* speaks, and one more file drifting in or out of a set of two hundred does not. */
|
|
790
|
+
const frameworkIdiom: Chore = {
|
|
791
|
+
id: `framework-idiom`,
|
|
792
|
+
title: `Finish the framework migrations`,
|
|
793
|
+
icon: `history`,
|
|
794
|
+
description: `Code still written the way the framework used to recommend, long after it stopped.`,
|
|
795
|
+
kind: `accruing`,
|
|
796
|
+
criterion: `A file uses a framework idiom that framework's own maintainers have replaced.`,
|
|
797
|
+
applies: needsFramework,
|
|
798
|
+
stance: `act`,
|
|
799
|
+
needs: [`ui`],
|
|
800
|
+
cadenceMs: 60 * DAY_MS,
|
|
801
|
+
assess: (context) => {
|
|
802
|
+
const facts = factsOf(context, `ui`);
|
|
803
|
+
if (facts === undefined) {
|
|
804
|
+
return undefined;
|
|
805
|
+
}
|
|
806
|
+
/* Two rules are dropped rather than shown, and the second is the one that would have made this chore
|
|
807
|
+
* embarrassing.
|
|
808
|
+
*
|
|
809
|
+
* AN IDIOM THIS BUILD HAS NEVER HEARD OF. The daemon composes the sweep from its own copy of the table, so
|
|
810
|
+
* a sandbox image ahead of the browser can report a rule that has no label or replacement here — and a row
|
|
811
|
+
* saying "42 files use react-foo" with no idea what to do about them is worse than no row.
|
|
812
|
+
*
|
|
813
|
+
* AN IDIOM BELONGING TO A FRAMEWORK THIS REPOSITORY DOES NOT USE. A probe's command is a fixed string, so
|
|
814
|
+
* every rule in the table is swept in every repository, and an Angular pattern gets its chance in a Vue
|
|
815
|
+
* codebase: `RouterModule.forRoot` inside a comment, a `*ngIf` in an example string, and — the case that
|
|
816
|
+
* caught this — the book's own rule table quoting its own patterns back at it. What the repository
|
|
817
|
+
* DECLARES is the arbiter, the same `deps` the gate above reads. */
|
|
818
|
+
const frameworks = new Set(frameworksOf(context.signals.shape.deps).map((framework) => framework.id));
|
|
819
|
+
const found = facts.scan.idioms.flatMap(({ id, files }) => {
|
|
820
|
+
const rule = idiomRule(id);
|
|
821
|
+
return rule === undefined || !frameworks.has(rule.framework) || files.length === 0 ? [] : [{ rule, files }];
|
|
822
|
+
});
|
|
823
|
+
if (found.length === 0) {
|
|
824
|
+
return undefined;
|
|
825
|
+
}
|
|
826
|
+
const total = found.reduce((sum, entry) => sum + entry.files.length, 0);
|
|
827
|
+
const ranked = found.toSorted((left, right) => right.files.length - left.files.length);
|
|
828
|
+
return {
|
|
829
|
+
headline: `${plural(found.length, `retired idiom`)} still in use, across ${plural(total, `file`)}`,
|
|
830
|
+
detail: ranked.map((entry) => `${plural(entry.files.length, `file`)} · ${entry.rule.label} → ${entry.rule.replacement}`),
|
|
831
|
+
digest: digestOf(...ranked.map((entry) => `${entry.rule.id}:${bucketOf(entry.files.length)}`).toSorted()),
|
|
832
|
+
severity: `info`,
|
|
833
|
+
why:
|
|
834
|
+
`${repoLabel(context.repo)} still uses ${plural(found.length, `idiom`)} its framework has replaced: ` +
|
|
835
|
+
`${ranked.map((entry) => `${entry.rule.label} in ${plural(entry.files.length, `file`)} (replaced by ${entry.rule.replacement})`).join(`; `)}. ` +
|
|
836
|
+
`A sample of the files: ${ranked
|
|
837
|
+
.flatMap((entry) => entry.files.slice(0, 3))
|
|
838
|
+
.slice(0, DETAIL_LIMIT)
|
|
839
|
+
.join(`, `)}.`,
|
|
840
|
+
};
|
|
841
|
+
},
|
|
842
|
+
diagnosis: `A retired idiom keeps working until the major release that drops it, and then it is an emergency inside somebody else's upgrade.`,
|
|
843
|
+
goal:
|
|
844
|
+
`Take ONE idiom, the one with the most files, and no more. Convert the files where the conversion is mechanical and the behaviour ` +
|
|
845
|
+
`is provably identical. Stop at the first file that needs a design decision — a class component with genuine error-boundary ` +
|
|
846
|
+
`semantics, an NgModule that something outside the repository imports — leave it, and say what it would take. Do not convert an ` +
|
|
847
|
+
`idiom the repository has deliberately kept: if the newest code uses it too, that is a choice, and reporting it as one is the ` +
|
|
848
|
+
`useful answer.`,
|
|
849
|
+
done: `Done when a re-scan reports fewer files on that idiom, the repository's type-check and tests pass, and every file you skipped has a one-line reason.`,
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
/* COMPONENTS. Two components that are the same component, which is the `library-overlap` finding turned inward:
|
|
853
|
+
* somebody needed a button, did not find the one that existed, and wrote a second one. It is the most ordinary
|
|
854
|
+
* kind of duplication in a front-end and the one no tool complains about, because both files are perfectly good
|
|
855
|
+
* code and neither knows the other exists.
|
|
856
|
+
*
|
|
857
|
+
* TWO KINDS OF EVIDENCE, and they catch opposite failures. A NAME FAMILY catches components that were written
|
|
858
|
+
* separately and never shared a line — `BaseButton.vue` and `ButtonV2.tsx` reduce to the same stem, and no clone
|
|
859
|
+
* detector will ever connect them. A CLONE PAIR catches the reverse: two components with unrelated names doing
|
|
860
|
+
* the same work, which is what jscpd is actually good at, filtered to the pairs where both sides are components
|
|
861
|
+
* so it is a finding about the UI rather than a slice of the repo-wide duplication chore.
|
|
862
|
+
*
|
|
863
|
+
* It needs jscpd rather than reading it if present. Half a measurement would let the row claim it had looked for
|
|
864
|
+
* shared logic in a repository where that sweep has never run — the exact "measured and found nothing" lie the
|
|
865
|
+
* `unavailable` state exists to make impossible. jscpd is already running weekly for the duplication chore in any
|
|
866
|
+
* Node repository, so the honest choice is also the free one. */
|
|
867
|
+
const componentOverlap: Chore = {
|
|
868
|
+
id: `component-overlap`,
|
|
869
|
+
title: `Settle on one component per job`,
|
|
870
|
+
icon: `copy`,
|
|
871
|
+
description: `Components built twice — the same name in two places, or the same logic under two names.`,
|
|
872
|
+
kind: `drifting`,
|
|
873
|
+
criterion: `Two component files reduce to the same name, or a duplicated block spans two components.`,
|
|
874
|
+
applies: needsFramework,
|
|
875
|
+
stance: `report`,
|
|
876
|
+
needs: [`ui`, `jscpd`],
|
|
877
|
+
cadenceMs: 90 * DAY_MS,
|
|
878
|
+
assess: (context) => {
|
|
879
|
+
const ui = factsOf(context, `ui`);
|
|
880
|
+
const jscpd = factsOf(context, `jscpd`);
|
|
881
|
+
if (ui === undefined || jscpd === undefined) {
|
|
882
|
+
return undefined;
|
|
883
|
+
}
|
|
884
|
+
const byStem = new Map<string, string[]>();
|
|
885
|
+
for (const path of ui.scan.components) {
|
|
886
|
+
const stem = componentStem(path);
|
|
887
|
+
if (stem !== undefined) {
|
|
888
|
+
byStem.set(stem, [...(byStem.get(stem) ?? []), normalizePath(path)]);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
const families = [...byStem]
|
|
892
|
+
.filter(([, paths]) => paths.length > 1)
|
|
893
|
+
.map(([stem, paths]) => ({ stem, paths: paths.toSorted() }))
|
|
894
|
+
.toSorted((left, right) => right.paths.length - left.paths.length);
|
|
895
|
+
// Only the clones with a component on BOTH sides. A component that shares a block with a utility module
|
|
896
|
+
// is the duplication chore's finding, not this one, and reporting it here would be two rows lighting for
|
|
897
|
+
// one fact.
|
|
898
|
+
const inventory = new Set(ui.scan.components.map(normalizePath));
|
|
899
|
+
const pairs = jscpd.duplication.top.filter(
|
|
900
|
+
(clone) => inventory.has(normalizePath(clone.first)) && inventory.has(normalizePath(clone.second)),
|
|
901
|
+
);
|
|
902
|
+
if (families.length === 0 && pairs.length === 0) {
|
|
903
|
+
return undefined;
|
|
904
|
+
}
|
|
905
|
+
const parts = [
|
|
906
|
+
...(families.length === 0 ? [] : [`${plural(families.length, `name`)} used by more than one component`]),
|
|
907
|
+
...(pairs.length === 0 ? [] : [`${plural(pairs.length, `clone`)} spanning two of them`]),
|
|
908
|
+
];
|
|
909
|
+
return {
|
|
910
|
+
headline: parts.join(`, `),
|
|
911
|
+
detail: [
|
|
912
|
+
...families.slice(0, DETAIL_LIMIT).map((family) => `${family.stem} · ${family.paths.join(`, `)}`),
|
|
913
|
+
...pairs.map((clone) => `${clone.lines} shared lines · ${normalizePath(clone.first)} ↔ ${normalizePath(clone.second)}`),
|
|
914
|
+
],
|
|
915
|
+
// Identities on both halves: every component that joins or leaves a family, and every clone pair that
|
|
916
|
+
// appears, is genuinely a new fact rather than drift in a number.
|
|
917
|
+
digest: digestOf(
|
|
918
|
+
...families.map((family) => `${family.stem}:${family.paths.join(`+`)}`).toSorted(),
|
|
919
|
+
...pairs.map((clone) => `${normalizePath(clone.first)}|${normalizePath(clone.second)}`).toSorted(),
|
|
920
|
+
),
|
|
921
|
+
severity: `info`,
|
|
922
|
+
why:
|
|
923
|
+
`${repoLabel(context.repo)} has ${parts.join(` and `)}, out of ${plural(ui.scan.components.length, `component file`)} scanned. ` +
|
|
924
|
+
`${
|
|
925
|
+
families.length === 0
|
|
926
|
+
? ``
|
|
927
|
+
: `The names: ${families
|
|
928
|
+
.slice(0, DETAIL_LIMIT)
|
|
929
|
+
.map((family) => `${family.stem} (${family.paths.join(`, `)})`)
|
|
930
|
+
.join(`; `)}. `
|
|
931
|
+
}` +
|
|
932
|
+
`${pairs.length === 0 ? `` : `The clones: ${pairs.map((clone) => `${normalizePath(clone.first)} ↔ ${normalizePath(clone.second)}, ${clone.lines} lines`).join(`; `)}.`}`,
|
|
933
|
+
};
|
|
934
|
+
},
|
|
935
|
+
diagnosis: `A component built twice is maintained once — whichever copy the next person happens to open is the one that gets the fix.`,
|
|
936
|
+
goal:
|
|
937
|
+
`Read every file in each group before saying anything about it; a shared name is a reason to look, not a finding on its own. For ` +
|
|
938
|
+
`each group, say whether these genuinely do the same job, and if they do, name the one to keep and count the call sites that would ` +
|
|
939
|
+
`have to move. Where the answer is that the same LOGIC is duplicated rather than the whole component — the same fetch and loading ` +
|
|
940
|
+
`state, the same form validation, the same list virtualization written twice — say so, and name the hook or composable it should ` +
|
|
941
|
+
`become and where it would live. Where two components share a name and nothing else, say that too and close it: a false family is ` +
|
|
942
|
+
`worth one line, and the next reader needs to know it was considered.`,
|
|
943
|
+
done: `Done when every group has either a component to keep with a call-site count, a shared unit to extract with a home, or a reason it is fine.`,
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
/* TAILWIND. A design system exists to make a decision once; an arbitrary value is that decision being made again,
|
|
947
|
+
* inline, by whoever was in the file. What makes this measurable rather than a matter of taste is that Tailwind
|
|
948
|
+
* spells the bypass out loud — `bg-[#3b82f6]` is the palette being stepped around, in the markup, in a form no
|
|
949
|
+
* reviewer can miss and no linter mentions.
|
|
950
|
+
*
|
|
951
|
+
* Deliberately NOT every arbitrary value. `grid-cols-[1fr_auto]` is the feature working as intended and there is
|
|
952
|
+
* no token it should have been; matching those would make this an objection to Tailwind rather than a finding
|
|
953
|
+
* about this repository. Only colours and pixel sizes, which are the two things the theme definitely already has
|
|
954
|
+
* an answer for. */
|
|
955
|
+
const tailwindBypass: Chore = {
|
|
956
|
+
id: `tailwind-arbitrary-values`,
|
|
957
|
+
title: `Put hard-coded styles back on the scale`,
|
|
958
|
+
icon: `palette`,
|
|
959
|
+
description: `Colours and sizes written inline in the markup, around the theme that already defines them.`,
|
|
960
|
+
kind: `drifting`,
|
|
961
|
+
criterion: `A Tailwind class hard-codes a colour or a pixel size instead of using the theme's scale.`,
|
|
962
|
+
applies: (signals) => (usesTailwind(signals.shape.deps) ? undefined : `no Tailwind`),
|
|
963
|
+
stance: `act`,
|
|
964
|
+
needs: [`ui`],
|
|
965
|
+
cadenceMs: 30 * DAY_MS,
|
|
966
|
+
assess: (context) => {
|
|
967
|
+
const facts = factsOf(context, `ui`);
|
|
968
|
+
if (facts === undefined) {
|
|
969
|
+
return undefined;
|
|
970
|
+
}
|
|
971
|
+
const { bypasses } = facts.scan;
|
|
972
|
+
if (bypasses.length === 0) {
|
|
973
|
+
return undefined;
|
|
974
|
+
}
|
|
975
|
+
const total = bypasses.reduce((sum, entry) => sum + entry.count, 0);
|
|
976
|
+
const worst = bypasses.toSorted((left, right) => right.count - left.count).slice(0, DETAIL_LIMIT);
|
|
977
|
+
return {
|
|
978
|
+
headline: `${plural(total, `hard-coded value`)} across ${plural(bypasses.length, `file`)}`,
|
|
979
|
+
detail: worst.map((entry) => `${entry.path} · ${plural(entry.count, `value`)}`),
|
|
980
|
+
// The worst files by identity — a new file arriving at the top of this list is the event — with the
|
|
981
|
+
// spread and the total riding along bucketed, because both drift by one every time anyone writes
|
|
982
|
+
// markup and neither is worth interrupting somebody about.
|
|
983
|
+
digest: digestOf(...worst.map((entry) => entry.path).toSorted(), `files:${bucketOf(bypasses.length)}`, `total:${bucketOf(total)}`),
|
|
984
|
+
severity: `info`,
|
|
985
|
+
why:
|
|
986
|
+
`${repoLabel(context.repo)} has ${plural(total, `Tailwind class`, `Tailwind classes`)} hard-coding a colour or a pixel size ` +
|
|
987
|
+
`across ${plural(bypasses.length, `file`)}; the heaviest are ` +
|
|
988
|
+
`${worst
|
|
989
|
+
.slice(0, 5)
|
|
990
|
+
.map((entry) => `${entry.path} (${entry.count})`)
|
|
991
|
+
.join(`, `)}.`,
|
|
992
|
+
};
|
|
993
|
+
},
|
|
994
|
+
diagnosis: `Every inline colour is a place the theme cannot reach — a palette change lands everywhere except the files that opted out of it.`,
|
|
995
|
+
goal:
|
|
996
|
+
`Read the theme first — the Tailwind config, or the CSS that defines the tokens — so you know what the scale actually offers. Then ` +
|
|
997
|
+
`replace the values that have a token: an exact palette match, a spacing step, a type size. Where a value is CLOSE to a token but ` +
|
|
998
|
+
`not equal, do not round it silently; that is a visual change wearing a refactor's clothes. List those separately with both values ` +
|
|
999
|
+
`and let the owner decide. Where a value has no token and should — a brand colour used in nine places — say that the theme is ` +
|
|
1000
|
+
`missing an entry rather than editing nine files.`,
|
|
1001
|
+
done: `Done when a re-scan reports fewer hard-coded values, nothing renders differently, and every value you left has a one-line reason.`,
|
|
1002
|
+
};
|
|
1003
|
+
|
|
621
1004
|
/* THE SURVEYS. Chores with no measurement at all, and they are here because the absence of a measurement is not
|
|
622
1005
|
* the absence of value — these are the reviews a codebase silently rots without, and none of them can be detected
|
|
623
1006
|
* by a tool. Their trigger is the calendar, and the ledger is what makes that trigger honest: a survey is due
|
|
@@ -655,6 +1038,10 @@ const survey = ({ id, title, icon, description, diagnosis, goal, done, cadenceDa
|
|
|
655
1038
|
title,
|
|
656
1039
|
icon,
|
|
657
1040
|
description,
|
|
1041
|
+
// Not a parameter of SurveySpec, and it never will be: a survey has no measurement, so "due because it has
|
|
1042
|
+
// been that long" IS the surveying kind. The two are the same claim spelled twice, and the test below holds
|
|
1043
|
+
// them to it in both directions.
|
|
1044
|
+
kind: `surveying`,
|
|
658
1045
|
criterion: `${cadenceDays} days have passed since this review was last run.`,
|
|
659
1046
|
applies,
|
|
660
1047
|
stance: `report`,
|
|
@@ -693,10 +1080,9 @@ const patterns = survey({
|
|
|
693
1080
|
`estimate the size of the conversion. Do not convert anything.`,
|
|
694
1081
|
done: `Done when each concern has a named convention, a reference file, and a count of the sites that diverge from it.`,
|
|
695
1082
|
cadenceDays: 90,
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
: `this repository has ${signals.totals.files} indexed files — too few for cross-cutting patterns to have diverged`,
|
|
1083
|
+
// The one cause that is a measurement rather than an absence, and it still groups: every chore gated on size
|
|
1084
|
+
// is gated on the SAME size, so the string is the same string.
|
|
1085
|
+
applies: (signals) => (signals.totals.files >= PATTERNS_FLOOR ? undefined : `only ${signals.totals.files} indexed files`),
|
|
700
1086
|
});
|
|
701
1087
|
|
|
702
1088
|
const deprecated = survey({
|
|
@@ -712,7 +1098,7 @@ const deprecated = survey({
|
|
|
712
1098
|
`replacement for each. Change nothing.`,
|
|
713
1099
|
done: `Done when every deprecation has call sites cited, a replacement named, and the release it is expected to break in.`,
|
|
714
1100
|
cadenceDays: 90,
|
|
715
|
-
applies: (signals) => (signals.shape.packageManifest ? undefined : `
|
|
1101
|
+
applies: (signals) => (signals.shape.packageManifest ? undefined : `no package.json`),
|
|
716
1102
|
});
|
|
717
1103
|
|
|
718
1104
|
/* THE CHORE THAT NAMED THE PROBLEM. Gated on documents actually EXISTING, which is the whole reason `applies`
|
|
@@ -720,8 +1106,10 @@ const deprecated = survey({
|
|
|
720
1106
|
* re-read, and the first thing an owner of a fresh workspace sees is an offer to re-read documentation they have
|
|
721
1107
|
* never written. That is not a chore being wrong about a threshold — it is the surface admitting it never looked.
|
|
722
1108
|
*
|
|
723
|
-
* Note which fact it gates on: the
|
|
724
|
-
*
|
|
1109
|
+
* Note which fact it gates on: the MAP, not the directory. An empty `docs/architecture/` is a directory somebody
|
|
1110
|
+
* made and never filled, and a gate on the directory would put the chore back exactly where it started. The
|
|
1111
|
+
* survey then reads the package READMEs too — they are the package pages — but a repo with no map has not been
|
|
1112
|
+
* documented at all, and that is the case worth staying quiet for. */
|
|
725
1113
|
const documentationDrift = survey({
|
|
726
1114
|
id: `documentation-drift`,
|
|
727
1115
|
title: `Re-read the documentation against the code`,
|
|
@@ -734,7 +1122,7 @@ const documentationDrift = survey({
|
|
|
734
1122
|
`what, which file to change — over prose that has merely aged. Do not rewrite the documents; produce the list of what is wrong.`,
|
|
735
1123
|
done: `Done when every architecture document has been read and every false claim is listed with both sides cited.`,
|
|
736
1124
|
cadenceDays: 90,
|
|
737
|
-
applies: (signals) => (signals.shape.docs.length > 0 ? undefined : `
|
|
1125
|
+
applies: (signals) => (signals.shape.docs.length > 0 ? undefined : `no architecture documents`),
|
|
738
1126
|
});
|
|
739
1127
|
|
|
740
1128
|
/* THE TWO CHORES THAT ONLY EXIST WHERE THEIR SUBJECT DOES. Both are surveys — nothing here can measure whether a
|
|
@@ -756,7 +1144,7 @@ const pipelines = survey({
|
|
|
756
1144
|
`because it genuinely has to be, say so — a pipeline that is honestly expensive is not a finding.`,
|
|
757
1145
|
done: `Done when every finding names a file, a step, and a concrete change, and anything deliberately slow is called out as such.`,
|
|
758
1146
|
cadenceDays: 90,
|
|
759
|
-
applies: (signals) => (signals.shape.ci.length > 0 ? undefined : `
|
|
1147
|
+
applies: (signals) => (signals.shape.ci.length > 0 ? undefined : `no CI pipeline`),
|
|
760
1148
|
});
|
|
761
1149
|
|
|
762
1150
|
const images = survey({
|
|
@@ -772,28 +1160,58 @@ const images = survey({
|
|
|
772
1160
|
`Dockerfiles — an image that fails to build is a much worse problem than one that is larger than ideal.`,
|
|
773
1161
|
done: `Done when every finding cites a Dockerfile line and names the change, with the ones that would need a base-image swap called out separately.`,
|
|
774
1162
|
cadenceDays: 90,
|
|
775
|
-
applies: (signals) => (signals.shape.dockerfiles.length > 0 ? undefined : `
|
|
1163
|
+
applies: (signals) => (signals.shape.dockerfiles.length > 0 ? undefined : `no Dockerfile`),
|
|
776
1164
|
});
|
|
777
1165
|
|
|
778
1166
|
/* THE BOOK'S ORDER, which is the panel's reading order and therefore a product decision rather than whatever
|
|
779
1167
|
* order these were written in. It narrows from "this is a risk you are carrying right now" to "this is worth
|
|
780
|
-
* thinking about this quarter"
|
|
781
|
-
*
|
|
782
|
-
*
|
|
783
|
-
*
|
|
784
|
-
*
|
|
1168
|
+
* thinking about this quarter".
|
|
1169
|
+
*
|
|
1170
|
+
* This used to be a comment above a hand-sorted array — the four kinds named in prose, the order maintained by
|
|
1171
|
+
* whoever added the last chore, and nothing anywhere that could check the two agreed. It was also thrown away at
|
|
1172
|
+
* render: the panel listed every chore in one flat column, so the single editorial claim this surface makes
|
|
1173
|
+
* ("a live advisory and a quarterly re-read are not the same kind of thing") was invisible and therefore
|
|
1174
|
+
* unarguable — on a page whose whole design is that every claim shows its working.
|
|
1175
|
+
*
|
|
1176
|
+
* So the kinds are data. They order the book here, they group the rows in the panel, and `caption` is the
|
|
1177
|
+
* sentence the panel puts beside each group so the grouping argues for itself.
|
|
785
1178
|
*
|
|
786
1179
|
* Ordering is by KIND, not by whether a given repository will see them: a chore that does not apply is dropped
|
|
787
|
-
* from that repository's list entirely (verdict.ts), so the reading order never has holes in it.
|
|
788
|
-
|
|
1180
|
+
* from that repository's list entirely (verdict.ts), so the reading order never has holes in it. It is also why
|
|
1181
|
+
* a block of chores written together does not READ together: the front-end four are one paragraph in this file
|
|
1182
|
+
* because they share a gate and a probe, and `kind` is what puts a Vue repository's bundle row next to its
|
|
1183
|
+
* dependency row rather than in a "front-end" section at the bottom. Where a chore is written and where it is
|
|
1184
|
+
* ranked are two separate facts, and only one of them is a product decision. */
|
|
1185
|
+
export interface ChoreKindSpec {
|
|
1186
|
+
readonly kind: ChoreKind;
|
|
1187
|
+
// Title case, because the panel renders it as a group heading rather than as a sentence.
|
|
1188
|
+
readonly label: string;
|
|
1189
|
+
// Why these belong together, in the reader's terms — what the group is CLAIMING about the rows under it.
|
|
1190
|
+
readonly caption: string;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
export const CHORE_KINDS: readonly ChoreKindSpec[] = [
|
|
1194
|
+
{ kind: `carrying`, label: `Carrying`, caption: `a risk this repository is running today — someone else decides when it becomes urgent` },
|
|
1195
|
+
{ kind: `accruing`, label: `Accruing`, caption: `cheap now, expensive later, and always getting later` },
|
|
1196
|
+
{ kind: `drifting`, label: `Drifting`, caption: `the shape of the thing is diverging from the idea of it` },
|
|
1197
|
+
{ kind: `surveying`, label: `Surveying`, caption: `periodic reads with nothing measuring them — due because it has been that long` },
|
|
1198
|
+
];
|
|
1199
|
+
|
|
1200
|
+
// Declaration order, which decides nothing but the order WITHIN a kind — the sort below is stable, so the two
|
|
1201
|
+
// facts stay separable: this list is where a chore is written down, CHORE_KINDS is where it is ranked.
|
|
1202
|
+
const BOOK: readonly Chore[] = [
|
|
789
1203
|
security,
|
|
790
1204
|
runtime,
|
|
791
1205
|
dependencies,
|
|
792
1206
|
deadCode,
|
|
793
1207
|
complexity,
|
|
1208
|
+
bundleWeight,
|
|
1209
|
+
frameworkIdiom,
|
|
794
1210
|
documentation,
|
|
795
1211
|
duplication,
|
|
796
1212
|
libraries,
|
|
1213
|
+
componentOverlap,
|
|
1214
|
+
tailwindBypass,
|
|
797
1215
|
patterns,
|
|
798
1216
|
deprecated,
|
|
799
1217
|
documentationDrift,
|
|
@@ -801,6 +1219,12 @@ export const CHORES: readonly Chore[] = [
|
|
|
801
1219
|
images,
|
|
802
1220
|
];
|
|
803
1221
|
|
|
1222
|
+
const KIND_ORDER: readonly ChoreKind[] = CHORE_KINDS.map(({ kind }) => kind);
|
|
1223
|
+
|
|
1224
|
+
// Sorted rather than filtered into groups, so no chore can ever be dropped out of the book by a kind the list
|
|
1225
|
+
// above forgot — a missing kind sorts to the front, where it is visible, instead of vanishing.
|
|
1226
|
+
export const CHORES: readonly Chore[] = BOOK.toSorted((left, right) => KIND_ORDER.indexOf(left.kind) - KIND_ORDER.indexOf(right.kind));
|
|
1227
|
+
|
|
804
1228
|
export const choreById = (id: string): Chore | undefined => CHORES.find((chore) => chore.id === id);
|
|
805
1229
|
|
|
806
1230
|
// The prompt for one chore against one finding. Built here rather than in the view because the panel, the badge's
|