autonomous-sdlc-harness 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +7 -0
- package/README.md +24 -0
- package/dist/cli.js +194 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/config.js +561 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/daemon.js +791 -0
- package/dist/commands/daemon.js.map +1 -0
- package/dist/commands/doctor.js +336 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/init.js +2023 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/registry.js +42 -0
- package/dist/commands/registry.js.map +1 -0
- package/dist/config/check.js +505 -0
- package/dist/config/check.js.map +1 -0
- package/dist/config/io.js +177 -0
- package/dist/config/io.js.map +1 -0
- package/dist/config/model.js +406 -0
- package/dist/config/model.js.map +1 -0
- package/dist/core/errors.js +71 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/git.js +537 -0
- package/dist/core/git.js.map +1 -0
- package/dist/core/json.js +125 -0
- package/dist/core/json.js.map +1 -0
- package/dist/core/layerCoverage.js +141 -0
- package/dist/core/layerCoverage.js.map +1 -0
- package/dist/core/layerGapRemedy.js +62 -0
- package/dist/core/layerGapRemedy.js.map +1 -0
- package/dist/core/nameList.js +23 -0
- package/dist/core/nameList.js.map +1 -0
- package/dist/core/paths.js +153 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/prompt.js +206 -0
- package/dist/core/prompt.js.map +1 -0
- package/dist/core/repoPaths.js +55 -0
- package/dist/core/repoPaths.js.map +1 -0
- package/dist/core/report.js +150 -0
- package/dist/core/report.js.map +1 -0
- package/dist/core/templating.js +88 -0
- package/dist/core/templating.js.map +1 -0
- package/dist/core/writer.js +479 -0
- package/dist/core/writer.js.map +1 -0
- package/dist/daemon/backend.js +180 -0
- package/dist/daemon/backend.js.map +1 -0
- package/dist/daemon/units.js +380 -0
- package/dist/daemon/units.js.map +1 -0
- package/dist/detect/nestedApplication.js +79 -0
- package/dist/detect/nestedApplication.js.map +1 -0
- package/dist/detect/presets.js +2033 -0
- package/dist/detect/presets.js.map +1 -0
- package/dist/detect/signals.js +1368 -0
- package/dist/detect/signals.js.map +1 -0
- package/dist/doctor/checks.js +3530 -0
- package/dist/doctor/checks.js.map +1 -0
- package/dist/generators/claudeContext.js +588 -0
- package/dist/generators/claudeContext.js.map +1 -0
- package/dist/generators/githooks.js +446 -0
- package/dist/generators/githooks.js.map +1 -0
- package/dist/generators/harnessConfig.js +632 -0
- package/dist/generators/harnessConfig.js.map +1 -0
- package/dist/generators/notifications.js +191 -0
- package/dist/generators/notifications.js.map +1 -0
- package/dist/generators/outerLoopScripts.js +165 -0
- package/dist/generators/outerLoopScripts.js.map +1 -0
- package/dist/generators/permissionProfile.js +1172 -0
- package/dist/generators/permissionProfile.js.map +1 -0
- package/dist/generators/projectSettings.js +322 -0
- package/dist/generators/projectSettings.js.map +1 -0
- package/dist/generators/repoRoot.js +417 -0
- package/dist/generators/repoRoot.js.map +1 -0
- package/dist/generators/scripts.js +557 -0
- package/dist/generators/scripts.js.map +1 -0
- package/dist/generators/stateDir.js +221 -0
- package/dist/generators/stateDir.js.map +1 -0
- package/dist/machine/paths.js +111 -0
- package/dist/machine/paths.js.map +1 -0
- package/dist/machine/plugins.js +224 -0
- package/dist/machine/plugins.js.map +1 -0
- package/dist/machine/registry.js +330 -0
- package/dist/machine/registry.js.map +1 -0
- package/package.json +23 -0
- package/scripts/README.md +13 -0
- package/scripts/daemon/launchd.plist.template +59 -0
- package/scripts/daemon/systemd.service.template +58 -0
- package/templates/README.md +15 -0
- package/templates/claude/CLAUDE.md +54 -0
- package/templates/claude/README.md +5 -0
- package/templates/claude/context/api.md +29 -0
- package/templates/claude/context/conventions.md +23 -0
- package/templates/claude/context/data-layer.md +28 -0
- package/templates/claude/context/data-storage.md +29 -0
- package/templates/claude/context/docs-catalog.md +29 -0
- package/templates/claude/context/domain.md +28 -0
- package/templates/claude/context/layer.md +20 -0
- package/templates/claude/context/module.md +30 -0
- package/templates/claude/context/package.md +29 -0
- package/templates/claude/context/presentation.md +32 -0
- package/templates/claude/context/state-slices.md +28 -0
- package/templates/claude/context/tests.md +28 -0
- package/templates/claude/harness-task-offer.md +58 -0
- package/templates/claude/push-notify.env.example +21 -0
- package/templates/claude/qa-accounts.env.example +38 -0
- package/templates/claude/qa_test_scenarios.md +110 -0
- package/templates/claude/settings.autonomous.json +93 -0
- package/templates/claude/settings.autonomous.qa.json +36 -0
- package/templates/githooks/README.md +3 -0
- package/templates/githooks/pre-push +72 -0
- package/templates/repo/README.md +3 -0
- package/templates/repo/gitattributes +16 -0
- package/templates/repo/gitignore +61 -0
- package/templates/repo/gitignore.qa +25 -0
- package/templates/repo/mcp.json +17 -0
- package/templates/scripts/README.md +5 -0
- package/templates/scripts/autonomous-format-stream.sh +95 -0
- package/templates/scripts/autonomous-notify.sh +337 -0
- package/templates/scripts/autonomous-watcher.sh +3087 -0
- package/templates/scripts/cleanup-merged-worktrees.sh +327 -0
- package/templates/scripts/commit-on-branch.sh +288 -0
- package/templates/scripts/create-worktree.sh +360 -0
- package/templates/scripts/deploy.sh +47 -0
- package/templates/scripts/lib/harness-run-lib.sh +1481 -0
- package/templates/scripts/push-branch.sh +140 -0
- package/templates/scripts/refresh-branch.sh +244 -0
- package/templates/scripts/restart-watcher.sh +401 -0
- package/templates/scripts/scratch-run.sh +302 -0
- package/templates/scripts/setup-worktree.sh +262 -0
- package/templates/scripts/start-dev-server.sh +99 -0
- package/templates/scripts/test.sh +50 -0
- package/templates/scripts/typecheck.sh +50 -0
- package/templates/state-dir/README-root.md +13 -0
- package/templates/state-dir/README.md +9 -0
- package/templates/state-dir/architecture_branch_review_point_reviews/README.md +9 -0
- package/templates/state-dir/architecture_branch_reviews/README.md +9 -0
- package/templates/state-dir/architecture_reviews/README.md +9 -0
- package/templates/state-dir/architecture_user_review_reviews/README.md +9 -0
- package/templates/state-dir/autonomous_inbox/README.md +9 -0
- package/templates/state-dir/autonomous_logs/README.md +9 -0
- package/templates/state-dir/branch_statistics/README.md +9 -0
- package/templates/state-dir/business_parity_branch_review_point_reviews/README.md +9 -0
- package/templates/state-dir/business_parity_branch_reviews/README.md +9 -0
- package/templates/state-dir/business_parity_reviews/README.md +9 -0
- package/templates/state-dir/business_parity_user_review_reviews/README.md +9 -0
- package/templates/state-dir/clarification_digests/README.md +9 -0
- package/templates/state-dir/clarifications/README.md +9 -0
- package/templates/state-dir/code_reviews/README.md +9 -0
- package/templates/state-dir/dispatch_additions/README.md +19 -0
- package/templates/state-dir/docs_catalog/README.md +9 -0
- package/templates/state-dir/flow_progress/README.md +9 -0
- package/templates/state-dir/improvement_observations/README.md +19 -0
- package/templates/state-dir/improvement_suggestions.md +29 -0
- package/templates/state-dir/lessons.md +23 -0
- package/templates/state-dir/qa_review_point_reviews/README.md +9 -0
- package/templates/state-dir/qa_reviews/README.md +9 -0
- package/templates/state-dir/review_plan_point_reviews/README.md +9 -0
- package/templates/state-dir/review_plan_reviews/README.md +9 -0
- package/templates/state-dir/scratch/README.md +11 -0
- package/templates/state-dir/skeptic_review_plan_reviews/README.md +9 -0
- package/templates/state-dir/skeptic_review_point_reviews/README.md +9 -0
- package/templates/state-dir/skeptic_reviews/README.md +9 -0
- package/templates/state-dir/story_plans/README.md +9 -0
- package/templates/state-dir/task_plan_point_reviews/README.md +9 -0
- package/templates/state-dir/task_plan_reviews/README.md +9 -0
- package/templates/state-dir/task_plans/README.md +9 -0
- package/templates/state-dir/task_prompts/README.md +9 -0
- package/templates/state-dir/ui_test_plan_reviews/README.md +9 -0
- package/templates/state-dir/ui_test_plans/README.md +9 -0
- package/templates/state-dir/user_review_fix_plan_point_reviews/README.md +9 -0
- package/templates/state-dir/user_reviews/README.md +9 -0
|
@@ -0,0 +1,3530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `doctor`'s questions: what is asked of an adopted repository, and how each answer is graded.
|
|
3
|
+
*
|
|
4
|
+
* **The rule this module exists to enforce: one broken check may not hide the rest.** `doctor` is
|
|
5
|
+
* the command an operator reaches for *because* something is wrong, so it has to answer about
|
|
6
|
+
* everything it was asked about — a check that threw would otherwise abort the run at whichever
|
|
7
|
+
* question happened to come first, and the report would be shorter the worse the repository is.
|
|
8
|
+
* {@link runChecks} therefore evaluates every entry of {@link CHECKS} in order and turns a thrown
|
|
9
|
+
* value into that check's own `fail`, so the count at the end is always over the whole list.
|
|
10
|
+
*
|
|
11
|
+
* ## Three non-obvious choices, and where each comes from
|
|
12
|
+
*
|
|
13
|
+
* 1. **Nothing here is re-derived from a second copy.** The backend detection and the watcher path
|
|
14
|
+
* are `daemon/backend.ts`'s, so `doctor` and `daemon` cannot describe one host differently; the
|
|
15
|
+
* config's shape rules are `config/check.ts`'s; the artifact-directory list is
|
|
16
|
+
* `generators/stateDir.ts`'s {@link selectedStateDirs}, the file paths are the generators' own
|
|
17
|
+
* exported constants, the ignore-rule spellings — including the superseded one — are
|
|
18
|
+
* `generators/repoRoot.ts`'s {@link clarificationsIgnoreRules} and the committed contract files
|
|
19
|
+
* those rules must leave committable are that module's {@link contentsIgnoredDirectories} rather
|
|
20
|
+
* than whatever negations the audited file still carries, and its managed block is found by
|
|
21
|
+
* that module's {@link GITIGNORE_BLOCK_HEADER}, the line the write engine itself matches on, the
|
|
22
|
+
* push-settings precedence is `generators/notifications.ts`'s {@link pushEnvCandidates}, the
|
|
23
|
+
* repository registry's location, reader and staleness grading are `machine/registry.ts`'s {@link registryPath},
|
|
24
|
+
* {@link readRegistry} and {@link inspect} — the same three `daemon list` enumerates through — and
|
|
25
|
+
* the writability probe is the write engine's {@link probeWritable}. A
|
|
26
|
+
* check that wanted a slightly different answer would be a second definition of the thing being
|
|
27
|
+
* checked, which is how a green `doctor` starts disagreeing with the run it is supposed to
|
|
28
|
+
* predict.
|
|
29
|
+
* 2. **The grades are chosen so that a freshly wired repository exits 0 — once it has a remote.** A
|
|
30
|
+
* `fail` means the flow cannot run as configured; a `warn` means something is worth fixing and
|
|
31
|
+
* nothing stops. The condition a freshly wired repository still ships with unresolved — the marketplace
|
|
32
|
+
* entry, which needs a published repository (`generators/projectSettings.ts`) — is therefore a
|
|
33
|
+
* warning, as is a run watcher that was deleted or left behind by a moved `scriptsDir`
|
|
34
|
+
* (`daemon/backend.ts`), and the command's exit status stays a statement about whether the
|
|
35
|
+
* repository is *runnable* rather than about whether it is perfect.
|
|
36
|
+
*
|
|
37
|
+
* **The remote is the one condition that qualifies that sentence, and it is deliberately a
|
|
38
|
+
* `fail`** — do not soften it back to a warning. A repository adopted by `init --git-init` has no
|
|
39
|
+
* remote by construction, so it is freshly wired and still exits non-zero: `create-worktree.sh`
|
|
40
|
+
* creates every run's checkout from `origin/<defaultBranch>`, so with no such ref the flow does
|
|
41
|
+
* not start at all and the report would otherwise be all-green about a repository nothing can run
|
|
42
|
+
* in. That is what separates it from the two checks whose remedies are also hand steps and which
|
|
43
|
+
* never fail (`notifications`, `repo-registry`): neither of those is a precondition for a run to
|
|
44
|
+
* begin, and this one is. The remedy is an adopter's to take — add a remote, push the branch —
|
|
45
|
+
* which is exactly the kind of condition the exit status is supposed to report.
|
|
46
|
+
* 3. **Reachability is command resolution by default, and a read-only registry query at most — never
|
|
47
|
+
* a launch.** The browser-wiring check asks whether a declared server's launch command resolves on
|
|
48
|
+
* `PATH`; under {@link CheckContext.probeRegistry} it additionally asks the registry which version
|
|
49
|
+
* each pinned package has ({@link probeRegistrySpec}), which fetches no package and installs
|
|
50
|
+
* nothing. Neither arm starts a server or opens a browser. A `doctor` that launched a browser would
|
|
51
|
+
* be a `doctor` nobody runs in CI, and driving a browser is the interactive-test phase's job and
|
|
52
|
+
* its agent's alone. The registry query is behind a flag for the neighbouring reason: a default run
|
|
53
|
+
* reaches no network, so it answers the same in CI, in an `&&` chain and offline — and the run that
|
|
54
|
+
* did not ask says so, rather than leaving an adopter to read a pass as a promise the packages can
|
|
55
|
+
* be fetched.
|
|
56
|
+
*
|
|
57
|
+
* ## What this module deliberately does not do
|
|
58
|
+
*
|
|
59
|
+
* - **It writes nothing** beyond {@link probeWritable}'s temp file, which that function removes with
|
|
60
|
+
* an in-process `fs.rm` on a single file — never a shelled-out recursive removal, which a
|
|
61
|
+
* user-level `permissions.deny` can silently block (`cli.ts`'s header).
|
|
62
|
+
* - **It repairs nothing.** Every failure names what to run — `init`, `init --force`, an edit to one
|
|
63
|
+
* config key — and `doctor` stays a command that is safe to run against a repository at any time.
|
|
64
|
+
*/
|
|
65
|
+
import { execFileSync } from 'node:child_process';
|
|
66
|
+
import { accessSync, constants as fsConstants, existsSync, readFileSync, statSync } from 'node:fs';
|
|
67
|
+
import { delimiter, join, posix, resolve as resolvePath } from 'node:path';
|
|
68
|
+
import { formatProblem } from '../config/check.js';
|
|
69
|
+
import { loadConfig } from '../config/io.js';
|
|
70
|
+
import { answersNone, browserWiringApplies, COMMAND_NONE_SENTINEL, CONFIG_FILENAME, DEFAULTS, FALLBACK_PRESET, isPlaceholder, LAYER_CATCH_ALL_PATH, STATE_DIR_DOT_PATTERN, STATE_DIR_PATTERN, } from '../config/model.js';
|
|
71
|
+
import { branchResolves, checkedOutBranch, commitsAhead, configuredRemotes, hasCommits, pathIsIgnored, remoteTrackingBranchResolves, resolveRepoRoot, worktreeList, } from '../core/git.js';
|
|
72
|
+
import { isJsonObject, readJsonFile } from '../core/json.js';
|
|
73
|
+
import { layerCoverage } from '../core/layerCoverage.js';
|
|
74
|
+
import { layerGapRemedy, recordedVerdictClause } from '../core/layerGapRemedy.js';
|
|
75
|
+
import { nameList } from '../core/nameList.js';
|
|
76
|
+
import { readTemplate, workRoot } from '../core/paths.js';
|
|
77
|
+
import { normalizeRepoPathStrict } from '../core/repoPaths.js';
|
|
78
|
+
import { probeWritable } from '../core/writer.js';
|
|
79
|
+
import { detectBackend, resolveWatcherPath, watcherMissingMessage } from '../daemon/backend.js';
|
|
80
|
+
import { renderUnit, repoSlug, unitEnvValue } from '../daemon/units.js';
|
|
81
|
+
import { LAYERLESS_BY_DESIGN_PRESETS, SHARED_CONVENTIONS_PATH, TESTS_LAYER_NAME } from '../detect/presets.js';
|
|
82
|
+
import { FORCED_SIGNAL_ID } from '../detect/signals.js';
|
|
83
|
+
import { CLAUDE_MD_PATH, SETUP_PENDING_CLOSE, SETUP_PENDING_OPEN, SKELETON_GUIDANCE_MARKER, TASK_OFFER_PATH, UNFILLED_STUB_MARKER, } from '../generators/claudeContext.js';
|
|
84
|
+
import { caseLabelMatches, isGlobPattern, PRE_PUSH_HOOK, readProtectedCaseLabel, resolveGithooksDir, resolveProtectedBranches, } from '../generators/githooks.js';
|
|
85
|
+
import { PUSH_CMD_KEY, PUSH_URL_KEY, pushEnvCandidates, } from '../generators/notifications.js';
|
|
86
|
+
import { outerLoopScriptsDir } from '../generators/outerLoopScripts.js';
|
|
87
|
+
import { bashScriptRule, entryWord, isUnderDirectory, namesBrowserTool, normalizedRoot, pluginRootEntryTarget, PROFILE_PATH, readRule, renderProfile, TEMPLATE_PATH as PROFILE_TEMPLATE_PATH, } from '../generators/permissionProfile.js';
|
|
88
|
+
import { ENABLED_PLUGINS_KEY, MARKETPLACE_ENTRY_SHAPE, MARKETPLACE_FLAG, MARKETPLACES_KEY, MARKETPLACE_NAME, marketplaceEntryDefect, PLUGIN_KEY, SETTINGS_PATH, SLUG_SHAPE, } from '../generators/projectSettings.js';
|
|
89
|
+
import { clarificationsIgnoreRules, contentsIgnoredDirectories, GITIGNORE_BLOCK_HEADER, GITIGNORE_PATH, MCP_PATH, } from '../generators/repoRoot.js';
|
|
90
|
+
import { configKeyPath, configuredWrapperFile, scriptInvocation, selectWrapper, wrappedKeyMismatch, wrappedKeyMismatchMessage, WRAPPER_SCRIPTS, wrapperCommandLine, } from '../generators/scripts.js';
|
|
91
|
+
import { selectedStateDirs } from '../generators/stateDir.js';
|
|
92
|
+
import { machineConfigDir } from '../machine/paths.js';
|
|
93
|
+
import { installedPluginsPath, knownMarketplacesPath, pluginHelperPath, pluginHelperScripts, pluginInstallRoot, pluginRuntimeRoot, pluginScriptsDir, } from '../machine/plugins.js';
|
|
94
|
+
import { inspect, readRegistry, registryPath } from '../machine/registry.js';
|
|
95
|
+
/**
|
|
96
|
+
* How the CLI is typed, for every remedy that tells an operator what to run next. The `npx` prefix
|
|
97
|
+
* is not decoration: the rule for which occurrences carry it is stated once in `commands/init.ts`,
|
|
98
|
+
* beside its own `CLI`.
|
|
99
|
+
*/
|
|
100
|
+
const CLI = 'npx autonomous-sdlc-harness';
|
|
101
|
+
/**
|
|
102
|
+
* The outer-loop script whose precondition {@link REMOTE_CHECK} reports on, named the way this
|
|
103
|
+
* module already names `harness-run-lib.sh` and `autonomous-notify.sh` — as a file in a sentence.
|
|
104
|
+
* Nothing here resolves it or reads it; a check that needed its *path* would ask
|
|
105
|
+
* `generators/outerLoopScripts.ts` for it rather than joining one here.
|
|
106
|
+
*/
|
|
107
|
+
const WORKTREE_SCRIPT = 'create-worktree.sh';
|
|
108
|
+
/**
|
|
109
|
+
* The outer-loop script {@link BASE_FRESHNESS_CHECK}'s second remedy names, held beside
|
|
110
|
+
* {@link WORKTREE_SCRIPT} for the same reason: it is a file named in a sentence. The invocation the
|
|
111
|
+
* message prints is {@link scriptInvocation}'s — `generators/scripts.ts` is the one producer of
|
|
112
|
+
* `bash <scriptsDir>/<file>` — so nothing here formats that shape a second time.
|
|
113
|
+
*/
|
|
114
|
+
const REFRESH_SCRIPT = 'refresh-branch.sh';
|
|
115
|
+
/** The permission lists a generated profile carries, in the order the template writes them. */
|
|
116
|
+
const PERMISSION_LISTS = Object.freeze(['allow', 'deny', 'ask']);
|
|
117
|
+
/** An unsubstituted token, which a raw template's entry may carry and a rendered profile may not. */
|
|
118
|
+
const TOKEN_MARKER = '{{';
|
|
119
|
+
/** The profile key that starts the repository's declared MCP servers for a run. */
|
|
120
|
+
const ENABLED_SERVERS_KEY = 'enabledMcpjsonServers';
|
|
121
|
+
/** The key `.mcp.json` declares its servers under. */
|
|
122
|
+
const SERVERS_KEY = 'mcpServers';
|
|
123
|
+
/** How long the `jq` version probe may take before it is treated as no answer at all. */
|
|
124
|
+
const JQ_PROBE_TIMEOUT_MS = 5_000;
|
|
125
|
+
/**
|
|
126
|
+
* How long one registry metadata read may take before that package is reported as unanswered.
|
|
127
|
+
*
|
|
128
|
+
* Wider than the `jq` bound beside it because this is the one probe in this module that crosses the
|
|
129
|
+
* network, and the adopter it exists for is on the slow side of it — a corporate proxy, a distant
|
|
130
|
+
* mirror, a cold cache. A bound tight enough to cut those off would report a working registry as
|
|
131
|
+
* unreachable, which is the one wrong answer this check must not give. It is still a bound rather
|
|
132
|
+
* than a deadline: a registry that accepts the connection and never answers costs this much per
|
|
133
|
+
* pinned package and is then reported, and never hangs the command an operator ran to find out
|
|
134
|
+
* what is wrong.
|
|
135
|
+
*/
|
|
136
|
+
const REGISTRY_PROBE_TIMEOUT_MS = 20_000;
|
|
137
|
+
/** The launcher whose declared package spec is fetched from the registry on first use, not installed. */
|
|
138
|
+
const REGISTRY_LAUNCHER = 'npx';
|
|
139
|
+
/** The client the reachability probe asks the registry through, and the name it is resolved by. */
|
|
140
|
+
const REGISTRY_CLIENT = 'npm';
|
|
141
|
+
/** The floor `plugin/hooks/README.md` §Prerequisites states, as the two numbers that decide it. */
|
|
142
|
+
const JQ_MIN_MAJOR = 1;
|
|
143
|
+
const JQ_MIN_MINOR = 5;
|
|
144
|
+
/**
|
|
145
|
+
* The first `<major>.<minor>` in whatever `jq --version` printed.
|
|
146
|
+
*
|
|
147
|
+
* Deliberately loose, because the string has changed shape across the releases this floor spans —
|
|
148
|
+
* `jq-1.7.1`, `jq-1.6`, `jq version 1.5`, and a distribution's own suffixed spelling — and the two
|
|
149
|
+
* numbers are the only part of any of them this check decides on. A string carrying none of it is
|
|
150
|
+
* graded rather than parsed anyway; see {@link JQ_CHECK}.
|
|
151
|
+
*/
|
|
152
|
+
const JQ_VERSION_PATTERN = /(\d+)\.(\d+)/;
|
|
153
|
+
/** How much of a probe's own output a detail quotes back before it elides the rest. */
|
|
154
|
+
const PROBE_OUTPUT_CAP = 60;
|
|
155
|
+
/** A subprocess's output as one bounded line — a {@link CheckOutcome} detail may carry no newline. */
|
|
156
|
+
function firstLine(output) {
|
|
157
|
+
const line = (output.split('\n')[0] ?? '').trim();
|
|
158
|
+
return line.length > PROBE_OUTPUT_CAP ? `${line.slice(0, PROBE_OUTPUT_CAP)}…` : line;
|
|
159
|
+
}
|
|
160
|
+
/** A grade, as the checks below build one. */
|
|
161
|
+
function pass(detail) {
|
|
162
|
+
return { status: 'pass', detail };
|
|
163
|
+
}
|
|
164
|
+
function warn(detail) {
|
|
165
|
+
return { status: 'warn', detail };
|
|
166
|
+
}
|
|
167
|
+
function fail(detail) {
|
|
168
|
+
return { status: 'fail', detail };
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* A check whose input never resolved, graded `fail`.
|
|
172
|
+
*
|
|
173
|
+
* `fail` rather than a fourth "skipped" status: the question was asked and has no answer, which is
|
|
174
|
+
* not the same as an answer of "fine". The exit status is already non-zero from whichever check
|
|
175
|
+
* reported the missing input, so this adds no false alarm — it adds the line that says which further
|
|
176
|
+
* questions that failure left unanswered.
|
|
177
|
+
*/
|
|
178
|
+
function unevaluated(reason) {
|
|
179
|
+
return fail(`not evaluated, because ${reason}`);
|
|
180
|
+
}
|
|
181
|
+
/** A thrown value as a message. */
|
|
182
|
+
function messageOf(error) {
|
|
183
|
+
return error instanceof Error ? error.message : String(error);
|
|
184
|
+
}
|
|
185
|
+
/** Whether something is at this path and is a directory. Never throws. */
|
|
186
|
+
function isDirectory(path) {
|
|
187
|
+
try {
|
|
188
|
+
return statSync(path).isDirectory();
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** Whether this path names a file the current account may execute. Never throws. */
|
|
195
|
+
function isExecutableFile(path) {
|
|
196
|
+
try {
|
|
197
|
+
if (!statSync(path).isFile())
|
|
198
|
+
return false;
|
|
199
|
+
accessSync(path, fsConstants.X_OK);
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Which directory of a `PATH` value holds an executable of this name, or `undefined` for none.
|
|
208
|
+
*
|
|
209
|
+
* Split out of {@link resolvesOnPath} because {@link DAEMON_PATH_CHECK} has to report *where* a
|
|
210
|
+
* binary the daemon cannot reach actually lives on this machine, and a second walk of the same list
|
|
211
|
+
* would be a second answer to what a `PATH` resolves.
|
|
212
|
+
*/
|
|
213
|
+
function locateOnPath(command, searchPath) {
|
|
214
|
+
return searchPath
|
|
215
|
+
.split(delimiter)
|
|
216
|
+
.filter((entry) => entry !== '')
|
|
217
|
+
.find((directory) => isExecutableFile(join(directory, command)));
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Whether a launch command resolves to something executable — `PATH` lookup done in process.
|
|
221
|
+
*
|
|
222
|
+
* Deliberately not a shelled-out `which` or `command -v`: the first is one more binary to depend on
|
|
223
|
+
* and the second needs a shell, and both are a spawn where a `stat` answers the question. A command
|
|
224
|
+
* containing a separator is treated as a path, the way a shell treats one.
|
|
225
|
+
*
|
|
226
|
+
* `searchPath` defaults to this process's own `PATH`, which is what every caller but one asks.
|
|
227
|
+
* {@link DAEMON_PATH_CHECK} passes the **installed unit's** `PATH` instead, because the question it
|
|
228
|
+
* asks is about a file rather than about the shell `doctor` was typed at.
|
|
229
|
+
*/
|
|
230
|
+
function resolvesOnPath(command, searchPath = process.env['PATH'] ?? '') {
|
|
231
|
+
if (command === '')
|
|
232
|
+
return false;
|
|
233
|
+
if (command.includes('/'))
|
|
234
|
+
return isExecutableFile(resolvePath(command));
|
|
235
|
+
return locateOnPath(command, searchPath) !== undefined;
|
|
236
|
+
}
|
|
237
|
+
/** One permission list's string entries, or none when the profile does not carry it as a list. */
|
|
238
|
+
function permissionEntries(profile, list) {
|
|
239
|
+
const permissions = profile['permissions'];
|
|
240
|
+
if (!isJsonObject(permissions))
|
|
241
|
+
return [];
|
|
242
|
+
const entries = permissions[list];
|
|
243
|
+
if (!Array.isArray(entries))
|
|
244
|
+
return [];
|
|
245
|
+
return entries.filter((entry) => typeof entry === 'string');
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Every string in the profile that names a location: the three permission lists and the extra
|
|
249
|
+
* directories a run is granted.
|
|
250
|
+
*
|
|
251
|
+
* Restricted to those rather than taken over the whole document, so the rationale block's prose —
|
|
252
|
+
* which legitimately mentions paths — cannot answer a question about what the *rules* point at.
|
|
253
|
+
*/
|
|
254
|
+
function locationStrings(profile) {
|
|
255
|
+
const strings = PERMISSION_LISTS.flatMap((list) => permissionEntries(profile, list));
|
|
256
|
+
return [...strings, ...permissionEntries(profile, 'additionalDirectories')];
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* The rule wrapper a permission entry is written in — `Edit(`…`)`, `Read(`…`)`, `Bash(`…`)` — matched
|
|
260
|
+
* so the content inside it can be read as the path expression it is. An entry carrying no wrapper, as
|
|
261
|
+
* `additionalDirectories` does, is its own content.
|
|
262
|
+
*/
|
|
263
|
+
const RULE_WRAPPER = /^[A-Za-z]+\(([\s\S]*)\)$/;
|
|
264
|
+
/**
|
|
265
|
+
* The absolute path expressions inside one location string, each normalized to what it points at.
|
|
266
|
+
*
|
|
267
|
+
* Three shapes are handled at once because all three are in every generated profile: `Edit(//<abs>/**)`
|
|
268
|
+
* and its `Write`/`Read` twins, whose leading `//` is the tool syntax marking an absolute path and is
|
|
269
|
+
* collapsed here to the single slash the path itself carries; `Bash(bash <abs>/<script>:*)`, where the
|
|
270
|
+
* path is the second whitespace-separated word and `:*` is the argument wildcard rather than part of
|
|
271
|
+
* it; and a bare directory. A word not starting with `/` — `git`, `bash`, a repo-relative invocation —
|
|
272
|
+
* names no root and is dropped.
|
|
273
|
+
*/
|
|
274
|
+
function absolutePathExpressions(entry) {
|
|
275
|
+
const inner = RULE_WRAPPER.exec(entry)?.[1] ?? entry;
|
|
276
|
+
return inner
|
|
277
|
+
.split(/\s+/)
|
|
278
|
+
.filter((word) => word.startsWith('/'))
|
|
279
|
+
.map((word) => word.replace(/^\/+/, '/').replace(/:\*$/, ''));
|
|
280
|
+
}
|
|
281
|
+
/** One path segment as a matcher: `*` matches any run of non-separator characters, the rest is literal. */
|
|
282
|
+
function segmentMatcher(segment) {
|
|
283
|
+
const source = segment.replace(/[.*+?^${}()|[\]\\]/g, (character) => character === '*' ? '[^/]*' : `\\${character}`);
|
|
284
|
+
return new RegExp(`^${source}$`);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Whether a path expression covers `repoRoot` — the two compared segment by segment, with `*` treated
|
|
288
|
+
* as the wildcard it is, for as many segments as the shorter of the two has.
|
|
289
|
+
*
|
|
290
|
+
* That single rule is what makes each real case answer correctly, and it is why the comparison stops
|
|
291
|
+
* at the shorter path rather than demanding equal depth:
|
|
292
|
+
*
|
|
293
|
+
* - `<work>/<project>-*` against a sibling worktree `<work>/<project>-feature_x` — equal depth, and
|
|
294
|
+
* the wildcard segment is the whole point (`core/paths.ts`'s `worktreeGlob`, emitted unconditionally);
|
|
295
|
+
* - a wrapper-script rule under that same pattern — `<work>/<project>-<wildcard>/scripts/test.sh` —
|
|
296
|
+
* against that same root: the expression is *deeper* than the root, and a script rule inside a
|
|
297
|
+
* checkout names that checkout;
|
|
298
|
+
* - `<work>/**` against `<work>/<project>` — the expression is *shallower*, and a rule spanning the
|
|
299
|
+
* directory above covers what is under it.
|
|
300
|
+
*
|
|
301
|
+
* A checkout the profile was not generated for still fails it: `<work>/<project>` and
|
|
302
|
+
* `<work>/<project>-feature_x` differ in a literal segment, which is the moved-or-copied case the
|
|
303
|
+
* check exists to report.
|
|
304
|
+
*/
|
|
305
|
+
function expressionCoversRoot(expression, repoRoot) {
|
|
306
|
+
const expressionSegments = expression.split('/').filter((segment) => segment !== '');
|
|
307
|
+
const rootSegments = repoRoot.split('/').filter((segment) => segment !== '');
|
|
308
|
+
if (expressionSegments.length === 0 || rootSegments.length === 0)
|
|
309
|
+
return false;
|
|
310
|
+
const depth = Math.min(expressionSegments.length, rootSegments.length);
|
|
311
|
+
for (let index = 0; index < depth; index += 1) {
|
|
312
|
+
if (!segmentMatcher(expressionSegments[index]).test(rootSegments[index]))
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Whether one location string covers `repoRoot`, asking of it as the **pattern** it is.
|
|
319
|
+
*
|
|
320
|
+
* The literal-substring test is kept as the first branch: it is the cheap answer for the checkout the
|
|
321
|
+
* profile was generated at, and keeping it means the previously passing case is decided by exactly the
|
|
322
|
+
* code that used to decide it. Only when it says no is the entry parsed as a path expression.
|
|
323
|
+
*/
|
|
324
|
+
function namesRoot(entry, repoRoot) {
|
|
325
|
+
if (entry.includes(repoRoot))
|
|
326
|
+
return true;
|
|
327
|
+
return absolutePathExpressions(entry).some((expression) => expressionCoversRoot(expression, repoRoot));
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* The `deny` entries the shipped template ships every generated profile with — the floor an adopter's
|
|
331
|
+
* own profile is compared against.
|
|
332
|
+
*
|
|
333
|
+
* Read from the template rather than restated here, so there is one declaration of what the floor is:
|
|
334
|
+
* a copy in this file would be the thing that goes stale the first time an entry is added to the
|
|
335
|
+
* template, and a `doctor` that vouches for a floor by consulting its own copy of it vouches for
|
|
336
|
+
* nothing. Entries carrying an unsubstituted token are skipped — the template is read raw, and a
|
|
337
|
+
* token'd entry is not a literal any rendered profile would hold.
|
|
338
|
+
*/
|
|
339
|
+
function shippedDenyFloor() {
|
|
340
|
+
const parsed = JSON.parse(readTemplate(PROFILE_TEMPLATE_PATH));
|
|
341
|
+
if (!isJsonObject(parsed))
|
|
342
|
+
return [];
|
|
343
|
+
return permissionEntries(parsed, 'deny').filter((entry) => !entry.includes(TOKEN_MARKER));
|
|
344
|
+
}
|
|
345
|
+
/** The MCP servers the profile starts for a run, or none when it carries no such key. */
|
|
346
|
+
function serversStartedByProfile(profile) {
|
|
347
|
+
const enabled = profile[ENABLED_SERVERS_KEY];
|
|
348
|
+
if (!Array.isArray(enabled))
|
|
349
|
+
return [];
|
|
350
|
+
return enabled.filter((entry) => typeof entry === 'string');
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Resolve everything the checks read, once.
|
|
354
|
+
*
|
|
355
|
+
* Nothing here throws: a repository `doctor` cannot resolve the root of is exactly the repository it
|
|
356
|
+
* has to report on rather than crash against, and the same holds for a config or a profile that does
|
|
357
|
+
* not parse. Each failure becomes a field the check that owns that subject renders.
|
|
358
|
+
*
|
|
359
|
+
* `probeRegistry` is the caller's answer rather than this function's, and it defaults to `false`: a
|
|
360
|
+
* context built without it is the context every default run gets, and no check here reaches a network
|
|
361
|
+
* unless the command was asked to.
|
|
362
|
+
*/
|
|
363
|
+
export function buildCheckContext(cwd, probeRegistry = false) {
|
|
364
|
+
let repoRoot;
|
|
365
|
+
let repoProblem;
|
|
366
|
+
try {
|
|
367
|
+
repoRoot = resolveRepoRoot(cwd);
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
repoProblem = messageOf(error);
|
|
371
|
+
}
|
|
372
|
+
if (repoRoot === undefined)
|
|
373
|
+
return { cwd, repoProblem, configProblems: [], probeRegistry };
|
|
374
|
+
const loaded = loadConfig(repoRoot);
|
|
375
|
+
const profilePath = join(repoRoot, PROFILE_PATH);
|
|
376
|
+
let profile;
|
|
377
|
+
let profileProblem;
|
|
378
|
+
try {
|
|
379
|
+
const parsed = readJsonFile(profilePath);
|
|
380
|
+
if (parsed === undefined) {
|
|
381
|
+
profileProblem = `no ${PROFILE_PATH} at ${profilePath}: this repository has no unattended-run permission profile — run \`${CLI} init\` to generate one`;
|
|
382
|
+
}
|
|
383
|
+
else if (!isJsonObject(parsed)) {
|
|
384
|
+
profileProblem = `${profilePath} is not a JSON object, so it is not a settings file the agent runner can load`;
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
profile = parsed;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
profileProblem = messageOf(error);
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
cwd,
|
|
395
|
+
repoRoot,
|
|
396
|
+
config: loaded.config,
|
|
397
|
+
configProblems: loaded.problems,
|
|
398
|
+
configPath: loaded.path,
|
|
399
|
+
profile,
|
|
400
|
+
profileProblem,
|
|
401
|
+
profilePath,
|
|
402
|
+
probeRegistry,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
/** Is this repository inside a work tree at all — the precondition every repo-wiring check has. */
|
|
406
|
+
const GIT_CHECK = {
|
|
407
|
+
id: 'git',
|
|
408
|
+
title: 'git is available and the current directory is inside a work tree',
|
|
409
|
+
run: (ctx) => ctx.repoRoot === undefined
|
|
410
|
+
? fail(ctx.repoProblem ??
|
|
411
|
+
`the repository root of ${ctx.cwd} could not be resolved, so there is no repository to check`)
|
|
412
|
+
: pass(`git answered, and ${ctx.cwd} is inside the work tree rooted at ${ctx.repoRoot}`),
|
|
413
|
+
};
|
|
414
|
+
/**
|
|
415
|
+
* What a `jj`-managed working copy keeps beside `.git` — and the whole of the check below's probe.
|
|
416
|
+
* No `jj` binary is looked for and none is invoked.
|
|
417
|
+
*
|
|
418
|
+
* **What the directory establishes is that `jj` manages this working copy, and not that the
|
|
419
|
+
* repository is colocated.** Measured on jj 0.44.0 and recorded in `README.md`'s `## Scope and
|
|
420
|
+
* limits`, in the bullet opening *Both `jj` shapes adopt*: a **non**-colocated repository's
|
|
421
|
+
* `.jj/repo/store/git_target` holds `../../../.git`, so it too keeps a real non-bare `.git` at the
|
|
422
|
+
* working-copy root with `.jj/` beside it, and this probe answers the same on both shapes. The one
|
|
423
|
+
* clause of the warning below that is colocated-only says so in its own words.
|
|
424
|
+
*/
|
|
425
|
+
const JJ_DIR = '.jj';
|
|
426
|
+
/**
|
|
427
|
+
* A `jj`-managed repository, told the things about itself the rest of this report assumes away.
|
|
428
|
+
*
|
|
429
|
+
* **The state this exists for is a report that is green and silent about a repository shape one of
|
|
430
|
+
* its own claims is false in.** A colocated repository returned 18 pass, 4 warn, 0 fail with every
|
|
431
|
+
* warning generic: nothing said that `<githooksDir>/pre-push` is a *git* hook which `jj git push`
|
|
432
|
+
* does not run, and nothing said that the detached `HEAD` jj leaves a colocated repository in after
|
|
433
|
+
* every `jj new` is the condition that demoted `defaultBranch` detection a rung
|
|
434
|
+
* ({@link DEFAULT_BRANCH_CHECK}'s value, and `generators/harnessConfig.ts`'s rung 2). The first was
|
|
435
|
+
* measured on one repository and one hook seconds apart: `git push` fired an instrumented hook,
|
|
436
|
+
* `jj git push` did not run it at all, and the ref landed.
|
|
437
|
+
*
|
|
438
|
+
* **The other half is stated as plainly as the gap, and bounded to what was measured.** The same
|
|
439
|
+
* measurement put `jj git push -b <protected>` in front of the plugin's session-level `PreToolUse`
|
|
440
|
+
* protected-branch guard and got the identical refusal `git push origin <protected>` draws — a push
|
|
441
|
+
* whose target is **named**. The bound is that target: `jj git push` with no bookmark argument
|
|
442
|
+
* carries no target token for that guard's explicit-target arm, and its other arm — a push issued
|
|
443
|
+
* while `HEAD` is on a protected branch — cannot fire wherever jj has detached `HEAD`, which is a
|
|
444
|
+
* colocated repository's steady state, because a detached `HEAD` leaves `hc_current_branch` with no
|
|
445
|
+
* branch to name. So the named form is refused by the session guard, the argument-less form is
|
|
446
|
+
* refused by neither layer there, and a forge-side branch ruleset is the only thing that covers it.
|
|
447
|
+
* Both halves go in: a warning read as
|
|
448
|
+
* "the run is unprotected" sends an adopter hunting a hole that is not there, and one read as "the
|
|
449
|
+
* session guard covers jj" sends them past the only layer that covers the default push.
|
|
450
|
+
*
|
|
451
|
+
* **A `warn`, never a `fail`, and do not promote it** — the module header's choice 2: nothing about
|
|
452
|
+
* the flow stops, and both facts are standing properties of a repository shape rather than something
|
|
453
|
+
* an adopter got wrong. It **repairs nothing** and invents no jj-side hook mechanism, because there
|
|
454
|
+
* is none to name; what it can name is the session guard, a forge-side branch ruleset and the two
|
|
455
|
+
* incantations that settle `defaultBranch` by hand.
|
|
456
|
+
*
|
|
457
|
+
* **The one git question it asks is {@link checkedOutBranch}'s**, so the detached-`HEAD` fact is
|
|
458
|
+
* reported as present *now* rather than asserted in general — and it is that function rather than a
|
|
459
|
+
* second probe, per the module header's choice 1.
|
|
460
|
+
*
|
|
461
|
+
* **The population it grades is a `jj`-managed repository, not a colocated one**, because
|
|
462
|
+
* {@link JJ_DIR}'s probe cannot separate the two — its own comment carries that measurement — so no
|
|
463
|
+
* grade here calls a repository colocated. Clauses (1) and (2) hold on both shapes: no `jj git push`
|
|
464
|
+
* runs a git hook, whichever shape it is issued in, and the session guard judges the same command
|
|
465
|
+
* string. The rung is **colocated-only** and is worded so: a colocated repository has jj export its
|
|
466
|
+
* refs and `HEAD` to git on every command, while a non-colocated one does not and keeps git's own
|
|
467
|
+
* `HEAD`, so nothing there costs that rung.
|
|
468
|
+
*
|
|
469
|
+
* **A plain git repository passes as "not graded"**, in {@link BASE_FRESHNESS_CHECK}'s wording. That
|
|
470
|
+
* arm is what keeps `doctor` silent about jj for every adopter who does not use it.
|
|
471
|
+
*
|
|
472
|
+
* It reads **no configuration** — so it answers the same on a repository whose `harness.config.json`
|
|
473
|
+
* is broken, which is a repository an adopter is especially likely to be running `doctor` against —
|
|
474
|
+
* and it writes nothing.
|
|
475
|
+
*/
|
|
476
|
+
const JJ_REPOSITORY_CHECK = {
|
|
477
|
+
id: 'jj-repository',
|
|
478
|
+
title: 'a jj-managed repository is told what the pre-push guard does not cover',
|
|
479
|
+
run: (ctx) => {
|
|
480
|
+
if (ctx.repoRoot === undefined)
|
|
481
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
482
|
+
if (!isDirectory(join(ctx.repoRoot, JJ_DIR))) {
|
|
483
|
+
return pass(`not graded, because there is no ${JJ_DIR}/ beside .git and no jj therefore manages this working copy: neither the hook gap a \`jj git push\` opens nor the detection rung a colocated jj repository loses can arise in a plain git repository`);
|
|
484
|
+
}
|
|
485
|
+
// Said of this checkout as it stands, never of jj in general: on a colocated repository the
|
|
486
|
+
// answer flips at the next `jj new`, and an adopter reading "your HEAD is detached" about a
|
|
487
|
+
// checkout whose HEAD is on a branch would discount the whole line.
|
|
488
|
+
const head = checkedOutBranch(ctx.repoRoot);
|
|
489
|
+
const rungNow = head.kind === 'detached'
|
|
490
|
+
? 'and HEAD is detached in this checkout right now, so that condition is present as you read this'
|
|
491
|
+
: head.kind === 'branch'
|
|
492
|
+
? `and HEAD names ${head.name} in this checkout right now, so the rung can answer as things stand — on a colocated repository the next \`jj new\` is what detaches it again`
|
|
493
|
+
: 'and HEAD names no branch in this checkout at all right now, so the rung cannot answer here for that reason instead';
|
|
494
|
+
return warn(`this repository is managed by jj (there is a ${JJ_DIR}/ beside .git — which is what was read, and a colocated repository and a non-colocated one both have one), and three things follow that nothing else in this report says. (1) <githooksDir>/${PRE_PUSH_HOOK} — the path the pre-push-guard check below names in full — is a git hook: git runs it on every push git performs, whoever invoked git, and a tool that implements push against the git backend itself, as \`jj git push\` does, performs no git push and runs no git hook. Measured: one repository, one hook, seconds apart. So the committed guard covers \`git push\` and nothing else, and the caller it does not reach is a person at a terminal. (2) The session-level PreToolUse protected-branch guard the plugin installs does see \`jj git push\` and refuses one that NAMES a protected target, with the identical decision \`git push origin <protected>\` draws — also measured on \`jj git push -b <protected>\` — so that form is not exposed by (1). The bound is the named target: \`jj git push\` with no bookmark argument carries no target token for that guard's explicit-target arm, and its other arm — a push issued while HEAD is on a protected branch — cannot fire wherever jj has detached HEAD, which is a colocated repository's steady state, so there the argument-less push is refused by neither layer and a branch ruleset on the forge is the only thing that covers it. (3) On a COLOCATED repository — one where jj exports its refs and HEAD to git on every command — defaultBranch detection loses a rung: jj points git's HEAD at a raw commit id after every \`jj new\`, so the rung that reads the branch HEAD names cannot answer and the configured value came from a rung below it. A non-colocated repository exports nothing and keeps git's own HEAD, so nothing there costs that rung; which of the two shapes this is was not read — a ${JJ_DIR}/ beside .git does not say — but HEAD was, ${rungNow}. Nothing is repaired by this line and there is no jj-side hook to install: for an unattended run the session guard is the layer that fires where the push names its target and no layer fires where it does not, the only floor a person at a terminal cannot get around — and the only cover for the argument-less push — is a branch ruleset on the forge, which nothing here provisions, and where the rung cannot answer, settle the value by hand with \`${CLI} config set defaultBranch <branch>\` or \`${CLI} init --reset-config --default-branch <branch>\` rather than leaving it to detection`);
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
/**
|
|
498
|
+
* Does the configured integration line name a branch this repository actually has?
|
|
499
|
+
*
|
|
500
|
+
* Detection settles the value once, at `init` time (`generators/harnessConfig.ts`), and everything
|
|
501
|
+
* that happens to a repository afterwards can move the two apart without saying so: a branch
|
|
502
|
+
* renamed, a clone whose branches are remote-tracking refs, a hand-edited config, an unborn `HEAD`
|
|
503
|
+
* pointed elsewhere between wiring and the first commit. **A wrong value fails by skipping rather
|
|
504
|
+
* than by erroring**, which is why it is asked here at all — a branch review takes its diff base
|
|
505
|
+
* from it, and the protected-branch guards decide what an unattended run may push by matching it, so
|
|
506
|
+
* a name that resolves nowhere is a diff against a ref git cannot find and a gate that matches no
|
|
507
|
+
* branch and therefore stops nothing.
|
|
508
|
+
*
|
|
509
|
+
* The grades follow what is *knowable*, and the split is the module header's rule applied to a
|
|
510
|
+
* repository between `git init` and its first commit:
|
|
511
|
+
*
|
|
512
|
+
* - **No commit at all is a `warn`.** `HEAD` points at a branch that does not exist until something
|
|
513
|
+
* is committed to it, so no name resolves and the configured one is unverifiable rather than
|
|
514
|
+
* wrong. That is the state every repository passes through between `git init` and its first
|
|
515
|
+
* commit, and what settles it is that commit — which `init` makes in any repository it wires that
|
|
516
|
+
* has none, whether it created that repository or adopted one already there, so what reaches this
|
|
517
|
+
* branch is mostly a run whose commit git declined for want of an identity. **The message names
|
|
518
|
+
* that cause and what closes it**, not only the commit: by the time an adopter reads this, the
|
|
519
|
+
* commit is the act that has already been refused, so naming it alone would point back at the
|
|
520
|
+
* failure. The remedy is worded exactly as `commitGeneratedFiles`' decline warning words it
|
|
521
|
+
* (`commands/init.ts`), so the two commands do not spell one fix two ways, and it names re-running
|
|
522
|
+
* `init` as well as committing by hand — that commit is guarded by `dryRun || hasCommits(...)`, so
|
|
523
|
+
* a repository still short of a commit is committed into on the next ordinary run. Failing here
|
|
524
|
+
* would turn a fixable state red.
|
|
525
|
+
* - **A repository that has commits and resolves the name neither locally nor under `origin/` is a
|
|
526
|
+
* `fail`**, because both readers of the value are then reading a ref that is not there.
|
|
527
|
+
*
|
|
528
|
+
* Both resolutions pass: {@link branchResolves} answers `'remote'` for a clone that has the branch
|
|
529
|
+
* only as a remote-tracking ref, which is the ordinary shape of a checkout that has never checked
|
|
530
|
+
* it out, and a ref is what a diff base and a guard match both take.
|
|
531
|
+
*
|
|
532
|
+
* It runs git and writes nothing, so `doctor`'s "writes nothing beyond the writability probe's temp
|
|
533
|
+
* file" contract is intact.
|
|
534
|
+
*/
|
|
535
|
+
const DEFAULT_BRANCH_CHECK = {
|
|
536
|
+
id: 'default-branch',
|
|
537
|
+
title: 'the configured default branch resolves in this repository',
|
|
538
|
+
run: (ctx) => {
|
|
539
|
+
if (ctx.repoRoot === undefined)
|
|
540
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
541
|
+
if (ctx.config === undefined)
|
|
542
|
+
return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
|
|
543
|
+
// The schema makes this key required and typed, so a value that is neither has already been
|
|
544
|
+
// reported by the config check; asking git about it would only turn one finding into two.
|
|
545
|
+
const branch = ctx.config.defaultBranch;
|
|
546
|
+
if (typeof branch !== 'string' || branch.trim() === '') {
|
|
547
|
+
return unevaluated(`defaultBranch is ${JSON.stringify(branch)} rather than a branch name (see the config check)`);
|
|
548
|
+
}
|
|
549
|
+
if (!hasCommits(ctx.repoRoot)) {
|
|
550
|
+
return warn(`this repository has no commit yet, so no branch resolves in it and the configured defaultBranch ${JSON.stringify(branch)} is unverifiable rather than wrong: git's HEAD names a branch that does not exist until something is committed to it, and this check answers for real from the first commit on. init makes that commit in any repository it wires that has none — whether it created the repository or found one already there — so reaching this warning means the commit was declined or never attempted: most often a machine with no commit identity, or a repository wired by an earlier release. Set an identity with \`git config user.email "you@example.com"\` and \`git config user.name "Your Name"\`, then re-run \`${CLI} init\`, which makes the commit on the next ordinary run because the repository still has none — or commit by hand with \`git add -A && git commit\``);
|
|
551
|
+
}
|
|
552
|
+
const resolution = branchResolves(ctx.repoRoot, branch);
|
|
553
|
+
if (resolution === 'local') {
|
|
554
|
+
return pass(`defaultBranch is ${JSON.stringify(branch)}, and this repository has a local branch by that name`);
|
|
555
|
+
}
|
|
556
|
+
if (resolution === 'remote') {
|
|
557
|
+
return pass(`defaultBranch is ${JSON.stringify(branch)}, which resolves in this checkout as origin/${branch} rather than as a local branch — the ordinary shape of a clone that has never checked it out, and a ref is what a branch review's diff base and a protected-branch match both take`);
|
|
558
|
+
}
|
|
559
|
+
return fail(`defaultBranch is ${JSON.stringify(branch)}, and this repository has neither a local branch nor an origin/ remote-tracking ref by that name: a branch review takes its diff base from this value and the protected-branch guards decide what an unattended run may push by matching against it, so neither works — the diff is against a ref git cannot find, and the guard matches no branch and therefore stops nothing. Run \`git branch -a\` to see what this repository has, then \`${CLI} config set defaultBranch <branch>\``);
|
|
560
|
+
},
|
|
561
|
+
};
|
|
562
|
+
/**
|
|
563
|
+
* Can a run be *started* here at all — is there a remote, and does the ref every run's checkout is
|
|
564
|
+
* branched from exist?
|
|
565
|
+
*
|
|
566
|
+
* **The question nothing else asks.** `default-branch` above resolves the configured name and is
|
|
567
|
+
* satisfied by a purely local branch; `worktrees` below asks whether git can list checkouts. Neither
|
|
568
|
+
* touches the remote, so a repository with no `origin` reported clean while no run could begin in
|
|
569
|
+
* it — the state the adoption rehearsal found, where the first drop archived within seconds on
|
|
570
|
+
* `fatal: 'origin' does not appear to be a git repository`.
|
|
571
|
+
*
|
|
572
|
+
* The precondition is `create-worktree.sh`'s, and it is two steps rather than one: the new-branch
|
|
573
|
+
* arm fetches `origin/<defaultBranch>` and then creates the worktree **from that ref**, and pushes
|
|
574
|
+
* the new branch to `origin` afterwards. That script refuses both bad states itself, before it
|
|
575
|
+
* creates anything (`exit 2`, naming the remedy and this check), so a repository missing one gets no
|
|
576
|
+
* worktree and nothing downstream — no phase, no agent, no commit — runs. This check is what says so
|
|
577
|
+
* before a prompt is dropped rather than in the run log of the drop that failed.
|
|
578
|
+
*
|
|
579
|
+
* Both bad states are a `fail`, for the reason choice 2 in the module header spells out: this is the
|
|
580
|
+
* one hand-remediable condition that is a precondition for a run to begin, which is what separates
|
|
581
|
+
* it from `notifications` and `repo-registry`. The second state is the one worth having a check for
|
|
582
|
+
* — a remote is configured, so `git remote -v` reassures, and the ref the script actually names is
|
|
583
|
+
* still not there.
|
|
584
|
+
*
|
|
585
|
+
* It asks {@link remoteTrackingBranchResolves} rather than {@link branchResolves}: the latter stops
|
|
586
|
+
* at the local spelling, which the checkout of an integration line always has, so it would answer
|
|
587
|
+
* `'local'` for exactly the repository this check has to fail.
|
|
588
|
+
*
|
|
589
|
+
* It runs git and writes nothing — no fetch, no network call — so `doctor`'s "writes nothing beyond
|
|
590
|
+
* the writability probe's temp file" contract is intact and the answer is the same offline.
|
|
591
|
+
*/
|
|
592
|
+
const REMOTE_CHECK = {
|
|
593
|
+
id: 'remote',
|
|
594
|
+
title: 'the flow can create a working copy from origin/<defaultBranch>',
|
|
595
|
+
run: (ctx) => {
|
|
596
|
+
if (ctx.repoRoot === undefined)
|
|
597
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
598
|
+
if (ctx.config === undefined)
|
|
599
|
+
return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
|
|
600
|
+
const branch = ctx.config.defaultBranch;
|
|
601
|
+
if (typeof branch !== 'string' || branch.trim() === '') {
|
|
602
|
+
return unevaluated(`defaultBranch is ${JSON.stringify(branch)} rather than a branch name (see the config check)`);
|
|
603
|
+
}
|
|
604
|
+
const remotes = configuredRemotes(ctx.repoRoot);
|
|
605
|
+
if (remotes.length === 0) {
|
|
606
|
+
return fail(`this repository has no remote configured, so no run can be started in it: ${WORKTREE_SCRIPT} fetches origin/${branch} and creates every run's checkout from that ref, and with no origin it refuses before a worktree exists — the flow stops before any phase begins. Add one and push the branch (\`git remote add origin <url> && git push -u origin ${branch}\`), or run the flow in a checkout that already has a remote`);
|
|
607
|
+
}
|
|
608
|
+
if (!remoteTrackingBranchResolves(ctx.repoRoot, branch)) {
|
|
609
|
+
return fail(`this repository has a remote (${nameList(remotes)}) but no origin/${branch} remote-tracking ref, so no run can be started in it: ${WORKTREE_SCRIPT} creates every run's checkout from origin/${branch}, and a local branch of that name does not satisfy it. Push the branch with \`git push -u origin ${branch}\`, or fetch it with \`git fetch origin ${branch}\` if it is already on the remote — and if the remote here is not called origin, add one that is`);
|
|
610
|
+
}
|
|
611
|
+
return pass(`origin is configured (${nameList(remotes)}) and origin/${branch} resolves as a remote-tracking ref, which is what ${WORKTREE_SCRIPT} branches a run's checkout from`);
|
|
612
|
+
},
|
|
613
|
+
};
|
|
614
|
+
/**
|
|
615
|
+
* Is the ref a run's working copy would be cut from **current** — or has this checkout got commits
|
|
616
|
+
* on the integration line that were never pushed?
|
|
617
|
+
*
|
|
618
|
+
* **The question `remote` above cannot answer.** That one asks whether `origin/<defaultBranch>`
|
|
619
|
+
* exists at all; a ref that exists and is behind the local branch satisfies it completely. So the
|
|
620
|
+
* repository this reports on is one where every other line is green: `create-worktree.sh` branches
|
|
621
|
+
* every run's working copy from `origin/<defaultBranch>`, and a run started there gets a base
|
|
622
|
+
* missing whatever was not pushed — commonly the harness wiring itself, which is discovered only
|
|
623
|
+
* after a planning phase has been paid for and the run has parked.
|
|
624
|
+
*
|
|
625
|
+
* **A `warn`, never a `fail`, and do not promote it.** The run does start and parks cleanly, so
|
|
626
|
+
* this is not a precondition in the sense the module header's choice 2 reserves `fail` for — and a
|
|
627
|
+
* check that failed a repository whose remote is a few commits behind is a check nobody keeps green.
|
|
628
|
+
*
|
|
629
|
+
* Two remedies, because there are two states to be in when it is read: `git push origin <branch>`
|
|
630
|
+
* in the checkout that holds the commits, which is the fix at the source, and — for a working copy
|
|
631
|
+
* that already exists and is already stale — `refresh-branch.sh --local`, which is the one refresh
|
|
632
|
+
* route the protected-branch guard permits (that script's own header states why).
|
|
633
|
+
*
|
|
634
|
+
* **Two states are not findings and pass as "not graded"**, following {@link DAEMON_PATH_CHECK}'s
|
|
635
|
+
* shape: each is another line's to report, and reporting it twice makes one finding two.
|
|
636
|
+
*
|
|
637
|
+
* - **No `origin/<branch>`** — `remote`, directly above, already fails over exactly that.
|
|
638
|
+
* - **No local `<branch>`** — the repository between `git init` and its first commit, and the clone
|
|
639
|
+
* that has the branch only as a remote-tracking ref. Neither holds anything `origin/<branch>` is
|
|
640
|
+
* missing, and which of the two it is, is `default-branch`'s line to say. Grading either a
|
|
641
|
+
* failure would turn a repository red that both of those checks deliberately do not.
|
|
642
|
+
*
|
|
643
|
+
* It reads refs and counts commits, writes nothing, and issues **no fetch**, so `doctor`'s "writes
|
|
644
|
+
* nothing beyond the writability probe's temp file" contract is intact and the answer is the same
|
|
645
|
+
* offline — which also means it compares against the remote as this checkout last saw it, and says
|
|
646
|
+
* so rather than making it current.
|
|
647
|
+
*/
|
|
648
|
+
const BASE_FRESHNESS_CHECK = {
|
|
649
|
+
id: 'base-freshness',
|
|
650
|
+
title: "the ref a run's working copy is branched from is not behind this checkout",
|
|
651
|
+
run: (ctx) => {
|
|
652
|
+
if (ctx.repoRoot === undefined)
|
|
653
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
654
|
+
if (ctx.config === undefined)
|
|
655
|
+
return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
|
|
656
|
+
const branch = ctx.config.defaultBranch;
|
|
657
|
+
if (typeof branch !== 'string' || branch.trim() === '') {
|
|
658
|
+
return unevaluated(`defaultBranch is ${JSON.stringify(branch)} rather than a branch name (see the config check)`);
|
|
659
|
+
}
|
|
660
|
+
if (!remoteTrackingBranchResolves(ctx.repoRoot, branch)) {
|
|
661
|
+
return pass(`not graded, because this repository has no origin/${branch} for a working copy to be cut from: that this is so is the remote check's line, directly above, and answering it twice would make one finding two`);
|
|
662
|
+
}
|
|
663
|
+
// `branchResolves` answers `'local'` first, so anything else here means `refs/heads/<branch>` is
|
|
664
|
+
// absent — the only two ways to reach that point are named in the header, and `origin/<branch>`
|
|
665
|
+
// is known to resolve by the branch above.
|
|
666
|
+
if (branchResolves(ctx.repoRoot, branch) !== 'local') {
|
|
667
|
+
return pass(`not graded, because this checkout has no local ${branch} — a repository between git init and its first commit, or a clone that has the branch only as a remote-tracking ref — so it holds nothing origin/${branch} is missing; which of the two it is, is the default-branch line's to say`);
|
|
668
|
+
}
|
|
669
|
+
const ahead = commitsAhead(ctx.repoRoot, `origin/${branch}`, branch);
|
|
670
|
+
if (ahead === undefined) {
|
|
671
|
+
return warn(`origin/${branch} and ${branch} both resolve here and git did not count the commits between them, so whether a run started now would be cut from a stale base is unknown: run \`git rev-list --count origin/${branch}..${branch}\` yourself before dropping one`);
|
|
672
|
+
}
|
|
673
|
+
if (ahead > 0) {
|
|
674
|
+
return warn(`${branch} is ${ahead} commit${ahead === 1 ? '' : 's'} ahead of origin/${branch}, so a run started now gets a stale base: ${WORKTREE_SCRIPT} branches every run's working copy from origin/${branch}, and nothing in those commits would be in it — which is how a whole planning phase gets paid for before anything discovers what is missing from the worktree. Push them from this checkout with \`git push origin ${branch}\`; a working copy that already exists catches up with \`${scriptInvocation(outerLoopScriptsDir(ctx.config), REFRESH_SCRIPT)} --local\`, the one refresh route the protected-branch guard permits. A warning rather than a failure because the run does start and parks cleanly`);
|
|
675
|
+
}
|
|
676
|
+
return pass(`origin/${branch} contains every commit ${branch} has, as this checkout last saw the remote, so a working copy ${WORKTREE_SCRIPT} cuts from origin/${branch} carries everything this checkout has of the integration line`);
|
|
677
|
+
},
|
|
678
|
+
};
|
|
679
|
+
/**
|
|
680
|
+
* Does the guard **on disk** refuse pushes to the branches the configuration names?
|
|
681
|
+
*
|
|
682
|
+
* **The question the three above cannot answer.** They grade the configured value; this grades the
|
|
683
|
+
* file that enforces it. The hook is `create-if-absent` (`generators/githooks.ts`), so it carries
|
|
684
|
+
* the set that was substituted into its `case` label when `init` wrote it, while every documented
|
|
685
|
+
* way of correcting `defaultBranch` afterwards — `init --reset-config --default-branch <name>`,
|
|
686
|
+
* `config set defaultBranch <name>`, an edit to `harness.config.json` — changes only the config. The
|
|
687
|
+
* measured state is a repository reporting 18 pass, 4 warn, 0 fail whose hook **allowed** a push to
|
|
688
|
+
* the integration line and **refused** one to an ordinary feature branch, because nothing anywhere
|
|
689
|
+
* compared the rendered list to the value it was rendered from. This is that comparison, and it is
|
|
690
|
+
* the one route-independent form of it: a hand edit to the hook reaches it too.
|
|
691
|
+
*
|
|
692
|
+
* **Every arm is a `pass` or a `warn`, never a `fail`, and do not promote it.** The flow runs — what
|
|
693
|
+
* is wrong is which branch is guarded — and the true floor is a rule enforced by the host the
|
|
694
|
+
* repository is pushed to (the hook's own header), which a repository that has one is not stopped by.
|
|
695
|
+
*
|
|
696
|
+
* Both sides of the comparison are the generator's, per the module header's choice 1:
|
|
697
|
+
* {@link resolveProtectedBranches} for the configured set — `protectedBranches` ∪ `defaultBranch` —
|
|
698
|
+
* {@link resolveGithooksDir} with {@link PRE_PUSH_HOOK} for where the file is, and
|
|
699
|
+
* {@link readProtectedCaseLabel} for what it enforces. The sink passed to the first is a **no-op**:
|
|
700
|
+
* an empty `protectedBranches` is the writing run's warning to give, and repeating it here would make
|
|
701
|
+
* one finding two. {@link resolveGithooksDir} throws on a `githooksDir` naming the repository root,
|
|
702
|
+
* which {@link runChecks} reports as this check's own failure — the handling that function documents.
|
|
703
|
+
*
|
|
704
|
+
* **What it deliberately does not grade:** whether `core.hooksPath` points at the directory the hook
|
|
705
|
+
* is in. A hook that is present and unreached is `pointHooksPath`'s warning at `init` time
|
|
706
|
+
* (`generators/githooks.ts`), and saying it again here would report one fault twice.
|
|
707
|
+
*
|
|
708
|
+
* The remedy names `init --force` and the delete-and-re-run route, and **never `--reset-config`**:
|
|
709
|
+
* that rebuilds the whole config from detection and the flags, so an adopter clearing a divergence
|
|
710
|
+
* by following it loses every hand-set value.
|
|
711
|
+
*
|
|
712
|
+
* It reads one file and writes nothing.
|
|
713
|
+
*/
|
|
714
|
+
const PRE_PUSH_GUARD_CHECK = {
|
|
715
|
+
id: 'pre-push-guard',
|
|
716
|
+
title: 'the rendered pre-push guard protects the branches the configuration names',
|
|
717
|
+
run: (ctx) => {
|
|
718
|
+
if (ctx.repoRoot === undefined)
|
|
719
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
720
|
+
if (ctx.config === undefined)
|
|
721
|
+
return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
|
|
722
|
+
const branch = ctx.config.defaultBranch;
|
|
723
|
+
if (typeof branch !== 'string' || branch.trim() === '') {
|
|
724
|
+
return unevaluated(`defaultBranch is ${JSON.stringify(branch)} rather than a branch name (see the config check)`);
|
|
725
|
+
}
|
|
726
|
+
const configured = resolveProtectedBranches(ctx.config, () => { });
|
|
727
|
+
const hookPath = `${resolveGithooksDir(ctx.config)}/${PRE_PUSH_HOOK}`;
|
|
728
|
+
let hookText;
|
|
729
|
+
try {
|
|
730
|
+
hookText = readFileSync(join(ctx.repoRoot, hookPath), 'utf8');
|
|
731
|
+
}
|
|
732
|
+
catch {
|
|
733
|
+
return warn(`there is no readable ${hookPath}, so nothing in this checkout judges a push against the protected set ${CONFIG_FILENAME} names (${nameList(configured)}): the guard the configuration describes is not there to refuse one. Re-run \`${CLI} init\`, which writes the hook because it is create-if-absent — no --force is needed while the file is absent`);
|
|
734
|
+
}
|
|
735
|
+
const label = readProtectedCaseLabel(hookText);
|
|
736
|
+
if (label === undefined) {
|
|
737
|
+
return warn(`${hookPath} is there but carries no case label in the generated shape, so what it protects cannot be read out of it and cannot be compared with the set the configuration resolves (${nameList(configured)}): this hook was edited, or was not written by this CLI. Read the file to see what it refuses, or delete it and re-run \`${CLI} init\`, which re-renders it from the config because the hook is create-if-absent`);
|
|
738
|
+
}
|
|
739
|
+
if (caseLabelMatches(label, configured)) {
|
|
740
|
+
return pass(`${hookPath} refuses a push whose target branch is ${nameList(label)}, which is the set ${CONFIG_FILENAME} resolves — protectedBranches unioned with defaultBranch`);
|
|
741
|
+
}
|
|
742
|
+
return warn(`${hookPath} refuses a push to ${nameList(label)}, and the configuration names ${nameList(configured)} (protectedBranches unioned with defaultBranch): git runs the file, so a push is judged by the hook's set and not by the config — a branch in one set and not the other is either pushed to unguarded or refused for no configured reason, and the two can be inverted entirely. Re-render with \`${CLI} init --force\`, which copies ${hookPath} to ${hookPath}.bak and re-renders it from the config in effect; without --force, delete ${hookPath} and re-run \`${CLI} init\``);
|
|
743
|
+
},
|
|
744
|
+
};
|
|
745
|
+
/**
|
|
746
|
+
* Does every branch `protectedBranches` **lists** still exist in this repository?
|
|
747
|
+
*
|
|
748
|
+
* **The state this exists for is a guard permanently protecting a branch nobody will ever push to
|
|
749
|
+
* again.** `config set defaultBranch <new>` changes one key of a pair the guards read together, so
|
|
750
|
+
* the list keeps the abandoned name; {@link resolveProtectedBranches} unions the two, and the next
|
|
751
|
+
* `init --force` renders `feat_add_search|trunk` into the hook while the wrappers resolve the same
|
|
752
|
+
* set. Nothing ages the entry out, and nothing said it was there.
|
|
753
|
+
*
|
|
754
|
+
* **The decision, stated here so it is not re-litigated: a stale entry is reported, never removed.**
|
|
755
|
+
* The union stays — it is what keeps the hook and the wrappers from disagreeing in the *unsafe*
|
|
756
|
+
* direction (that function's own header) — and narrowing a protected set automatically is the one
|
|
757
|
+
* direction that fails unsafely: a tool that dropped an entry it could not resolve would unprotect a
|
|
758
|
+
* branch on the strength of a ref lookup. So this names what it found and names the edit; the
|
|
759
|
+
* adopter makes it.
|
|
760
|
+
*
|
|
761
|
+
* **A `warn`, never a `fail`, and do not promote it.** Every guard still refuses more than it needs
|
|
762
|
+
* to, which stops nothing — and an entry naming a branch that does not exist *yet* is a legitimate
|
|
763
|
+
* thing to have configured.
|
|
764
|
+
*
|
|
765
|
+
* Three states are deliberately **not graded**, each because grading it would make one finding two
|
|
766
|
+
* or nag about a supported use:
|
|
767
|
+
*
|
|
768
|
+
* - **A repository with no commit** — `pass`, in {@link BASE_FRESHNESS_CHECK}'s wording: no name
|
|
769
|
+
* resolves there, so nothing here is answerable, and which state that is, is `default-branch`'s
|
|
770
|
+
* line to say.
|
|
771
|
+
* - **A glob entry** — skipped, on {@link FORBIDDEN_IN_PATTERN}'s own sentence: a `case` label is a
|
|
772
|
+
* glob, and a glob entry is what `protectedBranches` documents as the way to protect a whole
|
|
773
|
+
* namespace with one entry. The character set is {@link isGlobPattern}'s rather than a second one.
|
|
774
|
+
* - **An entry equal to `defaultBranch`** — skipped, because that value's resolution is
|
|
775
|
+
* `default-branch`'s line, four checks above, and answering it twice would make one finding two.
|
|
776
|
+
*
|
|
777
|
+
* It reads refs through {@link branchResolves} and writes nothing, so `doctor`'s "writes nothing
|
|
778
|
+
* beyond the writability probe's temp file" contract is intact and the answer is the same offline.
|
|
779
|
+
*/
|
|
780
|
+
const PROTECTED_SET_CHECK = {
|
|
781
|
+
id: 'protected-set',
|
|
782
|
+
title: 'every protectedBranches entry names a branch this repository has',
|
|
783
|
+
run: (ctx) => {
|
|
784
|
+
if (ctx.repoRoot === undefined)
|
|
785
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
786
|
+
if (ctx.config === undefined)
|
|
787
|
+
return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
|
|
788
|
+
const branch = ctx.config.defaultBranch;
|
|
789
|
+
if (typeof branch !== 'string' || branch.trim() === '') {
|
|
790
|
+
return unevaluated(`defaultBranch is ${JSON.stringify(branch)} rather than a branch name (see the config check)`);
|
|
791
|
+
}
|
|
792
|
+
// The listed key alone, never the resolved union: the union's other member is `defaultBranch`,
|
|
793
|
+
// and that member is `default-branch`'s to grade. An absent key takes its schema default, which
|
|
794
|
+
// is the value the guards are rendered from (`generators/githooks.ts` → `resolveProtectedBranches`),
|
|
795
|
+
// so the fallback is graded — but whether it answered is carried beside it, because a sentence
|
|
796
|
+
// saying the file "lists" an entry it does not carry sends an adopter looking for a line that is
|
|
797
|
+
// not there. `config set`'s own sibling clause (`commands/config.ts` → `siblingKeyState`) makes
|
|
798
|
+
// the same distinction about the same key.
|
|
799
|
+
const keyPresent = ctx.config.protectedBranches !== undefined;
|
|
800
|
+
const configured = ctx.config.protectedBranches ?? DEFAULTS.protectedBranches;
|
|
801
|
+
if (!Array.isArray(configured) || configured.some((entry) => typeof entry !== 'string')) {
|
|
802
|
+
return unevaluated(`protectedBranches is ${JSON.stringify(ctx.config.protectedBranches)} rather than a list of branch patterns (see the config check)`);
|
|
803
|
+
}
|
|
804
|
+
if (!hasCommits(ctx.repoRoot)) {
|
|
805
|
+
return pass(`not graded, because this repository has no commit yet and so resolves no branch at all: that this is so is the default-branch check's line, four above, and answering it twice would make one finding two`);
|
|
806
|
+
}
|
|
807
|
+
// Trimmed and de-duplicated as `resolveProtectedBranches` trims and de-duplicates them, so this
|
|
808
|
+
// grades the entries the guards are actually rendered from.
|
|
809
|
+
const defaultName = branch.trim();
|
|
810
|
+
const listed = [...new Set(configured.map((entry) => entry.trim()).filter((entry) => entry !== ''))];
|
|
811
|
+
const globs = listed.filter((entry) => isGlobPattern(entry));
|
|
812
|
+
const graded = listed.filter((entry) => !isGlobPattern(entry) && entry !== defaultName);
|
|
813
|
+
// Said wherever the grade lands, so a reader is never left wondering why an entry they can see
|
|
814
|
+
// in the file is absent from the line.
|
|
815
|
+
const skipped = [
|
|
816
|
+
globs.length === 0
|
|
817
|
+
? ''
|
|
818
|
+
: `${nameList(globs)} ${globs.length === 1 ? 'is a glob' : 'are globs'}, which name a namespace rather than one branch to look up`,
|
|
819
|
+
listed.includes(defaultName)
|
|
820
|
+
? `${defaultName} is defaultBranch, whose own resolution is the default-branch check's line`
|
|
821
|
+
: '',
|
|
822
|
+
].filter((note) => note !== '');
|
|
823
|
+
const skippedNote = skipped.length === 0 ? '' : `; not graded here: ${skipped.join('; ')}`;
|
|
824
|
+
// The one verb every arm below names the key with, so each says which of the two states it
|
|
825
|
+
// graded rather than describing the file as carrying a line only the schema does.
|
|
826
|
+
const listsVerb = keyPresent
|
|
827
|
+
? 'protectedBranches lists'
|
|
828
|
+
: 'protectedBranches is not set, so it stands at its schema default';
|
|
829
|
+
const defaultNote = keyPresent ? '' : '; protectedBranches is not set, so what is graded is its schema default';
|
|
830
|
+
if (listed.length === 0) {
|
|
831
|
+
// Protecting only `defaultBranch` is what an empty list resolves to, and warning about it is
|
|
832
|
+
// the writing run's line (`resolveProtectedBranches`), not this one's.
|
|
833
|
+
return pass(keyPresent
|
|
834
|
+
? `protectedBranches lists no branch, so it names none this repository could be missing`
|
|
835
|
+
: `protectedBranches is not set and its schema default lists no branch, so it names none this repository could be missing`);
|
|
836
|
+
}
|
|
837
|
+
if (graded.length === 0) {
|
|
838
|
+
return pass(`${listsVerb} ${nameList(listed)}, and no entry of it is a branch name for this check to look up${skippedNote}`);
|
|
839
|
+
}
|
|
840
|
+
const repoRoot = ctx.repoRoot;
|
|
841
|
+
const missing = graded.filter((entry) => branchResolves(repoRoot, entry) === 'none');
|
|
842
|
+
if (missing.length === 0) {
|
|
843
|
+
return pass(`every protectedBranches entry this check grades resolves in this repository as a local branch or an origin/ remote-tracking ref (${nameList(graded)})${defaultNote}${skippedNote}`);
|
|
844
|
+
}
|
|
845
|
+
// How the state was arrived at, and what the edit is called, both turn on whether the key
|
|
846
|
+
// answered: an absent one was never edited, so nothing about it was left behind by a `config set`
|
|
847
|
+
// and there is no entry to remove — the schema default simply outlived the integration line.
|
|
848
|
+
const origin = keyPresent
|
|
849
|
+
? `commonly a feature branch that was merged and deleted after \`config set defaultBranch\` moved the integration line, which changes only that one key. Nothing removed ${missing.length === 1 ? 'it' : 'them'} automatically and nothing will`
|
|
850
|
+
: `commonly a repository whose integration line was never the schema default: \`config set defaultBranch\` changes only that one key, and an unset protectedBranches goes on standing at its default. Nothing narrowed that default automatically and nothing will`;
|
|
851
|
+
return warn(`${listsVerb} ${nameList(missing)}, which ${missing.length === 1 ? 'names a branch' : 'name branches'} this repository has neither locally nor under origin/: the effective protected set is ${keyPresent ? 'this list' : 'that default'} unioned with defaultBranch (${JSON.stringify(branch)}), so ${missing.length === 1 ? 'that entry' : 'those entries'} stay in the case label of every hook the next \`${CLI} init --force\` renders and in the set the git wrappers resolve on every call — ${origin}: narrowing a protected set is the one direction that fails unsafely, so the edit is yours — \`${CLI} config set protectedBranches '<json>'\` ${keyPresent ? '' : 'sets the key '}with the entries you want, then \`${CLI} init --force\` to re-render the guard from it. A warning rather than a failure because an over-wide set stops nothing, and a branch that does not exist yet is a legitimate thing to have ${keyPresent ? 'listed' : 'protected'}${skippedNote}`);
|
|
852
|
+
},
|
|
853
|
+
};
|
|
854
|
+
/**
|
|
855
|
+
* `jq`, and the version floor its two consumers share.
|
|
856
|
+
*
|
|
857
|
+
* **This is the one prerequisite that fails silently on both sides of it**, which is why `doctor`
|
|
858
|
+
* asks about it at all. An *absent* `jq` is a `fail`: the plugin's guards and the generated
|
|
859
|
+
* outer-loop scripts both reach their configuration only through it, so with none on `PATH` neither
|
|
860
|
+
* subsystem can work — and the fix is a one-line install. A `jq` *older than 1.5* is also a `fail`,
|
|
861
|
+
* and it is the condition worth having a check for: both configuration loaders
|
|
862
|
+
* (`plugin/hooks/lib/harness-config-lib.sh`, and the generated `lib/harness-run-lib.sh`) use jq 1.5
|
|
863
|
+
* constructs, so an older `jq` makes the program a **compile** error rather than a missing binary —
|
|
864
|
+
* every read fails, both subsystems take their unresolvable-configuration path, and the diagnostic an
|
|
865
|
+
* operator reaches for first, `command -v jq`, succeeds throughout.
|
|
866
|
+
*
|
|
867
|
+
* The probe follows `daemon/backend.ts`'s discipline: `execFileSync` with a fixed argument vector,
|
|
868
|
+
* never a shell string, bounded by a timeout. Existence is answered by {@link resolvesOnPath} rather
|
|
869
|
+
* than by the spawn, so "not installed" and "installed but did not answer" are two findings instead
|
|
870
|
+
* of one, and the second of them is a `warn` — as is a version string this check cannot parse. Both
|
|
871
|
+
* mean `jq` is *there*, which is the state a harness may well work in, and failing a repository over
|
|
872
|
+
* an unrecognised string would be a check nobody keeps green.
|
|
873
|
+
*
|
|
874
|
+
* It reads no configuration and touches no repository: the floor is a property of the machine, and
|
|
875
|
+
* the check answers identically in a repository whose config is broken or absent.
|
|
876
|
+
*/
|
|
877
|
+
const JQ_CHECK = {
|
|
878
|
+
id: 'jq',
|
|
879
|
+
title: `jq is on PATH and is ${JQ_MIN_MAJOR}.${JQ_MIN_MINOR} or newer`,
|
|
880
|
+
run: () => {
|
|
881
|
+
if (!resolvesOnPath('jq')) {
|
|
882
|
+
return fail(`jq does not resolve on PATH: the plugin's guards and the generated outer-loop scripts both read harness.config.json through it, so every guard exits 0 silently and every script takes its closed path — install jq ${JQ_MIN_MAJOR}.${JQ_MIN_MINOR} or newer`);
|
|
883
|
+
}
|
|
884
|
+
let output;
|
|
885
|
+
try {
|
|
886
|
+
output = execFileSync('jq', ['--version'], {
|
|
887
|
+
encoding: 'utf8',
|
|
888
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
889
|
+
timeout: JQ_PROBE_TIMEOUT_MS,
|
|
890
|
+
windowsHide: true,
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
catch (error) {
|
|
894
|
+
return warn(`jq resolves on PATH but \`jq --version\` did not answer (${messageOf(error)}), so the ${JQ_MIN_MAJOR}.${JQ_MIN_MINOR} floor could not be checked here — run \`jq --version\` yourself before trusting the guards' and the scripts' configuration reads`);
|
|
895
|
+
}
|
|
896
|
+
const printed = firstLine(output);
|
|
897
|
+
const parsed = JQ_VERSION_PATTERN.exec(printed);
|
|
898
|
+
if (parsed === null) {
|
|
899
|
+
return warn(`jq resolves on PATH and \`jq --version\` printed ${JSON.stringify(printed)}, which carries no version number this check recognises, so the ${JQ_MIN_MAJOR}.${JQ_MIN_MINOR} floor could not be checked: a warning rather than a failure because jq is present and this harness may well work — compare that string against ${JQ_MIN_MAJOR}.${JQ_MIN_MINOR} by hand`);
|
|
900
|
+
}
|
|
901
|
+
const major = Number(parsed[1]);
|
|
902
|
+
const minor = Number(parsed[2]);
|
|
903
|
+
const version = `${major}.${minor}`;
|
|
904
|
+
if (major < JQ_MIN_MAJOR || (major === JQ_MIN_MAJOR && minor < JQ_MIN_MINOR)) {
|
|
905
|
+
return fail(`jq on PATH is ${version} (\`jq --version\` printed ${JSON.stringify(printed)}), older than the ${JQ_MIN_MAJOR}.${JQ_MIN_MINOR} floor: both configuration loaders — the plugin's hooks/lib/harness-config-lib.sh and the generated scripts' lib/harness-run-lib.sh — use jq 1.5 constructs (input/inputs, @tsv, try…catch, error(), and the def s($k; $v) value-parameter form), so on this jq the load program is a compile error and every configuration read fails. Both subsystems treat that as an unresolvable configuration rather than as an error, so it does not announce itself: an ordinary push to a non-protected branch is denied, the commit guard asks on every commit, the remaining guards go silent, the scripts take their closed path — and \`command -v jq\` succeeds throughout. Install jq ${JQ_MIN_MAJOR}.${JQ_MIN_MINOR} or newer`);
|
|
906
|
+
}
|
|
907
|
+
return pass(`jq ${version} is on PATH (\`jq --version\` printed ${JSON.stringify(printed)}), at or above the ${JQ_MIN_MAJOR}.${JQ_MIN_MINOR} floor the plugin's guards and the generated outer-loop scripts both need to resolve harness.config.json`);
|
|
908
|
+
},
|
|
909
|
+
};
|
|
910
|
+
/**
|
|
911
|
+
* Can worktree-based runs be prepared here?
|
|
912
|
+
*
|
|
913
|
+
* A `warn` rather than a `fail`: the flow runs in a single checkout, and an adopter whose git cannot
|
|
914
|
+
* list worktrees loses the isolation a parallel run wants rather than the ability to run at all.
|
|
915
|
+
*/
|
|
916
|
+
const WORKTREE_CHECK = {
|
|
917
|
+
id: 'worktrees',
|
|
918
|
+
title: 'git worktree list answers, so worktree-based runs are possible',
|
|
919
|
+
run: (ctx) => {
|
|
920
|
+
if (ctx.repoRoot === undefined)
|
|
921
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
922
|
+
const checkouts = worktreeList(ctx.repoRoot);
|
|
923
|
+
if (checkouts === undefined) {
|
|
924
|
+
return warn(`git worktree list did not answer in ${ctx.repoRoot}, so a run cannot be given a checkout of its own: the flow still runs in this single working copy, and two runs then share it`);
|
|
925
|
+
}
|
|
926
|
+
return pass(`git worktree list answers with ${checkouts.length} checkout${checkouts.length === 1 ? '' : 's'}, so a run can be given one of its own`);
|
|
927
|
+
},
|
|
928
|
+
};
|
|
929
|
+
/** Which service manager the run daemon can be installed into — `daemon/backend.ts`'s answer. */
|
|
930
|
+
const DAEMON_BACKEND_CHECK = {
|
|
931
|
+
id: 'daemon-backend',
|
|
932
|
+
title: 'this host has a service manager the run daemon can be installed into',
|
|
933
|
+
run: () => {
|
|
934
|
+
const backend = detectBackend();
|
|
935
|
+
if (backend.kind === 'none')
|
|
936
|
+
return warn(`${backend.reason}, so \`daemon install\` will refuse on this host`);
|
|
937
|
+
const where = backend.unitPath === undefined ? '' : `, and a user unit is installed into ${backend.unitPath}`;
|
|
938
|
+
return pass(`${backend.kind}: ${backend.reason}${where}`);
|
|
939
|
+
},
|
|
940
|
+
};
|
|
941
|
+
/**
|
|
942
|
+
* Is the script the daemon runs there?
|
|
943
|
+
*
|
|
944
|
+
* **A warning, never a failure**, and the reason has survived the watcher moving into the repository:
|
|
945
|
+
* `init` writes it into the configured `scriptsDir`, so an absence now means it was deleted or
|
|
946
|
+
* `scriptsDir` was changed without a re-run. Both are one `init` away, and neither is a reason for a
|
|
947
|
+
* repository that is otherwise wired to exit non-zero — the daemon is opt-in, and a run in the
|
|
948
|
+
* foreground never touches this file.
|
|
949
|
+
*
|
|
950
|
+
* The path is the same one `daemon install` will refuse over, because both come from
|
|
951
|
+
* `daemon/backend.ts` and neither joins it itself.
|
|
952
|
+
*/
|
|
953
|
+
const WATCHER_CHECK = {
|
|
954
|
+
id: 'run-watcher',
|
|
955
|
+
title: 'the run watcher the daemon executes is present',
|
|
956
|
+
run: (ctx) => {
|
|
957
|
+
if (ctx.repoRoot === undefined)
|
|
958
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
959
|
+
if (ctx.config === undefined) {
|
|
960
|
+
return unevaluated(`${CONFIG_FILENAME} could not be read, so the scriptsDir the watcher is written into is unknown (see the config check)`);
|
|
961
|
+
}
|
|
962
|
+
const watcher = resolveWatcherPath({ repoRoot: ctx.repoRoot, config: ctx.config });
|
|
963
|
+
return watcher.present
|
|
964
|
+
? pass(`the run watcher is at ${watcher.path}`)
|
|
965
|
+
: warn(watcherMissingMessage(watcher.path));
|
|
966
|
+
},
|
|
967
|
+
};
|
|
968
|
+
/**
|
|
969
|
+
* Whether this path names a regular file the current account may read — the same two tests the
|
|
970
|
+
* notifier applies to a candidate (`[ -f ] && [ -r ]`), so `doctor` and the script agree about which
|
|
971
|
+
* candidate exists. Never throws, and never creates anything: a candidate that is not there is
|
|
972
|
+
* answered by a `stat`, so asking about the machine-local file does not bring its directory into
|
|
973
|
+
* being.
|
|
974
|
+
*
|
|
975
|
+
* {@link COMMAND_PERMISSIONS_CHECK} asks it of a wrapper script, and readable is the right question
|
|
976
|
+
* there too: every entry the profile carries for one is `bash <path>`, which reads the file rather
|
|
977
|
+
* than executing it, so a wrapper whose mode bit was lost still runs and is not a finding.
|
|
978
|
+
*/
|
|
979
|
+
function isReadableFile(path) {
|
|
980
|
+
try {
|
|
981
|
+
if (!statSync(path).isFile())
|
|
982
|
+
return false;
|
|
983
|
+
accessSync(path, fsConstants.R_OK);
|
|
984
|
+
return true;
|
|
985
|
+
}
|
|
986
|
+
catch {
|
|
987
|
+
return false;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Which of the named keys a settings file assigns a non-empty value to — **by name, never by value**.
|
|
992
|
+
*
|
|
993
|
+
* The file is a shell fragment the notifier sources, so the parse is deliberately the small subset
|
|
994
|
+
* that shape allows: comments and blank lines skipped, an optional `export` prefix dropped, the name
|
|
995
|
+
* taken up to the first `=`, and the value read only far enough to ask whether anything is left after
|
|
996
|
+
* trimming and after one pair of surrounding quotes. A later assignment replaces an earlier one,
|
|
997
|
+
* because that is what sourcing the file does.
|
|
998
|
+
*
|
|
999
|
+
* The value never leaves this function: it is reduced to a boolean here, so no caller downstream has
|
|
1000
|
+
* one to print by accident. That is the check's one security property — a push URL is a bearer
|
|
1001
|
+
* credential for every endpoint worth pointing this at, and a terminal scrollback is not private.
|
|
1002
|
+
*/
|
|
1003
|
+
function nonEmptyKeys(text, keys) {
|
|
1004
|
+
const present = new Map();
|
|
1005
|
+
for (const raw of text.split('\n')) {
|
|
1006
|
+
const line = raw.trim().replace(/^export\s+/, '');
|
|
1007
|
+
if (line === '' || line.startsWith('#'))
|
|
1008
|
+
continue;
|
|
1009
|
+
const separator = line.indexOf('=');
|
|
1010
|
+
if (separator < 0)
|
|
1011
|
+
continue;
|
|
1012
|
+
const name = line.slice(0, separator).trim();
|
|
1013
|
+
if (!keys.includes(name))
|
|
1014
|
+
continue;
|
|
1015
|
+
const value = line
|
|
1016
|
+
.slice(separator + 1)
|
|
1017
|
+
.trim()
|
|
1018
|
+
.replace(/^(['"])([\s\S]*)\1$/, '$2')
|
|
1019
|
+
.trim();
|
|
1020
|
+
present.set(name, value !== '');
|
|
1021
|
+
}
|
|
1022
|
+
return keys.filter((key) => present.get(key) === true);
|
|
1023
|
+
}
|
|
1024
|
+
/** `<path> (machine-local)` / `<path> (repository-side)` — how a candidate is named in a detail. */
|
|
1025
|
+
function candidateLabel(candidate) {
|
|
1026
|
+
return `${candidate.path} (${candidate.origin === 'machine' ? 'machine-local' : 'repository-side'})`;
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* The clause both `warn` grades end with: what an unconfigured repository actually gets, and the
|
|
1030
|
+
* ways to configure it. Written once because the two states share the remedy — one has a file with
|
|
1031
|
+
* nothing in it and the other has no file — and a reader comparing the two reports should see one
|
|
1032
|
+
* answer to "so what do I do".
|
|
1033
|
+
*
|
|
1034
|
+
* It names the files by **role** rather than by path, because both branches that use it have
|
|
1035
|
+
* already spelled every candidate path in the same line; repeating them would make the one line an
|
|
1036
|
+
* operator has to read four paths long.
|
|
1037
|
+
*
|
|
1038
|
+
* The repository-side half of the remedy is only offered when there **is** a repository-side
|
|
1039
|
+
* candidate. A config with no `pushEnvPath` has none ({@link pushEnvCandidates}), and telling that
|
|
1040
|
+
* operator to fill a repository-side file by hand would send them to a file the notifier never
|
|
1041
|
+
* opens — the failure this whole check exists to prevent.
|
|
1042
|
+
*/
|
|
1043
|
+
function deliveryOptInAdvice(candidates) {
|
|
1044
|
+
const repositoryRemedy = candidates.some((candidate) => candidate.origin === 'repository')
|
|
1045
|
+
? ', and the repository-side one is filled in by hand'
|
|
1046
|
+
: `; this repository configures no repository-side file, so filling one means setting \`pushEnvPath\` in ${CONFIG_FILENAME} first`;
|
|
1047
|
+
return `delivery is opt-in and defaults to nothing pushed, so with neither ${PUSH_URL_KEY} nor ${PUSH_CMD_KEY} set an unattended run's completed, parked and failed events reach a macOS desktop banner where one is available and nothing at all on a Linux host. \`${CLI} init --notifications --push-url <url>\` writes the machine-local file for you${repositoryRemedy}`;
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Which settings file an unattended run's notifier will actually read, and whether either delivery
|
|
1051
|
+
* arm is configured in it.
|
|
1052
|
+
*
|
|
1053
|
+
* **The precedence is not decided here.** `generators/notifications.ts`'s {@link pushEnvCandidates}
|
|
1054
|
+
* is the TypeScript side's one definition of the order `docs/watcher.md` §6 publishes — machine-local
|
|
1055
|
+
* first, the repository's configured `pushEnvPath` second **when the config names one**, the first
|
|
1056
|
+
* that exists winning and the other not read at all — and this check walks that list rather than
|
|
1057
|
+
* joining either path itself, so it never names a file the notifier would not open. Two
|
|
1058
|
+
* derivations of a first-wins order would differ exactly on the machine that has both files, which is
|
|
1059
|
+
* the only machine anybody asks this question about.
|
|
1060
|
+
*
|
|
1061
|
+
* **No value is ever printed**, in the detail line or in any failure text: the arms are reported by
|
|
1062
|
+
* key name only ({@link nonEmptyKeys} reduces each to a boolean before it returns), which is the
|
|
1063
|
+
* discipline `autonomous-notify.sh`'s header states for the delivery side and `init`'s generator for
|
|
1064
|
+
* the writing side.
|
|
1065
|
+
*
|
|
1066
|
+
* **It never fails.** Notifications are optional by design and a repository with none runs
|
|
1067
|
+
* everything, so the worst grade here is a `warn` — one saying what an unconfigured run gets and
|
|
1068
|
+
* naming both the flag and the two paths that change it. Grading it a failure would make an opt-in
|
|
1069
|
+
* nobody has to take decide the exit status of a repository that works.
|
|
1070
|
+
*
|
|
1071
|
+
* It reads at most one file and creates nothing, machine-local directory included.
|
|
1072
|
+
*/
|
|
1073
|
+
const NOTIFICATIONS_CHECK = {
|
|
1074
|
+
id: 'notifications',
|
|
1075
|
+
title: 'the file an unattended run reads its notification settings from resolves',
|
|
1076
|
+
run: (ctx) => {
|
|
1077
|
+
if (ctx.repoRoot === undefined)
|
|
1078
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
1079
|
+
if (ctx.config === undefined) {
|
|
1080
|
+
return unevaluated(`${CONFIG_FILENAME} could not be read, so the repository-side candidate's path is unknown (see the config check)`);
|
|
1081
|
+
}
|
|
1082
|
+
const candidates = pushEnvCandidates(ctx.repoRoot, ctx.config);
|
|
1083
|
+
const resolved = candidates.find((candidate) => isReadableFile(candidate.path));
|
|
1084
|
+
if (resolved === undefined) {
|
|
1085
|
+
// The count comes off the list rather than being written into the sentence: a config with no
|
|
1086
|
+
// `pushEnvPath` has one candidate, and "neither" would then be a report about a second file
|
|
1087
|
+
// this repository does not have.
|
|
1088
|
+
const none = candidates.length === 1 ? 'the one candidate settings file does not exist' : 'neither candidate settings file exists';
|
|
1089
|
+
return warn(`${none}, so the notifier reads none: looked at ${candidates.map(candidateLabel).join(', then ')}. That is not a fault — ${deliveryOptInAdvice(candidates)}`);
|
|
1090
|
+
}
|
|
1091
|
+
// The other candidate is named in every resolved answer, because "which of these two is in
|
|
1092
|
+
// effect" is the whole question an operator with a file in both places is asking — and it is
|
|
1093
|
+
// named for the *reason* it lost, which differs by which side won: a candidate below the winner
|
|
1094
|
+
// is shadowed however complete it is, while one above it lost only by not being there.
|
|
1095
|
+
const winner = candidates.indexOf(resolved);
|
|
1096
|
+
const others = candidates
|
|
1097
|
+
.filter((candidate) => candidate !== resolved)
|
|
1098
|
+
.map((candidate) => candidates.indexOf(candidate) > winner
|
|
1099
|
+
? `the other candidate, ${candidateLabel(candidate)}, is deliberately not read while this one is there`
|
|
1100
|
+
: `the higher-precedence candidate, ${candidateLabel(candidate)}, does not exist, and would be read instead of this one if it did`)
|
|
1101
|
+
.join('; ');
|
|
1102
|
+
// Kept for the end of every answer below rather than folded into the sentence about the winner:
|
|
1103
|
+
// a clause about the *other* file between the winner and what the winner sets would leave every
|
|
1104
|
+
// "it sets…" with two candidate files behind it to refer to.
|
|
1105
|
+
const andTheOther = others === '' ? '' : ` — ${others}`;
|
|
1106
|
+
const head = `${candidateLabel(resolved)} is the settings file an unattended run's notifier reads, as the first candidate that exists`;
|
|
1107
|
+
let text;
|
|
1108
|
+
try {
|
|
1109
|
+
text = readFileSync(resolved.path, 'utf8');
|
|
1110
|
+
}
|
|
1111
|
+
catch (error) {
|
|
1112
|
+
return warn(`${head}, and it could not be read here (${messageOf(error)}), so what it configures is unknown${andTheOther}`);
|
|
1113
|
+
}
|
|
1114
|
+
const configured = nonEmptyKeys(text, [PUSH_URL_KEY, PUSH_CMD_KEY]);
|
|
1115
|
+
if (configured.length === 0) {
|
|
1116
|
+
return warn(`${head}, and it sets neither ${PUSH_URL_KEY} nor ${PUSH_CMD_KEY} to anything: ${deliveryOptInAdvice(candidates)}${andTheOther}`);
|
|
1117
|
+
}
|
|
1118
|
+
return pass(`${head}, and it sets ${nameList(configured)}, so ${configured.length === 1 ? 'that delivery arm is' : 'both delivery arms are'} configured; no value from it is printed here or anywhere else${andTheOther}`);
|
|
1119
|
+
},
|
|
1120
|
+
};
|
|
1121
|
+
/** What each stale grade means, in the words an operator can act on. `ok` has nothing to explain. */
|
|
1122
|
+
const STALE_STATE_MEANING = Object.freeze({
|
|
1123
|
+
'root-missing': 'the checkout the entry records is no longer there, so it was moved or removed after the daemon was installed',
|
|
1124
|
+
'not-a-repository': 'the path the entry records is still there but is no longer a repository root, so the checkout it named was replaced by something else',
|
|
1125
|
+
'unit-missing': 'the unit file the entry records has been deleted, so the service manager no longer has a daemon to run for it',
|
|
1126
|
+
});
|
|
1127
|
+
/** `1 other registered repository` / `N other registered repositories`, in the grammar it deserves. */
|
|
1128
|
+
function otherRepositories(count) {
|
|
1129
|
+
return count === 1 ? '1 other registered repository' : `${count} other registered repositories`;
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Is this repository armed on this machine, and is what the registry says about it still true?
|
|
1133
|
+
*
|
|
1134
|
+
* The two enumerating consumers of `repos.json` are `daemon list` and this check, and both ask
|
|
1135
|
+
* `machine/registry.ts` rather than deriving anything: the path is {@link registryPath}, the parse is
|
|
1136
|
+
* {@link readRegistry}, the staleness grading is {@link inspect}, and the key is the {@link repoSlug}
|
|
1137
|
+
* the daemon's own label carries. A second derivation of any of them would be a second answer to
|
|
1138
|
+
* "what is armed here", which is the failure that file's header exists to prevent.
|
|
1139
|
+
*
|
|
1140
|
+
* **It never fails, and that is settled by one sentence elsewhere:** `docs/watcher.md` §5 states the
|
|
1141
|
+
* registry is *not consulted before starting a run*. So nothing this check can find is a reason a run
|
|
1142
|
+
* cannot proceed. A repository with **no entry** is the ordinary state of one that runs in the
|
|
1143
|
+
* foreground — the same reasoning {@link WATCHER_CHECK} grades an opt-in subsystem's absence by — and
|
|
1144
|
+
* a **stale** entry costs an operator accuracy in `daemon list` rather than costing this repository a
|
|
1145
|
+
* run. Both warn.
|
|
1146
|
+
*
|
|
1147
|
+
* The count of *other* stale entries rides along on every grade, so one `doctor` run surfaces a
|
|
1148
|
+
* machine that has drifted; it is reported as a machine-level fact and never moves this repository's
|
|
1149
|
+
* own grade, because another checkout's moved directory is not this repository's fault or its fix.
|
|
1150
|
+
*
|
|
1151
|
+
* **It writes nothing, the machine-local directory included.** {@link readRegistry} fails open over
|
|
1152
|
+
* an absent file and {@link inspect} only stats and probes, so asking these questions cannot bring
|
|
1153
|
+
* `machineStateDir()` into being — which is the one place `doctor`'s "writes nothing" contract is
|
|
1154
|
+
* hardest to notice being broken, since it is outside the repository the snapshot test watches.
|
|
1155
|
+
* Pruning is `daemon list --prune`'s, never a read's.
|
|
1156
|
+
*/
|
|
1157
|
+
const REPO_REGISTRY_CHECK = {
|
|
1158
|
+
id: 'repo-registry',
|
|
1159
|
+
title: 'this repository is recorded in the machine-local registry of installed daemons',
|
|
1160
|
+
run: (ctx) => {
|
|
1161
|
+
if (ctx.repoRoot === undefined)
|
|
1162
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
1163
|
+
const path = registryPath();
|
|
1164
|
+
const slug = repoSlug(ctx.repoRoot);
|
|
1165
|
+
const entries = inspect(readRegistry());
|
|
1166
|
+
const here = entries.find((entry) => entry.slug === slug);
|
|
1167
|
+
const staleElsewhere = entries.filter((entry) => entry.slug !== slug && entry.state !== 'ok').length;
|
|
1168
|
+
const andElsewhere = staleElsewhere === 0
|
|
1169
|
+
? ''
|
|
1170
|
+
: `. Separately, ${otherRepositories(staleElsewhere)} on this machine ${staleElsewhere === 1 ? 'is' : 'are'} stale — a machine-level finding rather than this repository's: \`${CLI} daemon list\` shows which, and \`--prune\` drops them`;
|
|
1171
|
+
if (here === undefined) {
|
|
1172
|
+
return warn(`this repository is not registered in ${path}: no run daemon has been installed from this checkout, so nothing polls its inbox and a file dropped there simply sits until something starts a run by hand (docs/watcher.md §6 step 1). That is not a fault — the daemon is opt-in and a run in the foreground never needs one; \`${CLI} daemon install\`, run here, installs it and registers this checkout as ${slug}${andElsewhere}`);
|
|
1173
|
+
}
|
|
1174
|
+
if (here.state !== 'ok') {
|
|
1175
|
+
return warn(`this repository is registered in ${path} as ${slug}, and that entry is stale [${here.state}]: ${STALE_STATE_MEANING[here.state]}. The listing is wrong rather than this repository being unrunnable — the registry is not consulted before a run starts (docs/watcher.md §5) — so correct it either way: re-install from this checkout with \`${CLI} daemon install\`, which rewrites the entry, or drop the entry with \`${CLI} daemon list --prune\`, which removes registry rows and nothing else${andElsewhere}`);
|
|
1176
|
+
}
|
|
1177
|
+
return pass(`this repository is registered in ${path} as ${slug}: a ${here.entry.backend} daemon labelled ${here.entry.label}, whose unit file is installed at ${here.entry.unitPath}, and both the recorded checkout and that unit file are still there${andElsewhere}`);
|
|
1178
|
+
},
|
|
1179
|
+
};
|
|
1180
|
+
/**
|
|
1181
|
+
* The file the machine-local `MAX_PARALLEL_RUNS` default is set in, under {@link machineConfigDir}.
|
|
1182
|
+
*
|
|
1183
|
+
* `autonomous-watcher.sh` sources it after resolving its own defaults, so the value in it is the cap
|
|
1184
|
+
* every daemon on this machine inherits. Named here as a string rather than resolved through a
|
|
1185
|
+
* shared constant because the shell half is the one that *sources* it; this side only reads a line.
|
|
1186
|
+
*/
|
|
1187
|
+
const WATCHER_ENV_FILENAME = 'watcher.env';
|
|
1188
|
+
/**
|
|
1189
|
+
* The tunable that carries the cap, and the shipped default `autonomous-watcher.sh`'s `:-` applies.
|
|
1190
|
+
*
|
|
1191
|
+
* `MAX_PARALLEL_RUNS_DEFAULT` in `cli/templates/scripts/autonomous-watcher.sh` is the definition of
|
|
1192
|
+
* record; this is a mirror of it, because a TypeScript reader cannot evaluate that expansion. Change
|
|
1193
|
+
* one and change the other — `grep -rn MAX_PARALLEL_RUNS_DEFAULT cli/` reaches both.
|
|
1194
|
+
*/
|
|
1195
|
+
const MAX_PARALLEL_RUNS_VARIABLE = 'MAX_PARALLEL_RUNS';
|
|
1196
|
+
const DEFAULT_MAX_PARALLEL_RUNS = '5';
|
|
1197
|
+
/** One `MAX_PARALLEL_RUNS=` assignment, `export`ed or not, with the value as it was written. */
|
|
1198
|
+
const MAX_PARALLEL_RUNS_ASSIGNMENT = new RegExp(`^[ \\t]*(?:export[ \\t]+)?${MAX_PARALLEL_RUNS_VARIABLE}[ \\t]*=(.*)$`);
|
|
1199
|
+
/**
|
|
1200
|
+
* Where one repository keeps its **run** registry, relative to that repository's `stateDir`.
|
|
1201
|
+
*
|
|
1202
|
+
* `autonomous-watcher.sh`'s `REGISTRY="$LOGS_DIR/registry.json"` is the definition of record — this
|
|
1203
|
+
* is the first TypeScript reader of that file, and it reads it for a *report* only.
|
|
1204
|
+
*/
|
|
1205
|
+
const RUN_LOGS_DIR = 'autonomous_logs';
|
|
1206
|
+
const RUN_REGISTRY_FILENAME = 'registry.json';
|
|
1207
|
+
/** The record status that counts toward a repository's live runs. Half of the predicate; see below. */
|
|
1208
|
+
const RUNNING_STATUS = 'running';
|
|
1209
|
+
/** What a field reads as when the read that would have answered it faulted. Never a check's grade. */
|
|
1210
|
+
const UNKNOWN_FIELD = 'unknown';
|
|
1211
|
+
/**
|
|
1212
|
+
* Is this pid a process that exists, from this account's point of view?
|
|
1213
|
+
*
|
|
1214
|
+
* `process.kill(pid, 0)` **sends no signal**; it asks the kernel the question `kill -0` asks. `EPERM`
|
|
1215
|
+
* is `true` — the process is there and belongs to another account, which is the ordinary answer on a
|
|
1216
|
+
* shared machine and the one a naive `catch` would silently turn into "gone". `ESRCH`, and anything
|
|
1217
|
+
* else, is `false`.
|
|
1218
|
+
*
|
|
1219
|
+
* A pid of `0` or a negative one is rejected before the call rather than after: those spellings
|
|
1220
|
+
* address a process *group* rather than a process, so a garbage record must not reach the syscall.
|
|
1221
|
+
*/
|
|
1222
|
+
function pidIsAlive(pid) {
|
|
1223
|
+
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0)
|
|
1224
|
+
return false;
|
|
1225
|
+
try {
|
|
1226
|
+
process.kill(pid, 0);
|
|
1227
|
+
return true;
|
|
1228
|
+
}
|
|
1229
|
+
catch (error) {
|
|
1230
|
+
return error.code === 'EPERM';
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* How many runs one registered repository has in flight: records whose `status` is `running`
|
|
1235
|
+
* **and** whose pid answers {@link pidIsAlive}.
|
|
1236
|
+
*
|
|
1237
|
+
* **Both conditions, and the status filter is this report's own addition** — the same filter
|
|
1238
|
+
* `autonomous-watcher.sh`'s `footprint_live_runs` applies. The liveness half is NOT identical: this
|
|
1239
|
+
* side counts `EPERM` as alive (see {@link pidIsAlive}) and the shell's `kill -0 … 2>/dev/null`
|
|
1240
|
+
* cannot, so the two can differ by a run owned by another account on a shared machine. That is the
|
|
1241
|
+
* only case, and it is named rather than papered over. The watcher's `running_count` tests only the
|
|
1242
|
+
* pid, which is sound in the repository it runs in because `reconcile_stale_runs` demotes a dead
|
|
1243
|
+
* `running` record first on every pass; **no reconcile pass ever runs against a foreign root**, so a
|
|
1244
|
+
* bare pid walk there would count a `parked`, `paused` or `failed` record whose pid happens to have
|
|
1245
|
+
* been reused.
|
|
1246
|
+
*
|
|
1247
|
+
* An absent, unreadable or unparseable registry counts `0`: this is a report, and a repository whose
|
|
1248
|
+
* artifact tree cannot be read is better described as "none observed" than as a fault. It reads one
|
|
1249
|
+
* file and writes nothing.
|
|
1250
|
+
*/
|
|
1251
|
+
function liveRunCount(root, stateDir) {
|
|
1252
|
+
let parsed;
|
|
1253
|
+
try {
|
|
1254
|
+
parsed = readJsonFile(join(root, stateDir, RUN_LOGS_DIR, RUN_REGISTRY_FILENAME));
|
|
1255
|
+
}
|
|
1256
|
+
catch {
|
|
1257
|
+
return 0;
|
|
1258
|
+
}
|
|
1259
|
+
if (!isJsonObject(parsed))
|
|
1260
|
+
return 0;
|
|
1261
|
+
const runs = parsed['runs'];
|
|
1262
|
+
if (!isJsonObject(runs))
|
|
1263
|
+
return 0;
|
|
1264
|
+
let live = 0;
|
|
1265
|
+
for (const key of Object.keys(runs)) {
|
|
1266
|
+
const record = runs[key];
|
|
1267
|
+
if (!isJsonObject(record))
|
|
1268
|
+
continue;
|
|
1269
|
+
if (record['status'] !== RUNNING_STATUS)
|
|
1270
|
+
continue;
|
|
1271
|
+
if (pidIsAlive(record['pid']))
|
|
1272
|
+
live += 1;
|
|
1273
|
+
}
|
|
1274
|
+
return live;
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* The cap every daemon on this machine **inherits by default**, as the cell each row carries.
|
|
1278
|
+
*
|
|
1279
|
+
* The machine-local `watcher.env` value when it sets one, else the shipped `5`. A value that is not
|
|
1280
|
+
* a whole number falls back to `5` exactly as the shell's own `case "$cap" in ""|*[!0-9]*)` does, and
|
|
1281
|
+
* the last assignment in the file wins because that is what sourcing it would leave behind.
|
|
1282
|
+
*
|
|
1283
|
+
* **It is a default, not a foreign daemon's effective cap**, and the message says so: that daemon's
|
|
1284
|
+
* own environment may override it and nothing readable from here reveals whether it did. Stated as a
|
|
1285
|
+
* limit of the report rather than papered over — and carried **per row**, because the cap is per
|
|
1286
|
+
* repository (`autonomous-watcher.sh`'s tunable comment, `docs/watcher.md` §5) and a single
|
|
1287
|
+
* machine-scoped line would assert a machine-wide semantic the tree does not have.
|
|
1288
|
+
*/
|
|
1289
|
+
function inheritedCap() {
|
|
1290
|
+
let text;
|
|
1291
|
+
try {
|
|
1292
|
+
text = readFileSync(join(machineConfigDir(), WATCHER_ENV_FILENAME), 'utf8');
|
|
1293
|
+
}
|
|
1294
|
+
catch (error) {
|
|
1295
|
+
// Absent is the ordinary case and means the shipped default is in force; anything else is a read
|
|
1296
|
+
// this process could not make, and the field says so rather than reporting a number it guessed.
|
|
1297
|
+
return error.code === 'ENOENT' ? DEFAULT_MAX_PARALLEL_RUNS : UNKNOWN_FIELD;
|
|
1298
|
+
}
|
|
1299
|
+
let value;
|
|
1300
|
+
for (const line of text.split('\n')) {
|
|
1301
|
+
const match = MAX_PARALLEL_RUNS_ASSIGNMENT.exec(line);
|
|
1302
|
+
if (match !== null)
|
|
1303
|
+
value = match[1];
|
|
1304
|
+
}
|
|
1305
|
+
if (value === undefined)
|
|
1306
|
+
return DEFAULT_MAX_PARALLEL_RUNS;
|
|
1307
|
+
// Quoted or bare, and a bare value ends at the first space — the two spellings an operator writes.
|
|
1308
|
+
const trimmed = value.trim();
|
|
1309
|
+
const unquoted = /^(['"])(.*)\1$/.exec(trimmed);
|
|
1310
|
+
const resolved = unquoted === null ? (trimmed.split(/[ \t]/)[0] ?? '') : (unquoted[2] ?? '');
|
|
1311
|
+
return /^\d+$/.test(resolved) ? resolved : DEFAULT_MAX_PARALLEL_RUNS;
|
|
1312
|
+
}
|
|
1313
|
+
/** A configured value that is actually a non-empty string, or `undefined` for everything else. */
|
|
1314
|
+
function nonEmptyString(value) {
|
|
1315
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* One registered repository as the line the report prints.
|
|
1319
|
+
*
|
|
1320
|
+
* The identity fields are the entry's own and the grade is {@link inspect}'s; nothing is re-derived.
|
|
1321
|
+
* The four run-shaped fields — model, effort, live runs, cap — are read only for an `ok` entry,
|
|
1322
|
+
* because a root that is gone or is no longer a repository has no configuration to read and reading
|
|
1323
|
+
* it would report a neighbour's. **A read that faults degrades that field to `unknown` rather than
|
|
1324
|
+
* the check**: this is an advisory line, and one unreadable config elsewhere on the machine may not
|
|
1325
|
+
* cost the operator the rest of the report. Both reads report their fault by return rather than by
|
|
1326
|
+
* throwing, so the degradation is a branch on the returned state and not a `try`/`catch`.
|
|
1327
|
+
*
|
|
1328
|
+
* `agentModel` falls back to {@link DEFAULTS}`.agentModel`, the schema default a run would resolve.
|
|
1329
|
+
* `agentEffort` has **no** schema default, so an absent value is reported as the runtime applying its
|
|
1330
|
+
* own per-model default rather than as a level this report invented. An unreadable configuration
|
|
1331
|
+
* degrades the **live cell too**, to `0`: `stateDir` is then unknown, so {@link liveRunCount} is not
|
|
1332
|
+
* called at all rather than reading the schema default's path in a checkout that may not use it —
|
|
1333
|
+
* the value `autonomous-watcher.sh`'s `footprint_live_runs` prints for the same entry.
|
|
1334
|
+
*/
|
|
1335
|
+
function footprintRow(inspected, cap) {
|
|
1336
|
+
const { slug, entry, state } = inspected;
|
|
1337
|
+
// Every row carries the INHERITED default, this checkout's included: `doctor` is not the daemon
|
|
1338
|
+
// and cannot know what a unit's own environment resolved. `autonomous-watcher.sh`'s
|
|
1339
|
+
// `machine_footprint_report` prints its own resolved value on its own row, which it can; the
|
|
1340
|
+
// difference is stated in `docs/watcher.md` §7. That cell and the `unknown` / `—` spelling of an
|
|
1341
|
+
// unread model and effort are the only places the two rows differ in WORDING; no cell of either
|
|
1342
|
+
// row differs in VALUE.
|
|
1343
|
+
const capCell = `cap=${cap} (inherited default; this entry's own daemon environment may override, not derivable from here)`;
|
|
1344
|
+
const head = `${slug} [${state}] project=${entry.projectName} root=${entry.root}`;
|
|
1345
|
+
if (state !== 'ok') {
|
|
1346
|
+
return { armed: false, live: 0, text: `${head} — stale: ${STALE_STATE_MEANING[state]} — ${capCell}` };
|
|
1347
|
+
}
|
|
1348
|
+
// `loadConfig` never throws: an absent, unreadable or non-object document all come back as
|
|
1349
|
+
// `config: undefined` with the reason in `problems`. So the unreadable case is a RETURNED state,
|
|
1350
|
+
// not an exception — and it must read `unknown` rather than fall through to the schema defaults,
|
|
1351
|
+
// which would report a model this process never read. `autonomous-watcher.sh`'s
|
|
1352
|
+
// `machine_footprint_report` prints `—` for the same entry; the two may not disagree.
|
|
1353
|
+
const { config: loaded, problems } = loadConfig(entry.root);
|
|
1354
|
+
if (loaded === undefined) {
|
|
1355
|
+
const reason = problems[0]?.message ?? 'no readable harness.config.json';
|
|
1356
|
+
// No readable configuration means no known `stateDir`, so there is no run registry to read and
|
|
1357
|
+
// the schema default is a GUESS at another checkout's layout. `autonomous-watcher.sh`'s
|
|
1358
|
+
// `footprint_live_runs` reaches its registry through `hr_state_path`, which fails with the same
|
|
1359
|
+
// configuration read, and prints `0` rather than reading a registry it cannot locate. The two
|
|
1360
|
+
// halves may not disagree on a NUMBER, so this arm returns `0` here instead of falling through
|
|
1361
|
+
// to `liveRunCount`.
|
|
1362
|
+
return {
|
|
1363
|
+
armed: true,
|
|
1364
|
+
live: 0,
|
|
1365
|
+
text: `${head} model=${UNKNOWN_FIELD} (${reason}) effort=${UNKNOWN_FIELD} live=0 ${capCell}`,
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
// Read through `nonEmptyString` rather than the declared types: this config was parsed from a
|
|
1369
|
+
// file in another checkout, which a hand-edit may have left holding a number where the schema
|
|
1370
|
+
// says string, and the report's job is to print what is there without throwing over it.
|
|
1371
|
+
const model = nonEmptyString(loaded.agentModel) ?? DEFAULTS.agentModel;
|
|
1372
|
+
const effort = nonEmptyString(loaded.agentEffort) ?? "unpinned, so the runtime's own default applies";
|
|
1373
|
+
const stateDir = nonEmptyString(loaded.stateDir) ?? DEFAULTS.stateDir;
|
|
1374
|
+
// `liveRunCount` is total by construction — it swallows its own read failure and returns `0` — so
|
|
1375
|
+
// a wrapper here could never fire and would be one more thing to keep true.
|
|
1376
|
+
const live = liveRunCount(entry.root, stateDir);
|
|
1377
|
+
return { armed: true, live, text: `${head} model=${model} effort=${effort} live=${live} ${capCell}` };
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* What else is this machine armed to run, and at what settings?
|
|
1381
|
+
*
|
|
1382
|
+
* The same report `autonomous-watcher.sh status` prints, from the command an operator already runs to
|
|
1383
|
+
* ask what is wrong. {@link REPO_REGISTRY_CHECK} directly above answers for **this** checkout; this
|
|
1384
|
+
* one answers for the machine around it, and the two are meant to be read together: a repository that
|
|
1385
|
+
* is correctly armed on a machine already running several other repositories at high effort is a
|
|
1386
|
+
* different situation from the same repository alone, and neither line says that on its own.
|
|
1387
|
+
*
|
|
1388
|
+
* **It never fails**, for {@link REPO_REGISTRY_CHECK}'s reason: `docs/watcher.md` §5 states the
|
|
1389
|
+
* registry is not consulted before starting a run, and §7 that *"Nothing in this file may become a
|
|
1390
|
+
* precondition of anything"*. So nothing this check can find is a reason a run cannot proceed —
|
|
1391
|
+
* every disposition here is a `pass` or a `warn`, and this check must not become that section's
|
|
1392
|
+
* exception.
|
|
1393
|
+
*
|
|
1394
|
+
* **It reads and writes nothing, `machineStateDir()` included** — {@link readRegistry} fails open
|
|
1395
|
+
* over an absent file, {@link inspect} only stats and probes, and the two per-entry reads are a
|
|
1396
|
+
* config file and a run registry inside a repository that is not this one. Asking cannot bring the
|
|
1397
|
+
* machine-local directory into being, which is the corner of `doctor`'s "writes nothing" contract the
|
|
1398
|
+
* repository snapshot test cannot see.
|
|
1399
|
+
*/
|
|
1400
|
+
const MACHINE_FOOTPRINT_CHECK = {
|
|
1401
|
+
id: 'machine-footprint',
|
|
1402
|
+
title: 'what this machine is armed to run, and at what parallelism, model and effort',
|
|
1403
|
+
run: () => {
|
|
1404
|
+
const path = registryPath();
|
|
1405
|
+
const entries = inspect(readRegistry());
|
|
1406
|
+
// Failing open means an unreadable file and an empty one are the same answer here, so the remedy
|
|
1407
|
+
// names both readings rather than asserting the one it cannot distinguish.
|
|
1408
|
+
if (entries.length === 0) {
|
|
1409
|
+
return warn(`no repository is registered in ${path}, which also reads that way when the file is unreadable or was hand-edited into something this release does not parse: this machine is armed to run nothing unattended, or the index of what it is armed to run has been lost. That is not a fault — the daemon is opt-in — and \`${CLI} daemon install\`, run in each checkout, rebuilds the index one entry at a time; this report never writes it`);
|
|
1410
|
+
}
|
|
1411
|
+
const cap = inheritedCap();
|
|
1412
|
+
const rows = entries.map((entry) => footprintRow(entry, cap));
|
|
1413
|
+
const armed = rows.filter((row) => row.armed);
|
|
1414
|
+
const live = armed.reduce((total, row) => total + row.live, 0);
|
|
1415
|
+
const summary = `summary: armed=${armed.length} stale=${rows.length - armed.length} live=${live}`;
|
|
1416
|
+
const listing = rows.map((row) => row.text).join(' | ');
|
|
1417
|
+
if (armed.length === 0) {
|
|
1418
|
+
return warn(`every entry in ${path} is stale, so this machine's index of what it is armed to run no longer describes it: ${listing}. Nothing here stops a run — the registry is not consulted before one starts (docs/watcher.md §5) — so correct the listing either way: \`${CLI} daemon install\`, run in a checkout, rewrites that checkout's entry, and \`${CLI} daemon list --prune\` drops the rows whose checkouts are gone. ${summary}`);
|
|
1419
|
+
}
|
|
1420
|
+
return pass(`this machine is armed to run ${armed.length === 1 ? '1 repository' : `${armed.length} repositories`}, from ${path}: ${listing}. ${summary}. The cap is per repository, so the machine's own ceiling is the sum of the caps of whatever is running at once, and parallelism × model × effort is the adopter's call: several concurrent high-effort runs will exhaust a rate-limit window that one would not`);
|
|
1421
|
+
},
|
|
1422
|
+
};
|
|
1423
|
+
/**
|
|
1424
|
+
* The environment variable the run watcher reaches its agent binary through, and the name it falls
|
|
1425
|
+
* back to — `autonomous-watcher.sh`'s `AGENT_CLI="${HARNESS_AGENT_CLI:-claude}"`, the one place a
|
|
1426
|
+
* run's engine binary is chosen.
|
|
1427
|
+
*
|
|
1428
|
+
* **It is read out of the installed unit, never out of this process.** A user agent inherits the
|
|
1429
|
+
* service manager's environment rather than the login shell `doctor` was typed at, so this process's
|
|
1430
|
+
* value is the exact substitution {@link DAEMON_PATH_CHECK} exists to avoid: it would grade a binary
|
|
1431
|
+
* the daemon never invokes and leave the one it does invoke ungraded. `daemon/units.ts` choice 5
|
|
1432
|
+
* renders `PATH` and nothing else, so an unedited unit falls through to the default here just as the
|
|
1433
|
+
* watcher's `:-` does, and a unit an operator added the key to is graded against the CLI it names.
|
|
1434
|
+
*/
|
|
1435
|
+
const AGENT_CLI_VARIABLE = 'HARNESS_AGENT_CLI';
|
|
1436
|
+
const DEFAULT_AGENT_CLI = 'claude';
|
|
1437
|
+
/** Shell keywords and builtins no `PATH` resolves, so a line headed by one names no binary. */
|
|
1438
|
+
const SHELL_HEADS = new Set([
|
|
1439
|
+
'.',
|
|
1440
|
+
'cd',
|
|
1441
|
+
'eval',
|
|
1442
|
+
'exec',
|
|
1443
|
+
'export',
|
|
1444
|
+
'for',
|
|
1445
|
+
'if',
|
|
1446
|
+
'set',
|
|
1447
|
+
'source',
|
|
1448
|
+
'while',
|
|
1449
|
+
]);
|
|
1450
|
+
/** A leading `VAR=value` assignment, which a shell strips before it resolves the command. */
|
|
1451
|
+
const ASSIGNMENT_PREFIX = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
1452
|
+
/**
|
|
1453
|
+
* The binary a raw command line resolves through `PATH`, or `undefined` when that line names none.
|
|
1454
|
+
*
|
|
1455
|
+
* A shell strips a leading run of `VAR=value` assignments before it resolves the command, so they are
|
|
1456
|
+
* skipped here for the same reason — the leading run only, so `npm test FOO=bar` keeps its head. A head
|
|
1457
|
+
* that is a shell keyword or builtin resolves through no `PATH` at all, and a compound line
|
|
1458
|
+
* (`cd app && npm test`) arrives here with one as its head; grading either warns about a binary no
|
|
1459
|
+
* machine has, under a remedy that cannot clear it. Both are left for the caller to report ungraded
|
|
1460
|
+
* rather than guessed at, which is the answer an unreadable wrapper already gets — and a compound is a
|
|
1461
|
+
* state the shipped wrappers warn against in their own comments.
|
|
1462
|
+
*/
|
|
1463
|
+
function commandHead(line) {
|
|
1464
|
+
const tokens = line.trim().split(/\s+/).filter((token) => token !== '');
|
|
1465
|
+
let index = 0;
|
|
1466
|
+
while (index < tokens.length && ASSIGNMENT_PREFIX.test(tokens[index] ?? ''))
|
|
1467
|
+
index += 1;
|
|
1468
|
+
const head = tokens[index];
|
|
1469
|
+
if (head === undefined || SHELL_HEADS.has(head))
|
|
1470
|
+
return undefined;
|
|
1471
|
+
return head;
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* Derives {@link CommandHeads} over the whole configured surface — every `commands.*` value that is a
|
|
1475
|
+
* raw line, `deploy.command`, and the raw line inside each of the four {@link WRAPPER_SCRIPTS}
|
|
1476
|
+
* wrappers a configured value invokes.
|
|
1477
|
+
*
|
|
1478
|
+
* **One derivation, because two consumers ask one question.** Deriving the head in each check would
|
|
1479
|
+
* put two answers behind "what does this line run", which is the drift this module forbids; each
|
|
1480
|
+
* consumer instead filters the classes it grades. Nothing is dropped silently here: the `/`-headed
|
|
1481
|
+
* class the daemon check discards is returned apart so a consumer that must *report* it can.
|
|
1482
|
+
*
|
|
1483
|
+
* **Two places, one head function.** For a `commands.*` value that is itself a raw command line — the
|
|
1484
|
+
* form `setup-worktree.sh`'s `run_configured` evaluates — the head is that value's first token: the
|
|
1485
|
+
* binary that has to resolve, the rest arguments. For a value holding the wrapper invocation `init`
|
|
1486
|
+
* writes (`bash <scriptsDir>/<name>.sh`) that head is `bash` and the executable line sits *inside*
|
|
1487
|
+
* the wrapper, so the head is the first token of the **wrapper's own** line, read through
|
|
1488
|
+
* {@link wrapperCommandLine} off the file {@link configuredWrapperFile} names. Both arms reduce
|
|
1489
|
+
* through {@link commandHead}, and it is not always the first token: a leading run of `VAR=value`
|
|
1490
|
+
* assignments is stripped the way a shell strips it. The second arm walks {@link WRAPPER_SCRIPTS}
|
|
1491
|
+
* rather than `config.commands` because {@link configuredWrapperFile} takes a `WrapperKey`, and
|
|
1492
|
+
* `commands` also carries `build` and `depInstall`, which are not one.
|
|
1493
|
+
*
|
|
1494
|
+
* **The line is read out of the wrapper rather than recorded beside the invocation in the config**,
|
|
1495
|
+
* because the wrapper is create-if-absent and `generators/scripts.ts`'s `writeWrapperScripts` hands
|
|
1496
|
+
* it to the adopter — *"the raw command line in each wrapper is the adopter's to correct"* — so a copy
|
|
1497
|
+
* in the config is a second source of truth that grades a string nothing runs the first time that
|
|
1498
|
+
* file is edited.
|
|
1499
|
+
*
|
|
1500
|
+
* **Two values yield no entry at all**, on either arm, because neither is a command line and neither
|
|
1501
|
+
* is this derivation's to report: a value still holding `init`'s placeholder, which
|
|
1502
|
+
* {@link CONFIG_CHECK} reports; and `commands.typecheck` answered with the `<none>` sentinel,
|
|
1503
|
+
* which {@link CONFIG_CHECK} passes silently and {@link COMMAND_WRAPPERS_CHECK} and
|
|
1504
|
+
* {@link COMMAND_PERMISSIONS_CHECK} name in their pass text.
|
|
1505
|
+
*
|
|
1506
|
+
* **The second skip is key-gated — {@link answersNone}, never a bare value test.** This loop walks
|
|
1507
|
+
* every `commands.*` key and `deploy.command`, so a value-shape-only skip would drop the sentinel on
|
|
1508
|
+
* `commands.test` too and grade nothing there, while `config/check.ts` warns on that same key that
|
|
1509
|
+
* its value *will be run*. Do not "simplify" it to a test of the value alone.
|
|
1510
|
+
*
|
|
1511
|
+
* **Both consumers inherit both skips**, which is why neither {@link requiredBinaries} nor
|
|
1512
|
+
* {@link COMMAND_RESOLVES_CHECK} carries one: a second skip at a consumer is a second answer to the
|
|
1513
|
+
* question this function exists to answer once.
|
|
1514
|
+
*
|
|
1515
|
+
* The `deploy` skip belongs to {@link requiredBinaries}, not here — it is that check's question,
|
|
1516
|
+
* and this helper's other consumer grades `deploy`. A `deploy` key absent from the config yields no
|
|
1517
|
+
* entry on either arm.
|
|
1518
|
+
*/
|
|
1519
|
+
function commandHeads(config, repoRoot) {
|
|
1520
|
+
const graded = [];
|
|
1521
|
+
const pathHeads = [];
|
|
1522
|
+
const ungraded = [];
|
|
1523
|
+
// Named `absent` rather than `missing`: in this module a *missing* binary is one a PATH does not
|
|
1524
|
+
// reach, which is a different finding with a different remedy.
|
|
1525
|
+
const absent = [];
|
|
1526
|
+
const classify = (head, source, key) => {
|
|
1527
|
+
// `commandHead` drops empty tokens, so a returned head is never `''`.
|
|
1528
|
+
(head.includes('/') ? pathHeads : graded).push({ head, source, key });
|
|
1529
|
+
};
|
|
1530
|
+
// The key path is the source spelling on this arm, so `commands.*` entries keep today's text and
|
|
1531
|
+
// `deploy.command` arrives already spelled by `configKeyPath`, its one owner.
|
|
1532
|
+
// Each entry carries the bare command key beside its key path, because the sentinel skip below is
|
|
1533
|
+
// key-gated: `deploy.command` has no bare command key and passes `'deploy.command'`, which is not
|
|
1534
|
+
// one and so never answers `none`.
|
|
1535
|
+
const values = [
|
|
1536
|
+
...Object.entries(config.commands ?? {}).map(([key, value]) => [`commands.${key}`, key, value]),
|
|
1537
|
+
[configKeyPath('deploy'), 'deploy.command', config.deploy?.command],
|
|
1538
|
+
];
|
|
1539
|
+
for (const [keyPath, commandKey, value] of values) {
|
|
1540
|
+
if (typeof value !== 'string' || value.trim() === '' || isPlaceholder(value) || answersNone(commandKey, value))
|
|
1541
|
+
continue;
|
|
1542
|
+
const head = commandHead(value);
|
|
1543
|
+
if (head === undefined)
|
|
1544
|
+
ungraded.push({ key: keyPath, text: keyPath });
|
|
1545
|
+
else
|
|
1546
|
+
classify(head, keyPath, keyPath);
|
|
1547
|
+
}
|
|
1548
|
+
for (const { key } of WRAPPER_SCRIPTS) {
|
|
1549
|
+
const wrapper = configuredWrapperFile(config, key);
|
|
1550
|
+
if (wrapper === undefined)
|
|
1551
|
+
continue;
|
|
1552
|
+
const keyPath = configKeyPath(key);
|
|
1553
|
+
let body;
|
|
1554
|
+
try {
|
|
1555
|
+
body = wrapperCommandLine(readFileSync(join(repoRoot, wrapper.path), 'utf8'));
|
|
1556
|
+
}
|
|
1557
|
+
catch (error) {
|
|
1558
|
+
// `doctor` reports; it never throws for a file an adopter may have deleted or made unreadable.
|
|
1559
|
+
// Which of the two it is decides the remedy printed, so the arms are kept apart here.
|
|
1560
|
+
if (error?.code === 'ENOENT')
|
|
1561
|
+
absent.push({ key: keyPath, text: `${keyPath} (${wrapper.path})` });
|
|
1562
|
+
else
|
|
1563
|
+
ungraded.push({ key: keyPath, text: `${keyPath} (${wrapper.path}: ${messageOf(error)})` });
|
|
1564
|
+
continue;
|
|
1565
|
+
}
|
|
1566
|
+
// `unresolved` adds nothing and is *not* ungraded — see {@link CommandHeads}' fifth state.
|
|
1567
|
+
if (body.kind === 'command') {
|
|
1568
|
+
const head = commandHead(body.command);
|
|
1569
|
+
if (head === undefined)
|
|
1570
|
+
ungraded.push({ key: keyPath, text: `${keyPath} (${wrapper.path})` });
|
|
1571
|
+
else
|
|
1572
|
+
classify(head, `${keyPath} → ${wrapper.path}`, keyPath);
|
|
1573
|
+
}
|
|
1574
|
+
else if (body.kind === 'unrecognised')
|
|
1575
|
+
ungraded.push({ key: keyPath, text: `${keyPath} (${wrapper.path})` });
|
|
1576
|
+
}
|
|
1577
|
+
return { graded, pathHeads, ungraded, absent };
|
|
1578
|
+
}
|
|
1579
|
+
/**
|
|
1580
|
+
* The binaries a daemon-launched run has to resolve, derived from configuration rather than listed.
|
|
1581
|
+
*
|
|
1582
|
+
* **Config-derived is the point.** A hardcoded `npm` would be wrong for every adopter whose package
|
|
1583
|
+
* manager is not npm — the same class of defect as a hardcoded default branch. Three fixed members
|
|
1584
|
+
* join the derived ones, each because a shipped file invokes it by name: `git`, which every
|
|
1585
|
+
* outer-loop script runs; `jq`, which the generated `lib/harness-run-lib.sh` reaches every
|
|
1586
|
+
* configuration through and which {@link JQ_CHECK} already treats as a hard floor; and the agent CLI
|
|
1587
|
+
* above, whose name arrives as the installed unit's value — `undefined` or empty when the unit does
|
|
1588
|
+
* not set it, which is the default case because `${HARNESS_AGENT_CLI:-claude}` reads an empty value
|
|
1589
|
+
* as unset too.
|
|
1590
|
+
*
|
|
1591
|
+
* **The unit of comparison is the head of a command line**, over the two places such a line lives, and
|
|
1592
|
+
* deriving it is {@link commandHeads}' — this check classifies nothing itself, so it and its sibling
|
|
1593
|
+
* consumer cannot come to disagree about what a line runs. Grading only the `commands.*` value would
|
|
1594
|
+
* leave a repository whose every wrapped key holds `init`'s invocation — a Python or Go adoption —
|
|
1595
|
+
* with its whole toolchain ungraded, which is Finding 66; grading the wrapper bodies too is what
|
|
1596
|
+
* closes it, and keeping the value arm is what still grades `npm` on a Node adoption through
|
|
1597
|
+
* `commands.build`'s raw line.
|
|
1598
|
+
*
|
|
1599
|
+
* **`deploy` is skipped here, and the skip lives in this function rather than in the derivation** —
|
|
1600
|
+
* its command line lives on `deploy.command` rather than in `commands`, and no daemon-launched run
|
|
1601
|
+
* deploys, which is this check's question and not the derivation's. Every entry keyed
|
|
1602
|
+
* `deploy.command` is dropped from all three classes this check consumes.
|
|
1603
|
+
*
|
|
1604
|
+
* **{@link CommandHeads}' classes map onto this check's return.** `graded` folds into the binary
|
|
1605
|
+
* list; `pathHeads` are dropped silently, being paths the unit's `WorkingDirectory` and the
|
|
1606
|
+
* filesystem decide rather than any `PATH`; `ungraded` and `absent` pass through as the report
|
|
1607
|
+
* strings both result sentences carry, because a check that cannot derive a head must not go on
|
|
1608
|
+
* claiming it graded every binary a run invokes, and must not invent one it then warns about.
|
|
1609
|
+
*
|
|
1610
|
+
* **A wrapper that is not there is returned apart, in `absent`.** The ungraded three share one
|
|
1611
|
+
* remedy — repair the line — and a file that does not exist has no line to repair, so folding
|
|
1612
|
+
* `ENOENT` in with them printed an unfollowable instruction over the one state `init` clears by
|
|
1613
|
+
* itself (Finding 1).
|
|
1614
|
+
*/
|
|
1615
|
+
function requiredBinaries(config, repoRoot, unitAgentCli) {
|
|
1616
|
+
const found = new Map();
|
|
1617
|
+
const ungraded = [];
|
|
1618
|
+
// Named `absent` rather than `missing`: in this module a *missing* binary is one the unit's PATH
|
|
1619
|
+
// does not reach, which is a different finding with a different remedy.
|
|
1620
|
+
const absent = [];
|
|
1621
|
+
const add = (name, source) => {
|
|
1622
|
+
if (name === '' || name.includes('/'))
|
|
1623
|
+
return;
|
|
1624
|
+
const sources = found.get(name);
|
|
1625
|
+
if (sources === undefined)
|
|
1626
|
+
found.set(name, [source]);
|
|
1627
|
+
else if (!sources.includes(source))
|
|
1628
|
+
sources.push(source);
|
|
1629
|
+
};
|
|
1630
|
+
const agent = unitAgentCli?.trim() ?? '';
|
|
1631
|
+
add('git', 'every outer-loop script');
|
|
1632
|
+
add('jq', "the outer-loop scripts' configuration reads");
|
|
1633
|
+
add(agent === '' ? DEFAULT_AGENT_CLI : agent, agent === ''
|
|
1634
|
+
? `the watcher's agent binary (${AGENT_CLI_VARIABLE} unset in the unit, so the watcher's default)`
|
|
1635
|
+
: `the watcher's agent binary (${AGENT_CLI_VARIABLE}, as the unit sets it)`);
|
|
1636
|
+
// `deploy` is this check's to skip, on both arms: its command line lives on `deploy.command`
|
|
1637
|
+
// rather than in `commands`, and no daemon-launched run deploys. {@link commandHeads} derives it
|
|
1638
|
+
// for its other consumer, which does grade it.
|
|
1639
|
+
const heads = commandHeads(config, repoRoot);
|
|
1640
|
+
const kept = (entries) => entries.filter((entry) => entry.key !== configKeyPath('deploy'));
|
|
1641
|
+
// `pathHeads` are dropped here rather than reported: a head with a `/` in it is resolved against
|
|
1642
|
+
// the unit's `WorkingDirectory` and the filesystem, so no `PATH` grades it.
|
|
1643
|
+
for (const { head, source } of kept(heads.graded))
|
|
1644
|
+
add(head, source);
|
|
1645
|
+
ungraded.push(...kept(heads.ungraded).map(({ text }) => text));
|
|
1646
|
+
absent.push(...kept(heads.absent).map(({ text }) => text));
|
|
1647
|
+
return { binaries: [...found].map(([name, sources]) => ({ name, sources })), ungraded, absent };
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* The clauses both result sentences carry when a command line yielded no binary name.
|
|
1651
|
+
*
|
|
1652
|
+
* Two of them, because the remedy differs by reason and a printed remedy an operator cannot perform
|
|
1653
|
+
* is worse than none: {@link describeUnreducible} covers the lines that were read and would not
|
|
1654
|
+
* reduce, {@link describeAbsentWrappers} the wrappers no file backs. Both are appended to the `pass`
|
|
1655
|
+
* sentence as well as the `warn` one, and the `pass` sentence's universal claim is qualified in the
|
|
1656
|
+
* same breath: a check that could not reduce one of the lines it grades has not graded every binary
|
|
1657
|
+
* a run invokes and must not say it has.
|
|
1658
|
+
*/
|
|
1659
|
+
function describeUngraded(ungraded, absent) {
|
|
1660
|
+
const unreducible = describeUnreducible(ungraded);
|
|
1661
|
+
return `${unreducible}${describeAbsentWrappers(absent, unreducible !== '')}`;
|
|
1662
|
+
}
|
|
1663
|
+
/**
|
|
1664
|
+
* The clause for a command line that was read and yielded no binary name — an unreadable or
|
|
1665
|
+
* unrecognised wrapper, and a line {@link commandHead} reduces to no command.
|
|
1666
|
+
*
|
|
1667
|
+
* All three are repaired in the file, so they share the one remedy this sentence prints. Naming only
|
|
1668
|
+
* the first would send an operator to repair a forwarding line that is already correct.
|
|
1669
|
+
*/
|
|
1670
|
+
function describeUnreducible(ungraded) {
|
|
1671
|
+
if (ungraded.length === 0)
|
|
1672
|
+
return '';
|
|
1673
|
+
const one = ungraded.length === 1;
|
|
1674
|
+
return `. ${nameList(ungraded)} ${one ? 'is' : 'are'} **not** graded here: no binary name could be derived from ${one ? 'that command line' : 'those command lines'} — ${one ? 'it' : 'they'} could not be read, or ${one ? 'it is' : 'they are'} headed by a shell builtin or a compound rather than by a command \`PATH\` resolves — so whatever ${one ? 'it runs is' : 'they run are'} outside this comparison and a run may still fail there. Reduce ${one ? 'it' : 'them'} to one unindented \`<command> "$@"\` whose first word, after any leading \`VAR=value\`, is the binary itself, or read ${one ? 'it' : 'them'} yourself, then re-run \`${CLI} doctor\``;
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1677
|
+
* The clause for a wrapper the config invokes and no file backs.
|
|
1678
|
+
*
|
|
1679
|
+
* It is separate from {@link describeUnreducible} because neither half of that remedy can be
|
|
1680
|
+
* performed on a file that is not there: there is no line to reduce and nothing to read. The remedy
|
|
1681
|
+
* here is `init`'s, phrased as {@link watcherMissingMessage} phrases the same state for the sibling
|
|
1682
|
+
* asset it writes create-if-absent. It says more than "ungraded" because a wrapper that is gone is
|
|
1683
|
+
* not only outside this comparison — the config points every dispatch of that command at it, and the
|
|
1684
|
+
* permission profile allow-lists the same absent path — and this parenthetical is the whole report's
|
|
1685
|
+
* only mention of the fact: `command-wrappers` grades the configured value, never the file.
|
|
1686
|
+
*
|
|
1687
|
+
* `after` is whether {@link describeUnreducible} emitted a clause ahead of this one, which decides
|
|
1688
|
+
* only the "either" — this clause is also the whole ungraded report when no line failed to reduce.
|
|
1689
|
+
*/
|
|
1690
|
+
function describeAbsentWrappers(absent, after) {
|
|
1691
|
+
if (absent.length === 0)
|
|
1692
|
+
return '';
|
|
1693
|
+
const one = absent.length === 1;
|
|
1694
|
+
return `. ${nameList(absent)} ${one ? 'is' : 'are'} **not** graded here${after ? ' either' : ''}, because ${one ? 'that wrapper file does not exist' : 'those wrapper files do not exist'}: ${one ? 'the key names a wrapper no file backs, so every dispatch of that command fails' : 'the keys name wrappers no files back, so every dispatch of those commands fails'} and the permission profile allow-lists ${one ? 'an absent path' : 'absent paths'}. Re-run \`${CLI} init\`, which writes wrappers create-if-absent, to put ${one ? 'it' : 'them'} back, then re-run \`${CLI} doctor\``;
|
|
1695
|
+
}
|
|
1696
|
+
/** `npm (commands.test, commands.build; on this machine in /opt/homebrew/bin)` — one missing binary. */
|
|
1697
|
+
function describeMissing(binary) {
|
|
1698
|
+
const directory = locateOnPath(binary.name, process.env['PATH'] ?? '');
|
|
1699
|
+
const where = directory === undefined
|
|
1700
|
+
? "not on this shell's PATH either, so it is installed somewhere neither the daemon nor you resolve it from"
|
|
1701
|
+
: `on this machine in ${directory}`;
|
|
1702
|
+
return `${binary.name} (${binary.sources.join(', ')}; ${where})`;
|
|
1703
|
+
}
|
|
1704
|
+
/**
|
|
1705
|
+
* The remedy for a unit whose `PATH` is wrong or absent: re-render it, then make the service manager
|
|
1706
|
+
* re-read it. `daemon install` writes create-if-absent, so `--force` is the half that replaces an
|
|
1707
|
+
* installed unit, and `daemon stop` first is what lets a launchd agent be bootstrapped again.
|
|
1708
|
+
*/
|
|
1709
|
+
function reinstallAdvice(kind) {
|
|
1710
|
+
const reload = kind === 'systemd'
|
|
1711
|
+
? 'and run the `systemctl --user daemon-reload` and enable lines it prints, which it prints rather than runs'
|
|
1712
|
+
: 'which boots the rewritten agent back in with `launchctl bootstrap`';
|
|
1713
|
+
return `re-render the unit from a shell whose PATH does reach the toolchain: \`${CLI} daemon stop\`, then \`${CLI} daemon install --force\`, ${reload}`;
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1716
|
+
* Does the `PATH` in the **installed unit file** reach the binaries an unattended run invokes?
|
|
1717
|
+
*
|
|
1718
|
+
* **What this grades is a file rather than this machine, and the asymmetry below is why it has to be.**
|
|
1719
|
+
* `doctor` runs in a login shell, where the adopter's package manager and agent CLI resolve by
|
|
1720
|
+
* construction; the daemon runs on the service manager's environment, where on macOS only
|
|
1721
|
+
* `/usr/bin:/bin:/usr/sbin:/sbin` do. `daemon/units.ts` choice 5 captures the installing shell's
|
|
1722
|
+
* `PATH` into the unit to close that gap, and its one stated weakness is that the captured value
|
|
1723
|
+
* goes stale when the toolchain moves. That staleness is
|
|
1724
|
+
* only ever discovered by a real unattended run failing at its first configured command, which is
|
|
1725
|
+
* what this check exists to pre-empt. What that file's `PATH` is graded against now includes the
|
|
1726
|
+
* wrappers it has to reach: for every wrapped `commands.*` key {@link requiredBinaries} reads the
|
|
1727
|
+
* command line inside the wrapper the key invokes, so the comparison is against the project's own
|
|
1728
|
+
* toolchain rather than the `bash` that launches it.
|
|
1729
|
+
*
|
|
1730
|
+
* Nothing here is re-derived (this module's choice 1): the backend is `daemon/backend.ts`'s, the
|
|
1731
|
+
* unit's install path comes from {@link renderUnit} rather than being composed here — the rule
|
|
1732
|
+
* `daemon/units.ts`'s header states — and the resolution is {@link resolvesOnPath}, given the unit's
|
|
1733
|
+
* `PATH` instead of this process's, and the agent binary it resolves is the unit's too. The render is asked for its `targetPath` only, so it is passed
|
|
1734
|
+
* **no** `envPath`: the text this check would render is not the text it grades.
|
|
1735
|
+
*
|
|
1736
|
+
* **It never fails.** A foreground run is unaffected, the daemon is opt-in, and the remedy is an
|
|
1737
|
+
* operator step — the same shape of reason {@link REPO_REGISTRY_CHECK} never fails for. Two states are
|
|
1738
|
+
* not findings at all and pass silently: a host with no service manager, and a checkout no daemon
|
|
1739
|
+
* has been installed from. The second is `repo-registry`'s line to report, one above this one, and
|
|
1740
|
+
* a second warning about it would double every ordinary foreground repository's report.
|
|
1741
|
+
*/
|
|
1742
|
+
const DAEMON_PATH_CHECK = {
|
|
1743
|
+
id: 'daemon-path',
|
|
1744
|
+
title: "the installed daemon's PATH reaches the binaries the outer loop runs",
|
|
1745
|
+
run: (ctx) => {
|
|
1746
|
+
if (ctx.repoRoot === undefined)
|
|
1747
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
1748
|
+
if (ctx.config === undefined) {
|
|
1749
|
+
return unevaluated(`${CONFIG_FILENAME} could not be read, so which binaries a run invokes is unknown (see the config check)`);
|
|
1750
|
+
}
|
|
1751
|
+
const backend = detectBackend();
|
|
1752
|
+
if (backend.kind === 'none') {
|
|
1753
|
+
return pass(`not graded, because no daemon can be installed on this host and so none carries a PATH: ${backend.reason}`);
|
|
1754
|
+
}
|
|
1755
|
+
const watcher = resolveWatcherPath({ repoRoot: ctx.repoRoot, config: ctx.config });
|
|
1756
|
+
const unit = renderUnit({
|
|
1757
|
+
backend,
|
|
1758
|
+
config: ctx.config,
|
|
1759
|
+
repoRoot: ctx.repoRoot,
|
|
1760
|
+
watcherPath: watcher.path,
|
|
1761
|
+
envPath: undefined,
|
|
1762
|
+
});
|
|
1763
|
+
let text;
|
|
1764
|
+
try {
|
|
1765
|
+
text = readFileSync(unit.targetPath, 'utf8');
|
|
1766
|
+
}
|
|
1767
|
+
catch (error) {
|
|
1768
|
+
if (error?.code === 'ENOENT') {
|
|
1769
|
+
return pass(`not graded, because no ${backend.kind} unit is installed at ${unit.targetPath}: no daemon runs from this checkout, so there is no PATH to compare — whether one should be installed is the repo-registry line above, reported there once`);
|
|
1770
|
+
}
|
|
1771
|
+
return warn(`the ${backend.kind} unit for ${unit.label} is at ${unit.targetPath} and could not be read (${messageOf(error)}), so the PATH it gives the daemon is unknown: read it yourself, or ${reinstallAdvice(backend.kind)}`);
|
|
1772
|
+
}
|
|
1773
|
+
const { binaries: required, ungraded, absent } = requiredBinaries(ctx.config, ctx.repoRoot, unitEnvValue(backend.kind, text, AGENT_CLI_VARIABLE));
|
|
1774
|
+
const envPath = unitEnvValue(backend.kind, text, 'PATH');
|
|
1775
|
+
if (envPath === undefined) {
|
|
1776
|
+
return warn(`the ${backend.kind} unit for ${unit.label} at ${unit.targetPath} carries no PATH, so the daemon runs on the service manager's own default directories — the state every unit installed before this key shipped is in, and on macOS that is /usr/bin:/bin:/usr/sbin:/sbin, where a Homebrew package manager and an agent CLI under ~/.local/bin both fail to resolve. An unattended run then dies at its first configured command with \`command not found\` while a foreground run is fine, because your shell's PATH is not this. To fix it, ${reinstallAdvice(backend.kind)}`);
|
|
1777
|
+
}
|
|
1778
|
+
const missing = required.filter((binary) => !resolvesOnPath(binary.name, envPath));
|
|
1779
|
+
if (missing.length > 0) {
|
|
1780
|
+
return warn(`the ${backend.kind} unit for ${unit.label} at ${unit.targetPath} gives the daemon a PATH that does not reach ${missing.length === 1 ? 'a binary' : `${missing.length} binaries`} an unattended run invokes by name: ${nameList(missing.map(describeMissing))}. The captured PATH has gone stale, or the toolchain moved after the daemon was installed; a run dispatched by this daemon fails at that command and, once that one is repaired by hand, at the next. To fix it, ${reinstallAdvice(backend.kind)}${describeUngraded(ungraded, absent)}`);
|
|
1781
|
+
}
|
|
1782
|
+
return pass(`the ${backend.kind} unit for ${unit.label} at ${unit.targetPath} carries a PATH reaching every binary an unattended run invokes by name${ungraded.length + absent.length > 0 ? ' that could be derived here' : ''}, each with what requires it: ${nameList(required.map((binary) => `${binary.name} (${binary.sources.join(', ')})`))}. This grades that file rather than this machine — the shell doctor runs in resolves what a service manager's environment does not${describeUngraded(ungraded, absent)}`);
|
|
1783
|
+
},
|
|
1784
|
+
};
|
|
1785
|
+
/**
|
|
1786
|
+
* The config's shape, re-checked at runtime.
|
|
1787
|
+
*
|
|
1788
|
+
* The reason it is checked again at all is `docs/config.md` §3's: a config can be edited by hand
|
|
1789
|
+
* after the schema gate validated it, and every later check here reads values out of it. The
|
|
1790
|
+
* severities are `config/check.ts`'s own — an `error` blocks a write and fails here, a `warning`
|
|
1791
|
+
* (a `commands.*` placeholder the adopter never replaced, a phase that is on with its section
|
|
1792
|
+
* unfilled) is reported and does not stop anything.
|
|
1793
|
+
*/
|
|
1794
|
+
const CONFIG_CHECK = {
|
|
1795
|
+
id: 'config',
|
|
1796
|
+
title: `${CONFIG_FILENAME} is present and structurally valid`,
|
|
1797
|
+
run: (ctx) => {
|
|
1798
|
+
if (ctx.repoRoot === undefined)
|
|
1799
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
1800
|
+
const errors = ctx.configProblems.filter((problem) => problem.severity === 'error');
|
|
1801
|
+
if (errors.length > 0)
|
|
1802
|
+
return fail(errors.map(formatProblem).join('; '));
|
|
1803
|
+
const warnings = ctx.configProblems.filter((problem) => problem.severity === 'warning');
|
|
1804
|
+
if (warnings.length > 0)
|
|
1805
|
+
return warn(warnings.map(formatProblem).join('; '));
|
|
1806
|
+
return pass(`${ctx.configPath} is present and every key it declares has the shape the schema states`);
|
|
1807
|
+
},
|
|
1808
|
+
};
|
|
1809
|
+
/**
|
|
1810
|
+
* The clause the two command checks append when a key answered `<none>` is why they graded less.
|
|
1811
|
+
*
|
|
1812
|
+
* **Both pass sentences of both checks**, for {@link UNRESOLVED_BODY_CLAUSE}'s reason: a repository
|
|
1813
|
+
* where another wrapped key holds a command line reaches the graded sentence, and a clause carried
|
|
1814
|
+
* only by the *not graded* one would leave the answered key named by no check anywhere in the report
|
|
1815
|
+
* — which is every ordinary adoption, `preset-php` included.
|
|
1816
|
+
*
|
|
1817
|
+
* One spelling, because those sentences say the same thing and an adopter comparing two
|
|
1818
|
+
* lines must not have to decide whether two wordings mean two states. It names the answered keys
|
|
1819
|
+
* apart from the unfilled ones so the line distinguishes **answered: none** from **not yet
|
|
1820
|
+
* answered** without the reader opening the config, and it says no other check reports the state —
|
|
1821
|
+
* `config/check.ts` passes it silently, which is what *answered* means.
|
|
1822
|
+
*/
|
|
1823
|
+
function describeAnsweredNone(keyPaths) {
|
|
1824
|
+
if (keyPaths.length === 0)
|
|
1825
|
+
return '';
|
|
1826
|
+
return `; ${nameList(keyPaths)} answers \`${COMMAND_NONE_SENTINEL}\` — this repository has no such command, which is an answer rather than an unfilled key, so no check reports it as one`;
|
|
1827
|
+
}
|
|
1828
|
+
/**
|
|
1829
|
+
* Does every wrapped `commands.*` key hold its wrapper invocation rather than a raw command line?
|
|
1830
|
+
*
|
|
1831
|
+
* The state this reports is the one a nested application's hand-fill path produces: `init` could not
|
|
1832
|
+
* detect a command, the adopter filled the key in with a real command line, and the permission
|
|
1833
|
+
* profile allow-lists the **wrapper** that line was then inlined into rather than the line itself. An
|
|
1834
|
+
* unattended run refuses every dispatch of that command — 25 `permission_denied` events in one
|
|
1835
|
+
* measured run — while nothing in the repository's own report mentions it. The condition is not
|
|
1836
|
+
* restated here: it is {@link wrappedKeyMismatch}'s and the sentence is
|
|
1837
|
+
* {@link wrappedKeyMismatchMessage}'s, so `init`, `config set` and this check cannot come to state one
|
|
1838
|
+
* defect three ways (this module's choice 1).
|
|
1839
|
+
*
|
|
1840
|
+
* **A warning, never a failure.** The repository still runs in the foreground, an agent that hits the
|
|
1841
|
+
* refusal can reach the wrapper by hand, and the remedy is one `config set` — the same shape of
|
|
1842
|
+
* reason {@link PROFILE_PATHS_CHECK} and {@link PLUGIN_PERMISSIONS_CHECK} never fail for.
|
|
1843
|
+
*
|
|
1844
|
+
* **(i) Disjoint from `config`, and from the `command-permissions` check beside it.** It asks
|
|
1845
|
+
* neither whether a key still holds `init`'s placeholder — that is {@link CONFIG_CHECK}'s, through
|
|
1846
|
+
* `config/check.ts`'s own warning — nor whether the wrapper exists under `scriptsDir` and the profile
|
|
1847
|
+
* carries its three allow entries, which is {@link COMMAND_PERMISSIONS_CHECK}'s. It also does not
|
|
1848
|
+
* grade `commands.typecheck` answered `<none>`: that key holds no command line, `config/check.ts`
|
|
1849
|
+
* passes it, and this check names it in either of its pass sentences rather than warning. On the repository
|
|
1850
|
+
* Finding 65 measured, both of those answer clean: the wrapper exists, all six entries are present,
|
|
1851
|
+
* and the configured string is refused anyway. Neither check subsumes the other.
|
|
1852
|
+
*
|
|
1853
|
+
* **(ii) The `daemon-path` cross-reference.** The wrapper form this check pushes adopters toward is
|
|
1854
|
+
* the form {@link DAEMON_PATH_CHECK} grades: {@link requiredBinaries} reads the command line inside
|
|
1855
|
+
* the wrapper a `commands.*` key invokes and takes *its* head, so such a key contributes the
|
|
1856
|
+
* project's toolchain rather than the `bash` that runs it (Finding 66). The two stay disjoint in the
|
|
1857
|
+
* same way as (i) — this one grades the configured **value**, that one the **file** the value names —
|
|
1858
|
+
* and reporting a raw line still rewrites nothing.
|
|
1859
|
+
*/
|
|
1860
|
+
const COMMAND_WRAPPERS_CHECK = {
|
|
1861
|
+
id: 'command-wrappers',
|
|
1862
|
+
title: 'every wrapped commands.* key holds its wrapper invocation',
|
|
1863
|
+
run: (ctx) => {
|
|
1864
|
+
if (ctx.repoRoot === undefined)
|
|
1865
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
1866
|
+
if (ctx.config === undefined)
|
|
1867
|
+
return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
|
|
1868
|
+
const graded = [];
|
|
1869
|
+
const findings = [];
|
|
1870
|
+
// The ungraded keys are collected in two lists rather than one: the pass sentence has to say
|
|
1871
|
+
// which are unfilled and which are answered, or a reader cannot tell the two apart.
|
|
1872
|
+
const unfilled = [];
|
|
1873
|
+
const answered = [];
|
|
1874
|
+
for (const { key } of WRAPPER_SCRIPTS) {
|
|
1875
|
+
// {@link selectWrapper} rather than a second read of the config: it is the exported accessor
|
|
1876
|
+
// that already knows `deploy` reads its command line off its own object, and it separates the
|
|
1877
|
+
// three states this check does not grade — a key carrying nothing, one still holding the
|
|
1878
|
+
// placeholder, and `typecheck` answered `<none>` — from the one it does. An answered key holds
|
|
1879
|
+
// no command line, so there is no invocation to grade against its wrapper.
|
|
1880
|
+
const { placeholder, answeredNone, configured } = selectWrapper(ctx.config, key);
|
|
1881
|
+
if (answeredNone) {
|
|
1882
|
+
answered.push(configKeyPath(key));
|
|
1883
|
+
continue;
|
|
1884
|
+
}
|
|
1885
|
+
if (configured === undefined || placeholder) {
|
|
1886
|
+
unfilled.push(configKeyPath(key));
|
|
1887
|
+
continue;
|
|
1888
|
+
}
|
|
1889
|
+
const mismatch = wrappedKeyMismatch(ctx.config, key);
|
|
1890
|
+
if (mismatch === undefined)
|
|
1891
|
+
graded.push(`${configKeyPath(key)} → ${configured}`);
|
|
1892
|
+
else
|
|
1893
|
+
findings.push(wrappedKeyMismatchMessage(key, mismatch));
|
|
1894
|
+
}
|
|
1895
|
+
if (findings.length > 0)
|
|
1896
|
+
return warn(findings.join('; '));
|
|
1897
|
+
if (graded.length === 0) {
|
|
1898
|
+
const unfilledClause = unfilled.length === 0
|
|
1899
|
+
? ''
|
|
1900
|
+
: `: each of ${nameList(unfilled)} is unset or still holds the placeholder init wrote, which is the config check's line and is reported there once`;
|
|
1901
|
+
return pass(`not graded, because no wrapped key holds a command line${unfilledClause}${describeAnsweredNone(answered)}`);
|
|
1902
|
+
}
|
|
1903
|
+
return pass(`${graded.join(', ')} — each holds the invocation of the wrapper the permission profile allow-lists, so an agent that runs the configured string exactly as written runs an allow-listed path${describeAnsweredNone(answered)}`);
|
|
1904
|
+
},
|
|
1905
|
+
};
|
|
1906
|
+
/** One list's missing entries under a heading, so a paste lands in the right list. */
|
|
1907
|
+
function renderListGroup(list, entries) {
|
|
1908
|
+
return `permissions.${list}:\n${entries.join('\n')}`;
|
|
1909
|
+
}
|
|
1910
|
+
/**
|
|
1911
|
+
* Does the profile carry the entries a **filled** `commands.*` key implies — and is the wrapper those
|
|
1912
|
+
* entries name actually there?
|
|
1913
|
+
*
|
|
1914
|
+
* **The state this reports is invisible to every other check.** `init` writes the wrapper allow
|
|
1915
|
+
* entries from the commands it **detected**, so a repository where it detected none gets placeholders
|
|
1916
|
+
* and no entries, and a key filled in afterwards — by hand, or by a run that supplied the command
|
|
1917
|
+
* itself — adds none. Nothing regenerates the profile on an ordinary re-run: that needs
|
|
1918
|
+
* `init --force`. Measured on one such adoption, `permissions.allow` carried no `test.sh` or
|
|
1919
|
+
* `typecheck.sh` entry where a normally-detected repository carries six, and the report was clean.
|
|
1920
|
+
* What it costs is the failure the profile exists to prevent — a command matching neither `allow` nor
|
|
1921
|
+
* `deny` does not prompt in print mode, it hangs.
|
|
1922
|
+
*
|
|
1923
|
+
* **The expectation is re-derived, never restated.** {@link renderProfile}, the producer `init` itself
|
|
1924
|
+
* uses, is called with no `written` list — so the wrapper selection falls back to
|
|
1925
|
+
* `selectWrapperScripts` — and with {@link workRoot}'s value, the same default
|
|
1926
|
+
* `writePermissionProfile` applies for a caller that supplies none; the entries naming each wrapper's
|
|
1927
|
+
* file are then read back out of what it produced. So this check cannot come to disagree with the
|
|
1928
|
+
* generator, and the three-forms-per-script invariant is asserted here from the outside instead of
|
|
1929
|
+
* being kept as a second copy of the forms (this module's choice 1).
|
|
1930
|
+
*
|
|
1931
|
+
* **Each list is compared against its own.** A rendered `allow` entry is looked for in `allow` and a
|
|
1932
|
+
* rendered `ask` entry — which the deploy wrapper's three are — in `ask`, never in a union of the two:
|
|
1933
|
+
* `ask` is evaluated before `allow`, so a wrapper entry that moved into it is the stall rather than
|
|
1934
|
+
* the grant, and which list a row belongs in stays the template's statement rather than a second one
|
|
1935
|
+
* here.
|
|
1936
|
+
*
|
|
1937
|
+
* **Disjoint from both neighbours, and the three read in one order: the slot, the value, the
|
|
1938
|
+
* entries.** {@link CONFIG_CHECK} grades a key still holding `init`'s placeholder — this check skips
|
|
1939
|
+
* exactly those keys, the unset ones and `commands.typecheck` answered `<none>`, and says which is
|
|
1940
|
+
* which in either of its pass sentences, since an answered key implies no wrapper and so no entry to
|
|
1941
|
+
* be missing — and
|
|
1942
|
+
* {@link COMMAND_WRAPPERS_CHECK} grades a key holding a raw command line rather than its wrapper
|
|
1943
|
+
* invocation. A repository answers both of those
|
|
1944
|
+
* clean and still fails this one: the measured repository holds the invocation, in a valid config,
|
|
1945
|
+
* with no entry for it anywhere in the profile.
|
|
1946
|
+
*
|
|
1947
|
+
* **A `warn`, never a `fail`**, for {@link PROFILE_PATHS_CHECK}'s and
|
|
1948
|
+
* {@link PLUGIN_PERMISSIONS_CHECK}'s reason: the repository still runs in the foreground, and the
|
|
1949
|
+
* profile is a file the adopter owns. **The absent wrapper and the missing entries are two sentences**
|
|
1950
|
+
* because they are two repairs — one is `init` writing a file it writes create-if-absent, the other
|
|
1951
|
+
* `init --force` regenerating a file an unforced run will not touch — and a merged list prints an
|
|
1952
|
+
* instruction that cannot be followed.
|
|
1953
|
+
*
|
|
1954
|
+
* **A profile generated for another checkout answers as missing everything**, which is true and is
|
|
1955
|
+
* {@link PROFILE_PATHS_CHECK}'s finding stated in the specific. Both name `init --force`, so a reader
|
|
1956
|
+
* acting on either line is acting on both.
|
|
1957
|
+
*
|
|
1958
|
+
* **Nothing underivable is guessed at.** No root, no config and no readable profile each report
|
|
1959
|
+
* `unevaluated` naming the check that owns that input — and so does a {@link renderProfile} that
|
|
1960
|
+
* throws, whether on an adopter value it cannot build an entry from or on a packaging fault, because a
|
|
1961
|
+
* check that could not derive the expectation may not report the profile as wrong.
|
|
1962
|
+
*/
|
|
1963
|
+
const COMMAND_PERMISSIONS_CHECK = {
|
|
1964
|
+
id: 'command-permissions',
|
|
1965
|
+
title: 'the permission profile allow-lists the wrapper every filled commands.* key invokes',
|
|
1966
|
+
run: (ctx) => {
|
|
1967
|
+
const { repoRoot, config, profile } = ctx;
|
|
1968
|
+
if (repoRoot === undefined)
|
|
1969
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
1970
|
+
if (config === undefined)
|
|
1971
|
+
return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
|
|
1972
|
+
if (profile === undefined) {
|
|
1973
|
+
return unevaluated(`${PROFILE_PATH} could not be read (see the permission-profile check)`);
|
|
1974
|
+
}
|
|
1975
|
+
// {@link selectWrapper} rather than a second read of the config, for {@link COMMAND_WRAPPERS_CHECK}'s
|
|
1976
|
+
// reason: it is the exported accessor that knows `deploy` reads its line off its own object, and it
|
|
1977
|
+
// separates the three states this check does not grade from the one it does. An answered-`none` key
|
|
1978
|
+
// is not filled: it implies no wrapper and so no entry to look for.
|
|
1979
|
+
const filled = WRAPPER_SCRIPTS.filter(({ key }) => {
|
|
1980
|
+
const { placeholder, answeredNone, configured } = selectWrapper(config, key);
|
|
1981
|
+
return configured !== undefined && !placeholder && !answeredNone;
|
|
1982
|
+
});
|
|
1983
|
+
const answered = WRAPPER_SCRIPTS.filter(({ key }) => selectWrapper(config, key).answeredNone).map(({ key }) => configKeyPath(key));
|
|
1984
|
+
if (filled.length === 0) {
|
|
1985
|
+
const unfilled = WRAPPER_SCRIPTS.map(({ key }) => configKeyPath(key)).filter((path) => !answered.includes(path));
|
|
1986
|
+
const unfilledClause = unfilled.length === 0
|
|
1987
|
+
? ''
|
|
1988
|
+
: `: each of ${nameList(unfilled)} is unset or still holds the placeholder init wrote, which is the config check's line and is reported there once`;
|
|
1989
|
+
return pass(`not graded, because no wrapped key holds a command line and so none implies a permission entry${unfilledClause}${describeAnsweredNone(answered)}`);
|
|
1990
|
+
}
|
|
1991
|
+
let expected;
|
|
1992
|
+
try {
|
|
1993
|
+
expected = renderProfile({ repoRoot, config, workRoot: workRoot(repoRoot) });
|
|
1994
|
+
}
|
|
1995
|
+
catch (error) {
|
|
1996
|
+
return unevaluated(`the profile this config implies could not be rendered (${messageOf(error)}), so what ${PROFILE_PATH} owes for ${nameList(filled.map(({ key }) => configKeyPath(key)))} could not be derived: a check that cannot derive the expectation must not report the profile as wrong`);
|
|
1997
|
+
}
|
|
1998
|
+
const scriptsDir = outerLoopScriptsDir(config);
|
|
1999
|
+
const absent = [];
|
|
2000
|
+
const graded = [];
|
|
2001
|
+
const missing = [];
|
|
2002
|
+
let owed = 0;
|
|
2003
|
+
for (const { key, file } of filled) {
|
|
2004
|
+
const path = posix.join(scriptsDir, file);
|
|
2005
|
+
graded.push(`${configKeyPath(key)} → ${path}`);
|
|
2006
|
+
if (!isReadableFile(join(repoRoot, path)))
|
|
2007
|
+
absent.push(`${configKeyPath(key)} → ${path}`);
|
|
2008
|
+
for (const list of PERMISSION_LISTS) {
|
|
2009
|
+
const held = permissionEntries(profile, list);
|
|
2010
|
+
for (const entry of permissionEntries(expected, list).filter((rendered) => rendered.includes(file))) {
|
|
2011
|
+
owed += 1;
|
|
2012
|
+
if (!held.includes(entry))
|
|
2013
|
+
missing.push({ list, entry });
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
const keys = `${filled.length} filled wrapped ${filled.length === 1 ? 'key' : 'keys'} (${nameList(filled.map(({ key }) => configKeyPath(key)))})`;
|
|
2018
|
+
// Its own sentence, and first: a wrapper that is not there is repaired by a different command than
|
|
2019
|
+
// an entry that is not there, and the entry lines below have to stay last for a paste to work.
|
|
2020
|
+
const one = absent.length === 1;
|
|
2021
|
+
const absentSentence = absent.length === 0
|
|
2022
|
+
? ''
|
|
2023
|
+
: `${absent.length} of the ${filled.length} wrapper ${filled.length === 1 ? 'file' : 'files'} this repository's filled wrapped keys name ${one ? 'is' : 'are'} not readable under ${scriptsDir} — ${nameList(absent)} — so every entry naming ${one ? 'it' : 'them'} grants a path with no file at it, which is a repair of its own. Re-run \`${CLI} init\`, which writes each wrapper create-if-absent from the line its key holds.`;
|
|
2024
|
+
if (missing.length > 0) {
|
|
2025
|
+
const blocks = PERMISSION_LISTS.map((list) => ({
|
|
2026
|
+
list,
|
|
2027
|
+
entries: missing.filter((item) => item.list === list).map((item) => item.entry),
|
|
2028
|
+
}))
|
|
2029
|
+
.filter(({ entries }) => entries.length > 0)
|
|
2030
|
+
.map(({ list, entries }) => renderListGroup(list, entries));
|
|
2031
|
+
return warn(`${absentSentence}${absentSentence === '' ? '' : ' '}${PROFILE_PATH} is missing ${missing.length} of the ${owed} permission ${entryWord(owed)} this repository's ${keys} imply, so a command an agent runs from one of them matches neither allow nor deny and an unattended run hangs there with no diagnostic rather than failing. \`${CLI} init\` writes these entries from the commands it detected, so a key filled in afterwards carries none of them and no ordinary re-run adds them. Run \`${CLI} init --force\`, which regenerates the profile from the config as it now stands after copying the current one to a .bak sibling and carrying the plugin-root entries forward — or add each line below to that list in ${PROFILE_PATH} as its own string, unquoted exactly as it stands:\n${blocks.join('\n\n')}`);
|
|
2032
|
+
}
|
|
2033
|
+
if (absentSentence !== '') {
|
|
2034
|
+
return warn(`${absentSentence} ${PROFILE_PATH} carries all ${owed} permission ${entryWord(owed)} this repository's ${keys} imply, so the entries are right and the ${one ? 'file is' : 'files are'} what is missing`);
|
|
2035
|
+
}
|
|
2036
|
+
return pass(`${PROFILE_PATH} carries all ${owed} permission ${entryWord(owed)} this repository's ${keys} imply, and each names a wrapper that is readable: ${nameList(graded)}${describeAnsweredNone(answered)}`);
|
|
2037
|
+
},
|
|
2038
|
+
};
|
|
2039
|
+
/**
|
|
2040
|
+
* `npm (commands.build, commands.depInstall)` — one head, with every configured line that runs it.
|
|
2041
|
+
*
|
|
2042
|
+
* Grouped by head rather than listed per line, so a package manager three keys reach is one name in
|
|
2043
|
+
* the report and not three; the sources are what say which line to correct, and a `<key> → <path>`
|
|
2044
|
+
* source is the line *inside* that wrapper while a bare key is the value in the config.
|
|
2045
|
+
*/
|
|
2046
|
+
function describeHeadSources(entries) {
|
|
2047
|
+
const found = new Map();
|
|
2048
|
+
for (const { head, source } of entries) {
|
|
2049
|
+
const sources = found.get(head);
|
|
2050
|
+
if (sources === undefined)
|
|
2051
|
+
found.set(head, [source]);
|
|
2052
|
+
else if (!sources.includes(source))
|
|
2053
|
+
sources.push(source);
|
|
2054
|
+
}
|
|
2055
|
+
return [...found].map(([head, sources]) => `${head} (${sources.join(', ')})`);
|
|
2056
|
+
}
|
|
2057
|
+
/**
|
|
2058
|
+
* The clause for a command line whose head carries a `/`.
|
|
2059
|
+
*
|
|
2060
|
+
* Reported rather than resolved, and rather than dropped: a shell resolves such a head against the
|
|
2061
|
+
* filesystem and the directory the command runs in — never through `PATH` — so this check has no
|
|
2062
|
+
* question to ask about it, and warning that it is "not on `PATH`" would be a finding about a line
|
|
2063
|
+
* that may be perfectly correct. `./mvnw` is the form that makes this ordinary rather than exotic.
|
|
2064
|
+
*/
|
|
2065
|
+
function describePathHeads(pathHeads) {
|
|
2066
|
+
if (pathHeads.length === 0)
|
|
2067
|
+
return '';
|
|
2068
|
+
const one = pathHeads.length === 1;
|
|
2069
|
+
return `. ${nameList(describeHeadSources(pathHeads))} ${one ? 'is' : 'are'} **not** graded here: ${one ? 'that head carries a `/`' : 'those heads carry a `/`'}, so a shell resolves ${one ? 'it' : 'them'} against the filesystem and the directory the command runs in rather than through \`PATH\`, and this check decides nothing about ${one ? 'it' : 'them'}`;
|
|
2070
|
+
}
|
|
2071
|
+
/**
|
|
2072
|
+
* The clause both of this check's `pass` sentences carry about the one wrapper state that reaches
|
|
2073
|
+
* neither its graded nor its ungraded list.
|
|
2074
|
+
*
|
|
2075
|
+
* Stated rather than left silent, because a universal claim is only worth what its scope says: a
|
|
2076
|
+
* body {@link wrapperCommandLine} answers `unresolved` for runs no command at all, so there is no
|
|
2077
|
+
* head to resolve and nothing here to report — and a reader who does not know that reads this
|
|
2078
|
+
* check's `pass` as covering a wrapper that runs nothing.
|
|
2079
|
+
*/
|
|
2080
|
+
const UNRESOLVED_BODY_CLAUSE = 'A wrapper whose body runs no command at all yields no head here and is claimed nothing about: the config and command-wrappers lines report that state between them';
|
|
2081
|
+
/**
|
|
2082
|
+
* Does the command every configured line actually runs resolve on the `PATH` this run was given?
|
|
2083
|
+
*
|
|
2084
|
+
* **The three checks above grade the slot, the value and the entries; this one grades what the value
|
|
2085
|
+
* runs.** {@link CONFIG_CHECK} reports a key still holding `init`'s placeholder,
|
|
2086
|
+
* {@link COMMAND_WRAPPERS_CHECK} a key holding a raw line rather than its wrapper invocation, and
|
|
2087
|
+
* {@link COMMAND_PERMISSIONS_CHECK} the entries those keys imply and whether the wrapper file is
|
|
2088
|
+
* readable. A repository answers all three clean while every one of its wrappers exits 127 on every
|
|
2089
|
+
* invocation, because nothing asks whether the command *inside* the wrapper is installed. This asks
|
|
2090
|
+
* it, with `command -v` semantics, over every class {@link commandHeads} grades.
|
|
2091
|
+
*
|
|
2092
|
+
* **All four wrappers, `deploy.sh` included.** The reason {@link DAEMON_PATH_CHECK} leaves `deploy`
|
|
2093
|
+
* out — no daemon-launched run deploys — is that check's and does not hold here: this one grades the
|
|
2094
|
+
* shell `doctor` was typed at, which is the shell a deploy is dispatched from.
|
|
2095
|
+
*
|
|
2096
|
+
* **A `warn`, never a `fail`**, for {@link COMMAND_WRAPPERS_CHECK}'s stated reason: the repository
|
|
2097
|
+
* still runs in the foreground, and installing a tool is an operator step.
|
|
2098
|
+
*
|
|
2099
|
+
* **The three non-graded classes are reported rather than dropped**, each with why it is not graded —
|
|
2100
|
+
* a `/`-headed line is the filesystem's to resolve, a line that reduced to no binary name is
|
|
2101
|
+
* {@link describeUnreducible}'s, and a wrapper no file backs is {@link describeAbsentWrappers}'s —
|
|
2102
|
+
* and the `pass` sentence's universal claim is scoped to the heads that could be derived. An unfilled
|
|
2103
|
+
* `<configure this: …>` value is skipped by construction: {@link commandHeads} drops a value
|
|
2104
|
+
* {@link isPlaceholder} accepts before a head is taken, so such a key contributes nothing here and is
|
|
2105
|
+
* {@link CONFIG_CHECK}'s line.
|
|
2106
|
+
*/
|
|
2107
|
+
const COMMAND_RESOLVES_CHECK = {
|
|
2108
|
+
id: 'command-resolves',
|
|
2109
|
+
title: "the command every configured commands.* line runs resolves on this machine's PATH",
|
|
2110
|
+
run: (ctx) => {
|
|
2111
|
+
if (ctx.repoRoot === undefined)
|
|
2112
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
2113
|
+
if (ctx.config === undefined) {
|
|
2114
|
+
return unevaluated(`${CONFIG_FILENAME} could not be read, so which command each configured line runs is unknown (see the config check)`);
|
|
2115
|
+
}
|
|
2116
|
+
const heads = commandHeads(ctx.config, ctx.repoRoot);
|
|
2117
|
+
const notGraded = `${describePathHeads(heads.pathHeads)}${describeUngraded(heads.ungraded.map(({ text }) => text), heads.absent.map(({ text }) => text))}`;
|
|
2118
|
+
if (heads.graded.length === 0) {
|
|
2119
|
+
return pass(`not graded, because no configured command line reduced to a bare binary name for a \`PATH\` to decide: a key still holding the placeholder init wrote is not a command line, and that is the config check's line, reported there once. ${UNRESOLVED_BODY_CLAUSE}${notGraded}`);
|
|
2120
|
+
}
|
|
2121
|
+
const missing = heads.graded.filter(({ head }) => !resolvesOnPath(head));
|
|
2122
|
+
if (missing.length > 0) {
|
|
2123
|
+
const one = missing.length === 1;
|
|
2124
|
+
return warn(`${one ? 'a command' : `${missing.length} commands`} this repository's configured lines run ${one ? 'does' : 'do'} not resolve on the PATH this run was given: ${nameList(describeHeadSources(missing))}. Every dispatch of ${one ? 'that command' : 'those commands'} fails with \`command not found\`, and an unattended run dies at its first configured command. Two remedies: install the tool, or correct the raw command line the source names — a source spelled \`<key> → <path>\` is the line inside that wrapper, and a bare key is the value in ${CONFIG_FILENAME}. A warning rather than a failure, because the repository still runs in the foreground and installing a tool is an operator step. This grades the shell \`${CLI} doctor\` was typed at; whether the daemon's own environment reaches these binaries is the daemon-path line's question${notGraded}`);
|
|
2125
|
+
}
|
|
2126
|
+
return pass(`every command head this check could derive from this repository's configured lines resolves on the PATH this run was given, each with what runs it: ${nameList(describeHeadSources(heads.graded))}. This grades the shell \`${CLI} doctor\` was typed at rather than a service manager's environment, which is the daemon-path line's question. ${UNRESOLVED_BODY_CLAUSE}${notGraded}`);
|
|
2127
|
+
},
|
|
2128
|
+
};
|
|
2129
|
+
/**
|
|
2130
|
+
* `stateDir`, re-checked against both of the schema's clauses and then actually written into.
|
|
2131
|
+
*
|
|
2132
|
+
* This is the third of the three places `docs/config.md` §3 says the dot rule is enforced, and the
|
|
2133
|
+
* only one that runs against the value the repository *currently* holds. Both clauses are applied
|
|
2134
|
+
* separately, as the schema states them: the character-class `pattern` admits `.` and `/` after the
|
|
2135
|
+
* first character, so it alone would accept `sdlc/../.claude`, and the `(^|/)\.` clause is what
|
|
2136
|
+
* refuses a dot at the start of any segment. The dot clause is applied to the **raw** configured
|
|
2137
|
+
* value, since normalising one would strip a leading `./` that is itself a dot segment.
|
|
2138
|
+
*
|
|
2139
|
+
* Writability is answered by trying, through the write engine's {@link probeWritable}: a permission
|
|
2140
|
+
* bit, a read-only mount and a full filesystem are indistinguishable from a `stat`.
|
|
2141
|
+
*/
|
|
2142
|
+
const STATE_DIR_CHECK = {
|
|
2143
|
+
id: 'state-dir',
|
|
2144
|
+
title: 'the run-artifact directory is not dot-named and can be written into',
|
|
2145
|
+
run: (ctx) => {
|
|
2146
|
+
if (ctx.repoRoot === undefined)
|
|
2147
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
2148
|
+
if (ctx.config === undefined)
|
|
2149
|
+
return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
|
|
2150
|
+
const raw = ctx.config.stateDir;
|
|
2151
|
+
if (typeof raw !== 'string' || raw.trim() === '') {
|
|
2152
|
+
return fail(`stateDir is ${JSON.stringify(raw)} rather than a directory name, so there is no tree to check`);
|
|
2153
|
+
}
|
|
2154
|
+
if (STATE_DIR_DOT_PATTERN.test(raw)) {
|
|
2155
|
+
return fail(`stateDir is ${JSON.stringify(raw)}, which has a path segment starting with '.': the run-artifact tree has to be writable by an unattended run, and a dot-path is where a host reserves directories an unattended run may not write to — measured for .claude/**, where such a run completes with exit 0 having written nothing. Set stateDir in ${CONFIG_FILENAME} to a name without a leading dot; do not "fix" it back to a dot-name`);
|
|
2156
|
+
}
|
|
2157
|
+
if (!STATE_DIR_PATTERN.test(raw)) {
|
|
2158
|
+
return fail(`stateDir is ${JSON.stringify(raw)}, which is not a legal directory name: it must start with a letter, a digit or an underscore, may then contain letters, digits, '.', '_', '-' and '/', and may end with one '/'`);
|
|
2159
|
+
}
|
|
2160
|
+
// `resolve` rather than `join`: the two clauses above have already established that the value is
|
|
2161
|
+
// relative and dot-free, and resolving drops the trailing separator a configured `sdlc-harness/`
|
|
2162
|
+
// carries, so the path this check names is the one every other message names.
|
|
2163
|
+
const root = resolvePath(ctx.repoRoot, raw);
|
|
2164
|
+
if (!isDirectory(root)) {
|
|
2165
|
+
return fail(`stateDir is ${JSON.stringify(raw)} and there is no directory at ${root}: the run-artifact tree is missing — run \`${CLI} init\`, which creates it and rewrites nothing that is already there`);
|
|
2166
|
+
}
|
|
2167
|
+
const problem = probeWritable(root);
|
|
2168
|
+
if (problem !== undefined)
|
|
2169
|
+
return fail(`${root} is ${problem}`);
|
|
2170
|
+
return pass(`${raw} is not dot-named, and a probe file was created and removed in ${root}, so a run can write its artifacts there`);
|
|
2171
|
+
},
|
|
2172
|
+
};
|
|
2173
|
+
/**
|
|
2174
|
+
* Does the tree hold the artifact directories the configured phases call for?
|
|
2175
|
+
*
|
|
2176
|
+
* The list is {@link selectedStateDirs}'s, which is the same list `init` writes from — a second copy
|
|
2177
|
+
* here would drift the first time a directory was added or gated. A `warn`: `init` creates what is
|
|
2178
|
+
* missing and rewrites nothing, so this is the state a repository is in after a phase is turned on
|
|
2179
|
+
* and before the next `init`, which is a fixable condition rather than a broken one.
|
|
2180
|
+
*/
|
|
2181
|
+
const STATE_DIR_TREE_CHECK = {
|
|
2182
|
+
id: 'artifact-tree',
|
|
2183
|
+
title: 'every artifact directory the configured phases call for is present',
|
|
2184
|
+
run: (ctx) => {
|
|
2185
|
+
if (ctx.repoRoot === undefined)
|
|
2186
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
2187
|
+
if (ctx.config === undefined)
|
|
2188
|
+
return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
|
|
2189
|
+
// The same two clauses the state-dir check applies, asked here only as a precondition: a value
|
|
2190
|
+
// that fails either of them has already been reported there, and resolving it would name a
|
|
2191
|
+
// directory outside the repository or beneath a dot segment.
|
|
2192
|
+
const raw = ctx.config.stateDir;
|
|
2193
|
+
if (typeof raw !== 'string' || STATE_DIR_DOT_PATTERN.test(raw) || !STATE_DIR_PATTERN.test(raw)) {
|
|
2194
|
+
return unevaluated('stateDir does not name a usable directory (see the state-dir check)');
|
|
2195
|
+
}
|
|
2196
|
+
const root = resolvePath(ctx.repoRoot, raw);
|
|
2197
|
+
const expected = selectedStateDirs(ctx.config.phases);
|
|
2198
|
+
const missing = expected.filter((entry) => !isDirectory(join(root, entry.dir))).map((entry) => entry.dir);
|
|
2199
|
+
if (missing.length > 0) {
|
|
2200
|
+
return warn(`${missing.length} of the ${expected.length} artifact directories the configured phases call for are missing under ${root}: ${nameList(missing)} — run \`${CLI} init\` to create them; it rewrites nothing that is already there`);
|
|
2201
|
+
}
|
|
2202
|
+
return pass(`all ${expected.length} artifact directories the configured phases call for are present under ${root}`);
|
|
2203
|
+
},
|
|
2204
|
+
};
|
|
2205
|
+
/** The command both remedies below name, spelled as `init` and every stub footer spell it. */
|
|
2206
|
+
const ANALYZE_COMMAND = '/harness-analyze';
|
|
2207
|
+
/**
|
|
2208
|
+
* Setup's judgement half, reported as the four facts that decide it: a configured conventions
|
|
2209
|
+
* document with nothing behind it, one that is still an untouched skeleton, a configured
|
|
2210
|
+
* `layers[].path` naming no directory, and an always-loaded project file still carrying the
|
|
2211
|
+
* setup-pending banner.
|
|
2212
|
+
*
|
|
2213
|
+
* **It grades substring facts and never prose.** Whether a rule somebody wrote is a good rule is not
|
|
2214
|
+
* a question a `doctor` run can answer, and a check that tried would turn a repository red over a
|
|
2215
|
+
* document one person wrote and another would have written differently (`docs/analyze.md` §7). What
|
|
2216
|
+
* it costs to grade these four is nothing, and what it buys is that `doctor` predicts what a run in
|
|
2217
|
+
* this repository will find rather than reporting on wiring alone.
|
|
2218
|
+
*
|
|
2219
|
+
* **The document set is derived here, in the same terms `init`'s offer resolution derives it**
|
|
2220
|
+
* (`commands/init.ts`): every `layers[].conventions` value, normalized and deduplicated, plus
|
|
2221
|
+
* {@link SHARED_CONVENTIONS_PATH} whether or not a layer names it. `generators/claudeContext.ts`'s
|
|
2222
|
+
* own collector is module-private, so each consumer walks `layers[]` itself while all of them take
|
|
2223
|
+
* the shared document's path from that one exported constant — the duplication is bounded to the
|
|
2224
|
+
* walk, and the two cannot disagree about which documents should exist.
|
|
2225
|
+
*
|
|
2226
|
+
* **The layer's other pointer is graded in that same walk, because it is the scope rather than a
|
|
2227
|
+
* document.** `layers[].path` is what every dispatched agent is handed, and a path resolving to
|
|
2228
|
+
* nothing does not error — it returns no files, so each agent scoped to it reports having found
|
|
2229
|
+
* nothing wrong. `config/check.ts` grades that value as a non-empty string and makes no filesystem
|
|
2230
|
+
* call, which leaves a `statSync` per layer as the only thing that answers whether the directory is
|
|
2231
|
+
* there; a `statSync` is a deterministic fact, which is why it belongs in this check rather than
|
|
2232
|
+
* beside it. **The same root seen from the other direction is deliberately not graded here**: a
|
|
2233
|
+
* conventions document sitting in the tree for a layer `layers[]` does not carry needs a walk of the
|
|
2234
|
+
* conventions directory rather than a stat of a configured value, and is left for a later check.
|
|
2235
|
+
*
|
|
2236
|
+
* **"Unfilled" is the two-part test, never the marker alone** ({@link UNFILLED_STUB_MARKER}): each
|
|
2237
|
+
* template's footer tells a hand-writer to replace everything above it, so a document written by
|
|
2238
|
+
* hand has lost its guidance block while possibly keeping a trailing HTML comment nobody thought to
|
|
2239
|
+
* delete. Grading on the marker alone would report that finished document as unfilled in every
|
|
2240
|
+
* `doctor` run for the life of the repository, with the only stated remedy being the command that
|
|
2241
|
+
* adopter declined — which is exactly the state this check exists to *predict* rather than to
|
|
2242
|
+
* manufacture. Both halves are plain substrings, so the scope is unchanged. It is the same test
|
|
2243
|
+
* `init` and the analyze command apply.
|
|
2244
|
+
*
|
|
2245
|
+
* **Every state warns, and none fails.** An unfilled skeleton is a repository whose flow *runs*,
|
|
2246
|
+
* with worse results, so promoting it would make every freshly wired repository exit non-zero for
|
|
2247
|
+
* the one condition `init` cannot resolve on its own — the same reasoning the marketplace-entry
|
|
2248
|
+
* warning already carries (module header, choice 2).
|
|
2249
|
+
*
|
|
2250
|
+
* **Every warning names a remedy that does not require the declined command.** An unfilled document
|
|
2251
|
+
* is cleared by replacing its guidance block, by hand or by the command; the banner is cleared by
|
|
2252
|
+
* deleting its block, by hand or by the command; a missing document is `init`'s to write, or a
|
|
2253
|
+
* `layers[].conventions` value's to correct. A warning whose only route out is the command an
|
|
2254
|
+
* adopter turned down is a standing warning they can never clear.
|
|
2255
|
+
*
|
|
2256
|
+
* It reads files and writes none.
|
|
2257
|
+
*/
|
|
2258
|
+
const SETUP_ANALYSIS_CHECK = {
|
|
2259
|
+
id: 'setup-analysis',
|
|
2260
|
+
title: 'the conventions documents and layer directories the configuration points at exist and have been filled in',
|
|
2261
|
+
run: (ctx) => {
|
|
2262
|
+
if (ctx.repoRoot === undefined)
|
|
2263
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
2264
|
+
if (ctx.config === undefined) {
|
|
2265
|
+
return unevaluated(`${CONFIG_FILENAME} could not be read, so which conventions documents this repository should have is unknown (see the config check)`);
|
|
2266
|
+
}
|
|
2267
|
+
if (!Array.isArray(ctx.config.layers)) {
|
|
2268
|
+
return unevaluated('layers does not list layer profiles, so the documents and directories it points at cannot be collected (see the config check)');
|
|
2269
|
+
}
|
|
2270
|
+
// One walk of `layers[]` for both of the pointers an entry carries: the document its implementer
|
|
2271
|
+
// reads, and the directory its work is scoped to. A value that is not a non-empty string is a
|
|
2272
|
+
// finding `config/check.ts` has already reported, so it is skipped rather than turned into a
|
|
2273
|
+
// second one.
|
|
2274
|
+
const sharedPath = normalizeRepoPathStrict(SHARED_CONVENTIONS_PATH);
|
|
2275
|
+
const configured = new Set();
|
|
2276
|
+
const layerDirectories = [];
|
|
2277
|
+
for (const [index, layer] of ctx.config.layers.entries()) {
|
|
2278
|
+
const conventions = layer?.conventions;
|
|
2279
|
+
if (typeof conventions === 'string' && conventions.trim() !== '') {
|
|
2280
|
+
configured.add(normalizeRepoPathStrict(conventions));
|
|
2281
|
+
}
|
|
2282
|
+
const path = layer?.path;
|
|
2283
|
+
if (typeof path !== 'string' || path.trim() === '')
|
|
2284
|
+
continue;
|
|
2285
|
+
const name = layer?.name;
|
|
2286
|
+
layerDirectories.push({
|
|
2287
|
+
// A layer whose name is missing or blank is the config check's finding too; it is named here
|
|
2288
|
+
// by the key an adopter edits, so the line still points at exactly one row.
|
|
2289
|
+
name: typeof name === 'string' && name.trim() !== '' ? name : `layers[${index}]`,
|
|
2290
|
+
path: normalizeRepoPathStrict(path),
|
|
2291
|
+
});
|
|
2292
|
+
}
|
|
2293
|
+
const documents = [...new Set([sharedPath, ...configured])];
|
|
2294
|
+
// One pass: a document that is not there cannot also be a skeleton, so the two facts are
|
|
2295
|
+
// gathered from the single read each document gets.
|
|
2296
|
+
const missing = [];
|
|
2297
|
+
const unfilled = [];
|
|
2298
|
+
for (const path of documents) {
|
|
2299
|
+
let content;
|
|
2300
|
+
try {
|
|
2301
|
+
content = readFileSync(join(ctx.repoRoot, path), 'utf8');
|
|
2302
|
+
}
|
|
2303
|
+
catch {
|
|
2304
|
+
missing.push(path);
|
|
2305
|
+
continue;
|
|
2306
|
+
}
|
|
2307
|
+
if (content.includes(UNFILLED_STUB_MARKER) && content.includes(SKELETON_GUIDANCE_MARKER))
|
|
2308
|
+
unfilled.push(path);
|
|
2309
|
+
}
|
|
2310
|
+
// The same normalisation the documents got, so `./src/data` and `src/data/` are one path — and
|
|
2311
|
+
// the repository root, which the catch-all layer carries, normalises to `.` and always resolves.
|
|
2312
|
+
const repoRoot = ctx.repoRoot;
|
|
2313
|
+
const brokenPaths = layerDirectories.filter((layer) => !isDirectory(join(repoRoot, layer.path)));
|
|
2314
|
+
// A repository whose always-loaded file is absent, or was never generated by this CLI, simply
|
|
2315
|
+
// has no banner — which is a state to report nothing about rather than a finding.
|
|
2316
|
+
let banner = false;
|
|
2317
|
+
try {
|
|
2318
|
+
banner = readFileSync(join(ctx.repoRoot, CLAUDE_MD_PATH), 'utf8').includes(SETUP_PENDING_OPEN);
|
|
2319
|
+
}
|
|
2320
|
+
catch {
|
|
2321
|
+
banner = false;
|
|
2322
|
+
}
|
|
2323
|
+
const findings = [];
|
|
2324
|
+
// Split by whether a layer actually points at the missing path, because the remedy differs: the
|
|
2325
|
+
// shared document is expected whether or not any layer names it, so when it is the missing one
|
|
2326
|
+
// and no layer points at it there is no configured value to correct.
|
|
2327
|
+
const missingConfigured = missing.filter((path) => configured.has(path));
|
|
2328
|
+
const missingShared = missing.filter((path) => !configured.has(path));
|
|
2329
|
+
if (missingConfigured.length > 0) {
|
|
2330
|
+
findings.push(`${missingConfigured.length} of the ${documents.length} conventions documents this repository should have ${missingConfigured.length === 1 ? 'is' : 'are'} not there: ${nameList(missingConfigured)} — each is a configured \`layers[].conventions\` value, so it is a pointer an implementer of that layer follows to nothing. Either the path is wrong, which \`${CLI} config set layers\` corrects, or the tree was never generated, which \`${CLI} init\` writes`);
|
|
2331
|
+
}
|
|
2332
|
+
if (missingShared.length > 0) {
|
|
2333
|
+
findings.push(`the shared cross-layer document ${nameList(missingShared)} is not there, and no layer points at it: it holds the rules that span every layer and is written whether or not a layer names it, so there is no \`layers[].conventions\` value to correct — run \`${CLI} init\`, which creates it and rewrites nothing that is already there`);
|
|
2334
|
+
}
|
|
2335
|
+
if (brokenPaths.length > 0) {
|
|
2336
|
+
findings.push(`${brokenPaths.length} of the ${layerDirectories.length} layers this repository configures ${brokenPaths.length === 1 ? 'points' : 'point'} at a directory that is not there: ${nameList(brokenPaths.map((layer) => `${layer.name} → ${layer.path}`))} — \`layers[].path\` is the scope every dispatched agent is handed, so a path resolving to nothing returns no files and every agent scoped to it reports having found nothing wrong. Either the path is wrong, which \`${CLI} config set layers\` corrects, or the directory was never created`);
|
|
2337
|
+
}
|
|
2338
|
+
if (unfilled.length > 0) {
|
|
2339
|
+
findings.push(`${unfilled.length} of the ${documents.length} conventions documents this repository should have ${unfilled.length === 1 ? 'is' : 'are'} still an untouched skeleton: ${nameList(unfilled)} — the flow runs against ${unfilled.length === 1 ? 'it' : 'them'} and does worse work, because the rules its implementers and reviewers read are the ones nobody has written yet. Run \`${ANALYZE_COMMAND}\` in a session here, or write the rules in by hand: replacing the guidance block is what clears this, exactly as each document's own footer says`);
|
|
2340
|
+
}
|
|
2341
|
+
if (banner) {
|
|
2342
|
+
findings.push(`${CLAUDE_MD_PATH} still carries the setup-pending banner, so every session in this repository is told on every turn that setup is unfinished: run \`${ANALYZE_COMMAND}\`, which removes the block as its last act, or delete the \`${SETUP_PENDING_OPEN}\` … \`${SETUP_PENDING_CLOSE}\` block by hand once those sections are written`);
|
|
2343
|
+
}
|
|
2344
|
+
if (findings.length > 0)
|
|
2345
|
+
return warn(findings.join('; '));
|
|
2346
|
+
return pass(`all ${documents.length} conventions documents this repository should have are there, none is still an untouched skeleton, the ${layerDirectories.length} configured layer ${layerDirectories.length === 1 ? 'directory is' : 'directories are'} there, and ${CLAUDE_MD_PATH} carries no setup-pending banner`);
|
|
2347
|
+
},
|
|
2348
|
+
};
|
|
2349
|
+
/**
|
|
2350
|
+
* Whether the always-loaded file's change-request fence points at rules that are on disk.
|
|
2351
|
+
*
|
|
2352
|
+
* **The failure it exists to make visible is a silent one.** The fence in `.claude/CLAUDE.md` names
|
|
2353
|
+
* {@link TASK_OFFER_PATH} and fails closed: an agent that cannot read that file makes no offer and
|
|
2354
|
+
* carries the request out in the session it arrived in. That is the right behaviour and it produces
|
|
2355
|
+
* no artifact, no error and no line anywhere, so a deleted or never-generated rules file turns the
|
|
2356
|
+
* offer off with nothing reporting it — which is what this check reports.
|
|
2357
|
+
*
|
|
2358
|
+
* **A project file that does not name the path is graded on what is beside it.** The rules file is
|
|
2359
|
+
* planned on every run and the always-loaded file is never upgraded by an unforced one, so an
|
|
2360
|
+
* adopter whose `.claude/CLAUDE.md` predates this feature comes out of the next `init` holding the
|
|
2361
|
+
* rules file with nothing pointing at it — the reverse dangle, and the state the feature is inert
|
|
2362
|
+
* in. That warns. Only a repository carrying neither half passes: no fence, and nothing on disk
|
|
2363
|
+
* for one to reach.
|
|
2364
|
+
*
|
|
2365
|
+
* **The finding warns and never fails.** The repository is fully functional — every request still
|
|
2366
|
+
* gets done, in the receiving session — so what is lost is the offer, not the run. A missing rules
|
|
2367
|
+
* file is reported with both routes, because this check cannot distinguish an accident from an
|
|
2368
|
+
* adopter turning the offer off: one `init` re-creates the file under `create-if-absent` and touches
|
|
2369
|
+
* nothing else, and removing the fence section from `.claude/CLAUDE.md` is the deliberate off-state
|
|
2370
|
+
* the check already passes on. The remedy for a missing fence costs the adopter something either
|
|
2371
|
+
* way, which is why it is offered as a choice rather than as one command.
|
|
2372
|
+
*
|
|
2373
|
+
* It reads one file, tests one for existence, and writes none.
|
|
2374
|
+
*/
|
|
2375
|
+
/**
|
|
2376
|
+
* The heading of the fence section in the always-loaded file, named by both warnings: adding it by
|
|
2377
|
+
* hand is the remedy when the rules file is there and the fence is not, and removing it is the
|
|
2378
|
+
* deliberate off-state offered when the fence is there and the rules file is not. Either way the
|
|
2379
|
+
* warning has to say which section. Spelled here rather than imported because the template is the
|
|
2380
|
+
* source and this is a quotation of it, not a use of it.
|
|
2381
|
+
*/
|
|
2382
|
+
const TASK_OFFER_SECTION = '## Where a change request runs';
|
|
2383
|
+
const TASK_OFFER_RULES_CHECK = {
|
|
2384
|
+
id: 'task-offer-rules',
|
|
2385
|
+
title: "the change-request offer's rules file is there for the fence that points at it",
|
|
2386
|
+
run: (ctx) => {
|
|
2387
|
+
if (ctx.repoRoot === undefined)
|
|
2388
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
2389
|
+
let projectFile;
|
|
2390
|
+
try {
|
|
2391
|
+
projectFile = readFileSync(join(ctx.repoRoot, CLAUDE_MD_PATH), 'utf8');
|
|
2392
|
+
}
|
|
2393
|
+
catch {
|
|
2394
|
+
return unevaluated(`${CLAUDE_MD_PATH} could not be read, so whether it points at ${TASK_OFFER_PATH} is unknown (see the setup-analysis check)`);
|
|
2395
|
+
}
|
|
2396
|
+
const rulesPresent = existsSync(join(ctx.repoRoot, TASK_OFFER_PATH));
|
|
2397
|
+
if (!projectFile.includes(TASK_OFFER_PATH)) {
|
|
2398
|
+
if (rulesPresent) {
|
|
2399
|
+
return warn(`${TASK_OFFER_PATH} is there and ${CLAUDE_MD_PATH} does not name it, so the change-request offer is off in this repository and the rules file nobody reads is what says so: ${TASK_OFFER_PATH} is written on every run, while a ${CLAUDE_MD_PATH} that predates this feature is kept as it stands by every unforced \`${CLI} init\`. Add the \`${TASK_OFFER_SECTION}\` section to ${CLAUDE_MD_PATH} by hand, copying it from the template this CLI ships, or run \`${CLI} init --force\` — which re-renders the whole project file after one \`.bak\` and costs every section \`${ANALYZE_COMMAND}\` filled`);
|
|
2400
|
+
}
|
|
2401
|
+
return pass(`neither ${CLAUDE_MD_PATH} nor this repository carries the change-request offer, so there is no fence and nothing pointing at a file that is not there: every request is carried out in the session that receives it, which is what a project file written before this feature does`);
|
|
2402
|
+
}
|
|
2403
|
+
if (!rulesPresent) {
|
|
2404
|
+
return warn(`${CLAUDE_MD_PATH} names ${TASK_OFFER_PATH} and that file is not there, so the fence points at rules nobody can read: every change request in this repository is carried out in the session that receives it and no run is offered. This check cannot tell an accident from a deliberate removal, so it names both routes: if the offer is wanted, run \`${CLI} init\`, which re-creates that file under create-if-absent and touches nothing else; if it is not, remove the \`${TASK_OFFER_SECTION}\` section from ${CLAUDE_MD_PATH}, which is the off-state this check passes on`);
|
|
2405
|
+
}
|
|
2406
|
+
return pass(`${CLAUDE_MD_PATH} names ${TASK_OFFER_PATH} and that file is there, so the change-request fence reaches the rules it points at`);
|
|
2407
|
+
},
|
|
2408
|
+
};
|
|
2409
|
+
/**
|
|
2410
|
+
* The analyze target that addresses the layer profile, taken from the one list that declares the
|
|
2411
|
+
* reserved targets (`generators/claudeContext.ts`). Typed against that list rather than indexed into
|
|
2412
|
+
* it, so dropping the target from it is a compile error here rather than a remedy naming an argument
|
|
2413
|
+
* the command no longer takes.
|
|
2414
|
+
*/
|
|
2415
|
+
const LAYERS_TARGET = 'layers';
|
|
2416
|
+
/** The config key a review is recorded under, as both warning remedies tell an operator to set it. */
|
|
2417
|
+
const REVIEW_KEY = 'detection.review';
|
|
2418
|
+
/**
|
|
2419
|
+
* What detection recorded about *why* it chose the preset it chose, as a parenthetical.
|
|
2420
|
+
*
|
|
2421
|
+
* Every value is rendered only when the record carries it, and each absence has a phrase of its own:
|
|
2422
|
+
* `detection` declares no required sub-keys (`config/model.ts`, and the schema's own comment on why),
|
|
2423
|
+
* so a config carrying only `detection.review` reaches this with everything else absent, and a
|
|
2424
|
+
* detail printing the token `undefined` at an adopter is the one output this check may not produce.
|
|
2425
|
+
*/
|
|
2426
|
+
function detectionProvenance(detection) {
|
|
2427
|
+
const signal = detection.signal === undefined ? 'no signal recorded' : `signal \`${detection.signal}\``;
|
|
2428
|
+
const evidence = detection.evidence === undefined ? '' : `, evidence \`${detection.evidence}\``;
|
|
2429
|
+
return `${signal}${evidence}`;
|
|
2430
|
+
}
|
|
2431
|
+
/**
|
|
2432
|
+
* Is the layer profile one that was detected or reviewed, or the unrecognised-layout fallback nobody
|
|
2433
|
+
* has looked at?
|
|
2434
|
+
*
|
|
2435
|
+
* **The gate is two conjuncts, and that is the point.** A profile carrying no row scoped below the
|
|
2436
|
+
* repository root is not a finding on its own — `monorepo` writes one deliberately
|
|
2437
|
+
* (`detect/presets.ts`), and so does an adopter who decided this repository has one scope — so what
|
|
2438
|
+
* tells the deliberate profile from the placeholder is the `detection` record beside it:
|
|
2439
|
+
* {@link FALLBACK_PRESET} is the signal table's unconditional last row, which means nothing was
|
|
2440
|
+
* recognised, and a record that names no preset — absent, or present holding only a review — means
|
|
2441
|
+
* nothing was ever said.
|
|
2442
|
+
* **A recorded preset is not by itself enough**, which is why the clearing arm reads
|
|
2443
|
+
* `LAYERLESS_BY_DESIGN_PRESETS` (`detect/presets.ts`) rather than "any preset but the fallback":
|
|
2444
|
+
* every other preset resolves a source root and writes the catch-all row alone only when that root
|
|
2445
|
+
* was absent — a `pubspec.yaml` with no `lib/`, a Gradle project with no `src/main`. That is an
|
|
2446
|
+
* unresolved layout, not a chosen one, and `buildPreset`'s one-time init warning about it is not
|
|
2447
|
+
* repeated by anything else.
|
|
2448
|
+
* **The `tests` row does not clear it either**, which is why it is excluded from the scoped rows
|
|
2449
|
+
* below: every preset splices that row in wherever the toolchain's conventional test root exists
|
|
2450
|
+
* (`presetLayers`, `detect/presets.ts`), independently of whether a source root resolved, so a
|
|
2451
|
+
* repository whose only row below the catch-all is `tests` has the unresolved **implementation**
|
|
2452
|
+
* profile this check grades. `buildPreset` gates its init warning on the source rows for the same
|
|
2453
|
+
* reason, so the two channels report that repository together.
|
|
2454
|
+
* A row is identified by its normalised `path` against {@link LAYER_CATCH_ALL_PATH} — never by the
|
|
2455
|
+
* name `general`, per that constant's own note — and the profile is graded on whether *any* row is
|
|
2456
|
+
* scoped below it rather than on the row count, because the schema permits more than one catch-all
|
|
2457
|
+
* row. That is the exact complement of the test {@link LAYER_DRIFT_CHECK} applies, so the two
|
|
2458
|
+
* partition the repositories between them rather than both standing down on one.
|
|
2459
|
+
*
|
|
2460
|
+
* **It is deliberately blind to the conventions documents.** {@link SETUP_ANALYSIS_CHECK} above
|
|
2461
|
+
* grades those, and its skeleton warning was the last indirect trace that a fallback profile had
|
|
2462
|
+
* never been examined — a trace `/harness-analyze conventions` clears by filling the documents,
|
|
2463
|
+
* leaving `doctor` green about a profile nobody looked at. This check reads no document, so that
|
|
2464
|
+
* command does not move it.
|
|
2465
|
+
*
|
|
2466
|
+
* **A `detection.review` record is the one thing that clears it**, written by the analyze command's
|
|
2467
|
+
* `layers` target through `config set detection.review` or by hand. All three verdicts clear it
|
|
2468
|
+
* (`config/model.ts`'s `LAYER_REVIEW_VERDICTS`), the two declines included: what is graded is
|
|
2469
|
+
* whether the profile was considered, not what was concluded.
|
|
2470
|
+
*
|
|
2471
|
+
* **It never fails**, for {@link SETUP_ANALYSIS_CHECK}'s reason — an unreviewed profile is a
|
|
2472
|
+
* repository whose flow runs, with worse routing — and every warning arm names a remedy that does
|
|
2473
|
+
* not require the analyze command, per that check's own standing rule. **None of them names
|
|
2474
|
+
* `init --reset-config`**: that rebuilds the whole config from detection and the flags, keeping only
|
|
2475
|
+
* `appDir` (`docs/cli.md` §3), so an adopter clearing a cosmetic warning by following it loses every
|
|
2476
|
+
* hand-set value — a phase toggle turned on by hand, an added `protectedBranches` entry, a corrected
|
|
2477
|
+
* command line.
|
|
2478
|
+
*
|
|
2479
|
+
* It reads the config and writes nothing.
|
|
2480
|
+
*/
|
|
2481
|
+
const LAYER_PROFILE_CHECK = {
|
|
2482
|
+
id: 'layer-profile',
|
|
2483
|
+
title: 'the layer profile is a resolved detection or a reviewed one rather than an unexamined catch-all',
|
|
2484
|
+
run: (ctx) => {
|
|
2485
|
+
if (ctx.repoRoot === undefined)
|
|
2486
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
2487
|
+
if (ctx.config === undefined) {
|
|
2488
|
+
return unevaluated(`${CONFIG_FILENAME} could not be read, so which layer profile this repository routes by is unknown (see the config check)`);
|
|
2489
|
+
}
|
|
2490
|
+
if (!Array.isArray(ctx.config.layers)) {
|
|
2491
|
+
return unevaluated('layers does not list layer profiles, so whether the profile is the generated catch-all cannot be told (see the config check)');
|
|
2492
|
+
}
|
|
2493
|
+
const layers = ctx.config.layers;
|
|
2494
|
+
const detection = ctx.config.detection;
|
|
2495
|
+
const review = detection?.review;
|
|
2496
|
+
// Ahead of the conjuncts, and the arm that grades nothing: a `layers[]` with no row at all is
|
|
2497
|
+
// `config/check.ts`'s finding rather than this one's, and is reported as not graded so one
|
|
2498
|
+
// finding does not become two.
|
|
2499
|
+
if (layers.length === 0) {
|
|
2500
|
+
return pass(`not graded, because layers[] carries no row at all, so there is no generated catch-all row to tell from a chosen profile: that a profile lists no layer is the config check's line to say`);
|
|
2501
|
+
}
|
|
2502
|
+
// The provenance is carried into the shape arms as well, so the line a detected repository gets
|
|
2503
|
+
// names the preset it was detected as rather than only the row count. Dropped where there is
|
|
2504
|
+
// nothing to quote, for {@link detectionProvenance}'s reason.
|
|
2505
|
+
const recordedPreset = detection === undefined || detection.preset === undefined
|
|
2506
|
+
? ''
|
|
2507
|
+
: `, and detection recorded the \`${detection.preset}\` preset (${detectionProvenance(detection)})`;
|
|
2508
|
+
// Conjunct 1, and the first pass arm: a row scoped below the repository root is a profile
|
|
2509
|
+
// somebody shaped, whatever `detection` says about it. Tested over the **set** rather than on
|
|
2510
|
+
// `layers.length === 1`, because the schema deliberately permits more than one catch-all row
|
|
2511
|
+
// (`layers.contains` asserts at least one and draft-07 states no upper bound, so `config/check.ts`
|
|
2512
|
+
// does not reject a second) — and a profile of two catch-all rows is still nothing but the row
|
|
2513
|
+
// init generates. Keyed on the row count it passed as "more than the generated catch-all row",
|
|
2514
|
+
// which is false, while `layer-drift` read the same repository as not-graded, so both stood down.
|
|
2515
|
+
// This is the exact complement of {@link LAYER_DRIFT_CHECK}'s gate, which is what makes the
|
|
2516
|
+
// partition property stated above and in the `CHECKS` comment hold. Each path is normalised with
|
|
2517
|
+
// that check's helper, so `"./"` is the catch-all to both; a value that is not a non-empty string
|
|
2518
|
+
// is `config/check.ts`'s finding and is skipped rather than turned into a second one.
|
|
2519
|
+
const scoped = [];
|
|
2520
|
+
for (const [index, layer] of layers.entries()) {
|
|
2521
|
+
const path = layer?.path;
|
|
2522
|
+
if (typeof path !== 'string' || path.trim() === '')
|
|
2523
|
+
continue;
|
|
2524
|
+
if (normalizeRepoPathStrict(path) === LAYER_CATCH_ALL_PATH)
|
|
2525
|
+
continue;
|
|
2526
|
+
const name = layer?.name;
|
|
2527
|
+
// The `tests` row is skipped for the reason stated above: it is spliced in by test root alone
|
|
2528
|
+
// and so says nothing about whether an implementation root resolved. Keyed on the name rather
|
|
2529
|
+
// than the path because the path is the per-toolchain spelling (`test/`, `spec/`, `src/test/`)
|
|
2530
|
+
// while the name is the one routing tag `TESTS_LAYER_NAME` fixes across every preset.
|
|
2531
|
+
if (name === TESTS_LAYER_NAME)
|
|
2532
|
+
continue;
|
|
2533
|
+
scoped.push(typeof name === 'string' && name.trim() !== '' ? name : `layers[${index}]`);
|
|
2534
|
+
}
|
|
2535
|
+
if (scoped.length > 0) {
|
|
2536
|
+
return pass(`layers[] carries ${layers.length} ${layers.length === 1 ? 'row' : 'rows'}, ${scoped.length} of them scoped below the catch-all ${JSON.stringify(LAYER_CATCH_ALL_PATH)} (${nameList(scoped)}), so the profile is more than the row init generates for an unrecognised layout${recordedPreset}`);
|
|
2537
|
+
}
|
|
2538
|
+
// Conjunct 3, second: a recorded verdict says the profile was considered, and the rationale is
|
|
2539
|
+
// the half of that record a reader of the committed file actually acts on.
|
|
2540
|
+
if (review?.verdict !== undefined) {
|
|
2541
|
+
const rationale = review.rationale === undefined ? 'no rationale recorded' : `rationale ${JSON.stringify(review.rationale)}`;
|
|
2542
|
+
const at = review.at === undefined ? '' : `, recorded ${review.at}`;
|
|
2543
|
+
return pass(`the layer profile carries a recorded review — verdict \`${review.verdict}\`, ${rationale}${at} — so its catch-all-only profile is a profile that was looked at rather than the one init generated and nobody examined`);
|
|
2544
|
+
}
|
|
2545
|
+
// Conjunct 2, last: a recorded preset whose catch-all-only profile is a *decision*
|
|
2546
|
+
// (`LAYERLESS_BY_DESIGN_PRESETS`, plus the fallback tested beside it, which is never in that set).
|
|
2547
|
+
// Narrowed to that set rather than to "any preset but the fallback": every other preset resolves
|
|
2548
|
+
// a source root and writes one row only when that root was **not** found, which is the state this
|
|
2549
|
+
// check exists to keep reporting. A record that names no preset is not a decision either — it
|
|
2550
|
+
// carries no more about the layout than an absent one — so it falls to the warn arms below.
|
|
2551
|
+
// The detail carries `init`'s half of the same statement — the dispatch consequence and the
|
|
2552
|
+
// next step — so either command alone leaves the adopter with the whole state; `buildPreset`
|
|
2553
|
+
// carries this arm's half, gated on the same set. It stays a pass: it names what would propose
|
|
2554
|
+
// a profile, not a remedy to run, which is what separates it from the warn arms.
|
|
2555
|
+
if (detection?.preset !== undefined && detection.preset !== FALLBACK_PRESET && LAYERLESS_BY_DESIGN_PRESETS.has(detection.preset)) {
|
|
2556
|
+
return pass(`detection recorded the \`${detection.preset}\` preset (${detectionProvenance(detection)}), whose catch-all-only profile is a decision rather than an unresolved source root — a monorepo's packages are a judgement call the preset table declines to make — so that record's profile is this repository's profile. \`init\` announced it once: every dispatch in this repository is routed to that one layer, and per-package or per-module layers are \`${ANALYZE_COMMAND}\`'s to propose`);
|
|
2557
|
+
}
|
|
2558
|
+
const remedies = `Run \`${ANALYZE_COMMAND} ${LAYERS_TARGET}\` in a session here: the target proposes a profile or records that it considered one and declined, and either record clears this. It can also be cleared by hand with \`${CLI} config set ${REVIEW_KEY} '<json>'\``;
|
|
2559
|
+
// No recorded preset, whether or not a record exists: `detection` requires no key, so a
|
|
2560
|
+
// hand-written record holding only a review reaches here, and it says nothing about the layout.
|
|
2561
|
+
if (detection?.preset === undefined) {
|
|
2562
|
+
return warn(`this repository's ${CONFIG_FILENAME} records no detected layer preset, so its catch-all-only profile cannot be told from a profile somebody chose — the record predates this release, was hand-edited, or holds only a review. Run \`${ANALYZE_COMMAND} ${LAYERS_TARGET}\`, or record the review by hand with \`${CLI} config set ${REVIEW_KEY} '<json>'\``);
|
|
2563
|
+
}
|
|
2564
|
+
// The forced arm evaluated no signal at all, so it has no evidence to quote and saying "the
|
|
2565
|
+
// fallback caught this run" of it would be false: `--preset` set the value on the command line.
|
|
2566
|
+
// It names the *recorded* preset rather than the fallback, because a forced preset that resolved
|
|
2567
|
+
// no source root reaches here carrying its own name.
|
|
2568
|
+
if (detection.signal === FORCED_SIGNAL_ID) {
|
|
2569
|
+
return warn(`the \`${detection.preset}\` preset was forced on the command line, so no layout signal was evaluated, and layers[] still carries no row scoped below the repository root — every dispatch in this repository is routed to that one layer. ${remedies}`);
|
|
2570
|
+
}
|
|
2571
|
+
// A preset was recognised, but its source root was not: each of these presets writes the layer it
|
|
2572
|
+
// resolves and falls back to the catch-all alone when the directory is absent — a pubspec with no
|
|
2573
|
+
// `lib/`, a Gradle module with no `src/main`, a Gemfile with neither `app/` nor `lib/`.
|
|
2574
|
+
// `buildPreset` says so once at init, in a line that scrolls past on the adoption run and is
|
|
2575
|
+
// never repeated, so this is the arm that keeps saying it.
|
|
2576
|
+
if (detection.preset !== FALLBACK_PRESET) {
|
|
2577
|
+
return warn(`detection recorded the \`${detection.preset}\` preset (${detectionProvenance(detection)}), but that preset's source root was not found, so layers[] carries no row scoped below the repository root — every dispatch in this repository is routed to that one layer. ${remedies}`);
|
|
2578
|
+
}
|
|
2579
|
+
return warn(`the layer profile is the unrecognised-layout fallback (${detectionProvenance(detection)}), not a detected or reviewed one, and layers[] still carries no row scoped below the repository root — every dispatch in this repository is routed to that one layer. ${remedies}`);
|
|
2580
|
+
},
|
|
2581
|
+
};
|
|
2582
|
+
/**
|
|
2583
|
+
* Which source directories under the application directory no `layers[]` entry covers, and so route
|
|
2584
|
+
* to the catch-all row every profile ends with.
|
|
2585
|
+
*
|
|
2586
|
+
* **The set itself is {@link layerCoverage}'s, not this check's** — `init` reports the same
|
|
2587
|
+
* directories at the end of an adoption run, and a second derivation here would let the two commands
|
|
2588
|
+
* describe one repository differently. What stays here is the grading and the wording.
|
|
2589
|
+
*
|
|
2590
|
+
* **The not-graded arm is Finding 38's own amendment**: a profile that is nothing but the catch-all
|
|
2591
|
+
* row makes every directory qualify, so `layerCoverage` does not grade it and that state is
|
|
2592
|
+
* {@link LAYER_PROFILE_CHECK}'s to report, once, as itself.
|
|
2593
|
+
*
|
|
2594
|
+
* **A `warn`, never a `fail`**, for {@link SETUP_ANALYSIS_CHECK}'s reason — a repository routing a
|
|
2595
|
+
* directory to the catch-all runs, with worse routing. **The remedy is
|
|
2596
|
+
* `core/layerGapRemedy.ts`'s**, shared with `init`'s note so the two commands cannot word one
|
|
2597
|
+
* repository's next step two ways; that module is where either arm's spelling changes.
|
|
2598
|
+
*
|
|
2599
|
+
* **It reads `detection.review` for reporting only, never as a gate.** A recorded review does not
|
|
2600
|
+
* silence this check, and the reason is unchanged: a verdict describes the profile at the moment it
|
|
2601
|
+
* was reviewed, so a layer hand-added afterwards is exactly the drift this check exists to name, and
|
|
2602
|
+
* `layerCoverage` grades the same set either way. What the record changes is what the warning *says* —
|
|
2603
|
+
* it names the verdict and its date, so an adopter is not told about a decision the file already
|
|
2604
|
+
* holds as though nothing had been decided, and the remedy leads with `config set layers` instead of
|
|
2605
|
+
* sending them to the command that re-derives that decision.
|
|
2606
|
+
*
|
|
2607
|
+
* It reads directory entries and resolves each candidate against the repository's ignore rules, and
|
|
2608
|
+
* writes nothing.
|
|
2609
|
+
*/
|
|
2610
|
+
const LAYER_DRIFT_CHECK = {
|
|
2611
|
+
id: 'layer-drift',
|
|
2612
|
+
title: 'every source directory under the application directory is covered by a layer',
|
|
2613
|
+
run: (ctx) => {
|
|
2614
|
+
if (ctx.repoRoot === undefined)
|
|
2615
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
2616
|
+
if (ctx.config === undefined) {
|
|
2617
|
+
return unevaluated(`${CONFIG_FILENAME} could not be read, so which directories the layer profile covers is unknown (see the config check)`);
|
|
2618
|
+
}
|
|
2619
|
+
const repoRoot = ctx.repoRoot;
|
|
2620
|
+
const config = ctx.config;
|
|
2621
|
+
if (!Array.isArray(config.layers)) {
|
|
2622
|
+
return unevaluated('layers does not list layer profiles, so which directories they cover cannot be collected (see the config check)');
|
|
2623
|
+
}
|
|
2624
|
+
const { graded, appDir, covering, candidates, uncovered } = layerCoverage({ repoRoot, config });
|
|
2625
|
+
if (!graded) {
|
|
2626
|
+
return pass(`not graded, because this repository's profile carries no row scoped below the repository root — nothing but the catch-all row every preset generates — so every directory would qualify and the report would be the whole tree; that state is \`${LAYER_PROFILE_CHECK.id}\`'s to report, once, as itself`);
|
|
2627
|
+
}
|
|
2628
|
+
if (uncovered.length > 0) {
|
|
2629
|
+
// The recorded review is named, not obeyed: it describes the profile as it stood at review
|
|
2630
|
+
// time, so this check keeps naming every directory the profile does not cover, including any
|
|
2631
|
+
// added since. Dropped where there is nothing recorded to quote, and each field rendered only
|
|
2632
|
+
// where it is present, for {@link detectionProvenance}'s reason — no `undefined` token reaches
|
|
2633
|
+
// an adopter. Both the clause and the remedy are `core/layerGapRemedy.ts`'s single spelling,
|
|
2634
|
+
// shared with `init`'s gap note; this arm passes only its own command constants.
|
|
2635
|
+
const review = config.detection?.review;
|
|
2636
|
+
const clause = recordedVerdictClause(review);
|
|
2637
|
+
const remedy = layerGapRemedy({ review, analyzeCommand: ANALYZE_COMMAND, cli: CLI });
|
|
2638
|
+
return warn(`${uncovered.length} of the ${candidates.length} source directories under \`${appDir}\` ${uncovered.length === 1 ? 'is' : 'are'} covered by no layer and ${uncovered.length === 1 ? 'routes' : 'route'} to the catch-all: ${nameList(uncovered)} — an implementer and a reviewer working there are handed the shared cross-layer document rather than that layer's own rules.` +
|
|
2639
|
+
`${clause === undefined ? '' : ` ${clause}, which describes the profile as it stood then — this check still names every directory the profile does not cover, including any added since.`} ${remedy}`);
|
|
2640
|
+
}
|
|
2641
|
+
return pass(`every source directory under \`${appDir}\` is covered by a layer — ${candidates.length} checked, against the ${covering.length} configured non-catch-all ${covering.length === 1 ? 'layer' : 'layers'} ${nameList(covering.map((layer) => `${layer.name} → ${layer.path}`))}`);
|
|
2642
|
+
},
|
|
2643
|
+
};
|
|
2644
|
+
/**
|
|
2645
|
+
* The comment lines an earlier release wrote above the whole-directory rule, identified by a phrase
|
|
2646
|
+
* from each rather than by their full text.
|
|
2647
|
+
*
|
|
2648
|
+
* They are dead prose now — they state that the clarification channel has no committed README, which
|
|
2649
|
+
* the tree generator's row for it made false — and they survive a re-run for exactly the reason the
|
|
2650
|
+
* rule does. A substring is what is matched because those two lines were wrapped by the template
|
|
2651
|
+
* that emitted them and may have been re-wrapped by hand since; the phrases below are the parts that
|
|
2652
|
+
* carry the claim. Nothing the current template emits contains either of them, which is the property
|
|
2653
|
+
* the fresh-repository test in `test/doctor.test.mjs` holds in place.
|
|
2654
|
+
*/
|
|
2655
|
+
const SUPERSEDED_CLARIFICATION_COMMENTS = Object.freeze([
|
|
2656
|
+
'created on first write rather than by init',
|
|
2657
|
+
'can be ignored as a whole directory',
|
|
2658
|
+
]);
|
|
2659
|
+
/**
|
|
2660
|
+
* A managed-block line that excepts a directory's committed contract file from a rule beside it,
|
|
2661
|
+
* with the path it re-includes captured.
|
|
2662
|
+
*
|
|
2663
|
+
* It decides **which remedy** a hidden path gets — a negation that is present and shadowed is a line
|
|
2664
|
+
* to move, a negation that is absent is a line to merge back — and it no longer decides *which paths
|
|
2665
|
+
* are asked about*: that set is {@link contentsIgnoredDirectories}'s, because a negation an adopter
|
|
2666
|
+
* deleted removes the path from the file and must not remove it from the question.
|
|
2667
|
+
*/
|
|
2668
|
+
const README_NEGATION_PATTERN = /^!(.+\/README\.md)$/;
|
|
2669
|
+
/**
|
|
2670
|
+
* The managed block's own lines: the run of non-blank lines beneath {@link GITIGNORE_BLOCK_HEADER},
|
|
2671
|
+
* which is the extent the write engine maintains and the extent that constant's own doc states.
|
|
2672
|
+
*
|
|
2673
|
+
* Empty when the header is not in the file, which is a repository whose block was never written or
|
|
2674
|
+
* was renamed — there is then no block for a rule to be read out of, and the caller says so rather
|
|
2675
|
+
* than reading the adopter's own rules as the harness's.
|
|
2676
|
+
*/
|
|
2677
|
+
function managedBlockLines(lines) {
|
|
2678
|
+
const headerIndex = lines.indexOf(GITIGNORE_BLOCK_HEADER);
|
|
2679
|
+
if (headerIndex < 0)
|
|
2680
|
+
return [];
|
|
2681
|
+
const block = [];
|
|
2682
|
+
for (let at = headerIndex + 1; at < lines.length && lines[at] !== ''; at += 1)
|
|
2683
|
+
block.push(lines[at]);
|
|
2684
|
+
return block;
|
|
2685
|
+
}
|
|
2686
|
+
/**
|
|
2687
|
+
* The ignore rule no re-run can repair: a whole-directory exclusion of the clarification channel,
|
|
2688
|
+
* left above the contents-plus-exception pair that replaced it.
|
|
2689
|
+
*
|
|
2690
|
+
* **This is the one wiring defect in this file whose remediation is a hand edit**, and that is what
|
|
2691
|
+
* makes it `doctor`'s to find. The managed block is appended to and never pruned
|
|
2692
|
+
* (`generators/repoRoot.ts`'s {@link clarificationsIgnoreRules}), so an upgraded repository keeps the
|
|
2693
|
+
* old line; git cannot re-include a file whose parent directory is excluded, so the exception under
|
|
2694
|
+
* it does nothing; and the visible result is one untracked file that looks exactly like a directory
|
|
2695
|
+
* the flow forgot to document. Every command still reports success — which is the silent-failure
|
|
2696
|
+
* shape this whole file exists to convert into a line someone reads.
|
|
2697
|
+
*
|
|
2698
|
+
* The grades follow what is actually hidden. Both rules present is a `fail`: the channel's committed
|
|
2699
|
+
* contract is being ignored right now. The old rule *alone* is a `warn` — that repository predates
|
|
2700
|
+
* the tree row, so there is no README to hide yet, and the finding is that the next `init` creates
|
|
2701
|
+
* one and appends the pair beneath a rule that will swallow it. The two superseded comment lines are
|
|
2702
|
+
* reported inside whichever of those the repository is in, rather than as a finding of their own, so
|
|
2703
|
+
* that one hand edit closes the whole thing instead of leaving prose that contradicts the comment
|
|
2704
|
+
* beside it.
|
|
2705
|
+
*
|
|
2706
|
+
* **On top of that diagnosis it tests the property the pass claims, and tests it with git.** For
|
|
2707
|
+
* every README {@link contentsIgnoredDirectories} says the tree generator commits into a
|
|
2708
|
+
* contents-ignored directory, {@link pathIsIgnored} is asked whether that path is excluded here; any
|
|
2709
|
+
* that is makes this a `fail` naming it, and a probe that did not answer makes it a `warn` saying so.
|
|
2710
|
+
* That covers every pair without naming one and without reading direction: a rule above a negation
|
|
2711
|
+
* and a rule below it hide the file identically, so the layout test this check used to end on looked
|
|
2712
|
+
* in one of the two directions the fault comes from — and printed a pass about a README `git add`
|
|
2713
|
+
* refused in the same working tree.
|
|
2714
|
+
*
|
|
2715
|
+
* **The subject set is the generator's, not the audited file's**, and that is the second half of the
|
|
2716
|
+
* same fault. Reading the paths out of the block's own negations makes a deleted negation delete the
|
|
2717
|
+
* question with it: the contents rule beside it still hides the README, and the check reports a pass
|
|
2718
|
+
* about the pairs that happen to have survived. So the block is read only to choose between the two
|
|
2719
|
+
* hand edits — move the contents rule back above a negation that is there, or re-run `init` to merge
|
|
2720
|
+
* back a negation that is not.
|
|
2721
|
+
*
|
|
2722
|
+
* The *effective* answer is the point, and that is what reverses the reasoning this comment used to
|
|
2723
|
+
* carry. Whether the channel's committed contract can be committed is a property of every ignore
|
|
2724
|
+
* rule in force here, however many files they are spread across, so only git can answer it. The
|
|
2725
|
+
* **remedy** is still one line in one managed block, which is why the messages below still name that
|
|
2726
|
+
* line: git says *that* a path is hidden, and this file says *which* line to move or delete.
|
|
2727
|
+
*
|
|
2728
|
+
* It reads the file, asks git that one read-only question per committed contract file, and writes
|
|
2729
|
+
* nothing.
|
|
2730
|
+
*/
|
|
2731
|
+
const IGNORE_RULES_CHECK = {
|
|
2732
|
+
id: 'ignore-rules',
|
|
2733
|
+
title: 'the managed .gitignore block hides none of the files this repository commits',
|
|
2734
|
+
run: (ctx) => {
|
|
2735
|
+
if (ctx.repoRoot === undefined)
|
|
2736
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
2737
|
+
if (ctx.config === undefined) {
|
|
2738
|
+
return unevaluated(`${CONFIG_FILENAME} could not be read, so the stateDir the rules are spelled with is unknown (see the config check)`);
|
|
2739
|
+
}
|
|
2740
|
+
// The same precondition the artifact-tree check applies, and for the same reason: a stateDir
|
|
2741
|
+
// failing either schema clause has already been reported by the state-dir check, and rules
|
|
2742
|
+
// spelled with it would name a location no generated block ever held.
|
|
2743
|
+
const raw = ctx.config.stateDir;
|
|
2744
|
+
if (typeof raw !== 'string' || STATE_DIR_DOT_PATTERN.test(raw) || !STATE_DIR_PATTERN.test(raw)) {
|
|
2745
|
+
return unevaluated('stateDir does not name a usable directory (see the state-dir check)');
|
|
2746
|
+
}
|
|
2747
|
+
const path = join(ctx.repoRoot, GITIGNORE_PATH);
|
|
2748
|
+
let lines;
|
|
2749
|
+
try {
|
|
2750
|
+
lines = readFileSync(path, 'utf8')
|
|
2751
|
+
.split('\n')
|
|
2752
|
+
.map((line) => line.trim());
|
|
2753
|
+
}
|
|
2754
|
+
catch (error) {
|
|
2755
|
+
return warn(`${GITIGNORE_PATH} could not be read at ${path} (${messageOf(error)}), so none of the harness's ignore rules could be checked — and with none of them in force a single \`git add\` of the run-artifact tree commits the run-control files a run leaves in it, a committed STOP being the one that halts every run for everyone who clones. Run \`${CLI} init\`, which merges the managed block back in without touching the rest of the file`);
|
|
2756
|
+
}
|
|
2757
|
+
const rules = clarificationsIgnoreRules(raw);
|
|
2758
|
+
const superseded = lines.includes(rules.supersededDirectoryRule);
|
|
2759
|
+
const current = lines.includes(rules.contents) || lines.includes(rules.readmeException);
|
|
2760
|
+
const staleComments = lines.filter((line) => line.startsWith('#') && SUPERSEDED_CLARIFICATION_COMMENTS.some((marker) => line.includes(marker)));
|
|
2761
|
+
const andTheComments = staleComments.length === 0
|
|
2762
|
+
? ''
|
|
2763
|
+
: `, together with the ${staleComments.length} comment line${staleComments.length === 1 ? '' : 's'} above it that still describe this channel as having no committed README — the same change superseded them, and they now contradict the comment beside them`;
|
|
2764
|
+
if (superseded && current) {
|
|
2765
|
+
return fail(`${GITIGNORE_PATH} carries the whole-directory rule \`${rules.supersededDirectoryRule}\` as well as \`${rules.contents}\` and \`${rules.readmeException}\`: the directory rule comes first, and git cannot re-include a file whose parent directory is excluded, so the exception is inert and ${rules.readmePath} — the committed contract for what the park-and-ask channel holds — is ignored and stays untracked while init, doctor and every git command report success. The managed block is only appended to, so no re-run removes the line: delete \`${rules.supersededDirectoryRule}\` from ${path} by hand${andTheComments}, then \`git add ${rules.readmePath}\``);
|
|
2766
|
+
}
|
|
2767
|
+
if (superseded) {
|
|
2768
|
+
return warn(`${GITIGNORE_PATH} carries the whole-directory rule \`${rules.supersededDirectoryRule}\` and neither of the two rules that replaced it: nothing is hidden yet, because this repository has no ${rules.readmePath} for it to hide — the next \`${CLI} init\` writes that README and appends \`${rules.contents}\` and \`${rules.readmeException}\` beneath the directory rule, which is the point at which the exception goes inert and the contract file silently stops being committable. Delete the line from ${path} now${andTheComments}`);
|
|
2769
|
+
}
|
|
2770
|
+
if (!current) {
|
|
2771
|
+
return warn(`${GITIGNORE_PATH} carries neither \`${rules.contents}\` nor \`${rules.readmeException}\`, so nothing ignores the questions a parked run writes and the answers it is given — run \`${CLI} init\`, which merges the managed block back in without touching the rest of the file`);
|
|
2772
|
+
}
|
|
2773
|
+
// The property, asked of git, for every contract file the tree generator commits rather than for
|
|
2774
|
+
// the one named above and rather than for whichever negations the file still carries. It runs
|
|
2775
|
+
// after the four arms above so their remedies — each naming a line an operator has to edit — are
|
|
2776
|
+
// not replaced by the generic one below, and before the stale-comment arm so that a `fail` about
|
|
2777
|
+
// a hidden file can never be reported as a warning about prose.
|
|
2778
|
+
const negated = new Set();
|
|
2779
|
+
for (const line of managedBlockLines(lines)) {
|
|
2780
|
+
const readme = README_NEGATION_PATTERN.exec(line)?.[1];
|
|
2781
|
+
if (readme !== undefined)
|
|
2782
|
+
negated.add(readme);
|
|
2783
|
+
}
|
|
2784
|
+
const contractFiles = contentsIgnoredDirectories(raw).map((entry) => entry.readmePath);
|
|
2785
|
+
// Hidden with its negation still in the block, hidden with no negation left at all, and not
|
|
2786
|
+
// answered — three states with three different sentences to write.
|
|
2787
|
+
const shadowed = [];
|
|
2788
|
+
const unexcepted = [];
|
|
2789
|
+
const untested = [];
|
|
2790
|
+
for (const readme of contractFiles) {
|
|
2791
|
+
const verdict = pathIsIgnored(ctx.repoRoot, readme);
|
|
2792
|
+
if (verdict === undefined)
|
|
2793
|
+
untested.push(readme);
|
|
2794
|
+
else if (verdict)
|
|
2795
|
+
(negated.has(readme) ? shadowed : unexcepted).push(readme);
|
|
2796
|
+
}
|
|
2797
|
+
const hidden = [...shadowed, ...unexcepted];
|
|
2798
|
+
if (hidden.length > 0) {
|
|
2799
|
+
const one = hidden.length === 1;
|
|
2800
|
+
const remedies = [
|
|
2801
|
+
shadowed.length > 0
|
|
2802
|
+
? `${nameList(shadowed)} ${shadowed.length === 1 ? 'is' : 'are'} excepted by a \`!\` negation the block still carries, and some rule in force matches the path *after* it — git resolves a path by the **last** pattern that matches, so the exception is inert: put the directory's contents rule — \`<dir>/*\` — back **above** its negation inside the managed block in ${path}, or delete the superseded rule that shadows it. No re-run removes or moves a line, so that edit is by hand`
|
|
2803
|
+
: '',
|
|
2804
|
+
unexcepted.length > 0
|
|
2805
|
+
? `the managed block carries no \`!<path>\` negation for ${nameList(unexcepted)} at all: the line that excepts ${unexcepted.length === 1 ? "this directory's" : "these directories'"} committed contract file is missing from ${path}. Re-run \`${CLI} init\`, which merges it back in at its position inside the block`
|
|
2806
|
+
: '',
|
|
2807
|
+
].filter((part) => part !== '');
|
|
2808
|
+
return fail(`git reports ${nameList(hidden)} ignored in this working tree, though the run-artifact tree generator commits ${one ? 'it' : 'each of them'} as the contract for what its directory holds: \`git add\` refuses ${one ? 'it' : 'them'} and ${one ? 'it stays' : 'they stay'} untracked while init, doctor and every git command report success. ${remedies.join('. ')}. Then \`git add\` each path named here`);
|
|
2809
|
+
}
|
|
2810
|
+
if (untested.length > 0) {
|
|
2811
|
+
return warn(`git did not answer whether ${nameList(untested)} ${untested.length === 1 ? 'is' : 'are'} ignored here — \`git check-ignore\` exited with an error rather than a verdict — so the one thing this check exists to establish, that every README the tree generator commits into a contents-ignored directory is committable, was **not** measured and is not being claimed for ${untested.length === 1 ? 'that path' : 'those paths'}. Run \`git check-ignore -v -- <path>\` in ${ctx.repoRoot} to see what git refuses, then re-run \`${CLI} doctor\``);
|
|
2812
|
+
}
|
|
2813
|
+
if (staleComments.length > 0) {
|
|
2814
|
+
return warn(`${GITIGNORE_PATH}'s managed block is functionally correct — \`${rules.contents}\` and \`${rules.readmeException}\` with no whole-directory rule above them — but still carries ${staleComments.length} comment line${staleComments.length === 1 ? '' : 's'} describing this channel as having no committed README, which the tree's row for it made false. Delete ${staleComments.length === 1 ? 'it' : 'them'} from ${path}: prose contradicting the rule beside it is what sends the next reader to re-add the rule that was just removed`);
|
|
2815
|
+
}
|
|
2816
|
+
// Only what was measured: the pair is in the file, and git was asked about every contract file
|
|
2817
|
+
// the tree generator commits into a contents-ignored directory. A release that ignores none of
|
|
2818
|
+
// them by contents is said as that rather than as a count of nothing, so the sentence never
|
|
2819
|
+
// reports a measurement it did not make.
|
|
2820
|
+
const measured = contractFiles.length === 0
|
|
2821
|
+
? 'the tree generator commits no contract file into a contents-ignored directory for git to be asked about'
|
|
2822
|
+
: `git reports ${nameList(contractFiles)} committable in this working tree`;
|
|
2823
|
+
return pass(`${GITIGNORE_PATH} ignores the clarification channel by its contents and excepts ${rules.readmePath}, and ${measured}`);
|
|
2824
|
+
},
|
|
2825
|
+
};
|
|
2826
|
+
/**
|
|
2827
|
+
* The two project-scope keys that make a teammate's clone resolve the plugin.
|
|
2828
|
+
*
|
|
2829
|
+
* They are graded differently because they fail differently. Without `enabledPlugins` nothing about
|
|
2830
|
+
* this harness is enabled for anyone, including the adopter who just ran `init` — a `fail`. Without
|
|
2831
|
+
* the marketplace entry the plugin is enabled from a source the clone has never been told about,
|
|
2832
|
+
* which is a **clone-side** failure the adopter cannot see locally and which this release cannot
|
|
2833
|
+
* always avoid: the entry is omitted when this package's own repository URL does not yet name a
|
|
2834
|
+
* published account (`generators/projectSettings.ts`), so it is a `warn` naming the flag that
|
|
2835
|
+
* writes it.
|
|
2836
|
+
*
|
|
2837
|
+
* **The entry's presence is not the question; its shape is.** A clone resolves the plugin only from
|
|
2838
|
+
* an entry the host tool accepts, and one carrying the two inner fields at its top level is ignored
|
|
2839
|
+
* with a `Settings Warning` — the same clone-side outcome as no entry at all, which is why a
|
|
2840
|
+
* malformed one is graded the same `warn`. So the pass line's closing clause, *"a clone of this
|
|
2841
|
+
* repository resolves the plugin from the committed file"*, turns on the shape and not on the key:
|
|
2842
|
+
* it was asserted while only presence was tested, and {@link marketplaceEntryDefect} — asked of
|
|
2843
|
+
* `generators/projectSettings.ts`, never re-derived here — is what now earns it. The malformed
|
|
2844
|
+
* branch says so in the file, because this is the one defect an adopter cannot see locally: a
|
|
2845
|
+
* marketplace registered at user scope on the authoring machine resolves the plugin whatever the
|
|
2846
|
+
* committed file says.
|
|
2847
|
+
*/
|
|
2848
|
+
const PLUGIN_WIRING_CHECK = {
|
|
2849
|
+
id: 'plugin-wiring',
|
|
2850
|
+
title: `${SETTINGS_PATH} carries both project-scope keys, the marketplace entry in a resolvable shape`,
|
|
2851
|
+
run: (ctx) => {
|
|
2852
|
+
if (ctx.repoRoot === undefined)
|
|
2853
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
2854
|
+
const path = join(ctx.repoRoot, SETTINGS_PATH);
|
|
2855
|
+
let parsed;
|
|
2856
|
+
try {
|
|
2857
|
+
parsed = readJsonFile(path);
|
|
2858
|
+
}
|
|
2859
|
+
catch (error) {
|
|
2860
|
+
return fail(messageOf(error));
|
|
2861
|
+
}
|
|
2862
|
+
if (parsed === undefined) {
|
|
2863
|
+
return fail(`no ${SETTINGS_PATH} at ${path}: nothing enables this harness's plugin in this repository — run \`${CLI} init\``);
|
|
2864
|
+
}
|
|
2865
|
+
if (!isJsonObject(parsed))
|
|
2866
|
+
return fail(`${path} is not a JSON object, so the agent runner cannot load it`);
|
|
2867
|
+
const enabled = parsed[ENABLED_PLUGINS_KEY];
|
|
2868
|
+
if (!isJsonObject(enabled) || !Object.hasOwn(enabled, PLUGIN_KEY)) {
|
|
2869
|
+
return fail(`${SETTINGS_PATH} has no ${ENABLED_PLUGINS_KEY} entry for ${PLUGIN_KEY}, so the plugin is not enabled in this repository for anyone who opens it — run \`${CLI} init\`, which merges the key in without touching anything else in the file`);
|
|
2870
|
+
}
|
|
2871
|
+
if (enabled[PLUGIN_KEY] !== true) {
|
|
2872
|
+
return warn(`${SETTINGS_PATH} lists ${PLUGIN_KEY} under ${ENABLED_PLUGINS_KEY} with the value ${JSON.stringify(enabled[PLUGIN_KEY])} rather than true, so the plugin is registered and switched off`);
|
|
2873
|
+
}
|
|
2874
|
+
const marketplaces = parsed[MARKETPLACES_KEY];
|
|
2875
|
+
if (!isJsonObject(marketplaces) || !Object.hasOwn(marketplaces, MARKETPLACE_NAME)) {
|
|
2876
|
+
return warn(`${SETTINGS_PATH} enables ${PLUGIN_KEY} but has no ${MARKETPLACES_KEY} entry for ${MARKETPLACE_NAME}: a teammate's clone will enable the plugin from a marketplace it has never been told about and will not resolve it, and nothing shows that here because this checkout already knows the marketplace — re-run init with --marketplace <owner>/<repo> to write the entry`);
|
|
2877
|
+
}
|
|
2878
|
+
const defect = marketplaceEntryDefect(marketplaces[MARKETPLACE_NAME]);
|
|
2879
|
+
if (defect !== undefined) {
|
|
2880
|
+
return warn(`${SETTINGS_PATH}'s ${MARKETPLACES_KEY} entry for ${MARKETPLACE_NAME} is not a shape the agent runner accepts — ${defect}: the entry is ignored with a settings warning, so a teammate's clone enables the plugin from a marketplace it has never been told about and will not resolve it, exactly as if the key were absent, and nothing shows that here because this checkout already knows the marketplace. Re-running init does not repair it: ${SETTINGS_PATH} is merged missing keys only, so an entry that is already there is kept as it stands. Either edit it by hand to ${MARKETPLACE_ENTRY_SHAPE}, or delete the ${MARKETPLACE_NAME} key from ${MARKETPLACES_KEY} and run \`${CLI} init ${MARKETPLACE_FLAG} ${SLUG_SHAPE}\``);
|
|
2881
|
+
}
|
|
2882
|
+
return pass(`${SETTINGS_PATH} enables ${PLUGIN_KEY} and declares the ${MARKETPLACE_NAME} marketplace in the shape the agent runner reads, so a clone of this repository resolves the plugin from the committed file`);
|
|
2883
|
+
},
|
|
2884
|
+
};
|
|
2885
|
+
/** Is there a permission profile, and does it parse? Everything below reads it. */
|
|
2886
|
+
const PROFILE_CHECK = {
|
|
2887
|
+
id: 'permission-profile',
|
|
2888
|
+
title: `${PROFILE_PATH} exists and parses`,
|
|
2889
|
+
run: (ctx) => {
|
|
2890
|
+
if (ctx.repoRoot === undefined)
|
|
2891
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
2892
|
+
if (ctx.profile === undefined)
|
|
2893
|
+
return fail(ctx.profileProblem ?? `${PROFILE_PATH} could not be read`);
|
|
2894
|
+
return pass(`${ctx.profilePath} parses as the settings document an unattended run loads`);
|
|
2895
|
+
},
|
|
2896
|
+
};
|
|
2897
|
+
/**
|
|
2898
|
+
* Do the profile's absolute paths still name this checkout?
|
|
2899
|
+
*
|
|
2900
|
+
* The profile is generated with `<repo_root>`, `<work_root>` and the worktree glob resolved at `init`
|
|
2901
|
+
* time, so a repository cloned onto another machine — or moved — carries a profile whose rules point
|
|
2902
|
+
* at a location that no longer exists. A `warn`, because the fix is a re-run rather than an edit and
|
|
2903
|
+
* because a hand-tuned profile is a file the adopter may deliberately have pointed elsewhere.
|
|
2904
|
+
*
|
|
2905
|
+
* The question asked is "does any rule cover this root", not "is every path correct": a profile
|
|
2906
|
+
* generated here mentions the root in its edit, write and read rules, so its complete absence is the
|
|
2907
|
+
* signal.
|
|
2908
|
+
*
|
|
2909
|
+
* "Cover" is deliberately not "contain" ({@link namesRoot}). A generated profile names two locations —
|
|
2910
|
+
* the checkout it was generated at, and the sibling-worktree pattern that is emitted unconditionally
|
|
2911
|
+
* beside it — and a sibling worktree is covered by the second while appearing in neither as a
|
|
2912
|
+
* substring. Warning there would be a standing false alarm in exactly the checkouts the flow runs in,
|
|
2913
|
+
* and its remediation is the damaging part: an `init --force` inside a worktree regenerates the
|
|
2914
|
+
* **committed** profile with that worktree as `<repo_root>`, leaving the main checkout named by
|
|
2915
|
+
* nothing, since `<work>/<project>` does not match `<work>/<project>-*`.
|
|
2916
|
+
*/
|
|
2917
|
+
const PROFILE_PATHS_CHECK = {
|
|
2918
|
+
id: 'profile-paths',
|
|
2919
|
+
title: "the permission profile's absolute paths name this checkout",
|
|
2920
|
+
run: (ctx) => {
|
|
2921
|
+
if (ctx.repoRoot === undefined)
|
|
2922
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
2923
|
+
if (ctx.profile === undefined)
|
|
2924
|
+
return unevaluated(`${PROFILE_PATH} could not be read (see the permission-profile check)`);
|
|
2925
|
+
const covered = locationStrings(ctx.profile).some((entry) => namesRoot(entry, ctx.repoRoot));
|
|
2926
|
+
return covered
|
|
2927
|
+
? pass(`the profile's rules cover this repository root (${ctx.repoRoot}) — by naming it, or by a pattern such as the sibling-worktree glob that matches it — so they apply to this checkout`)
|
|
2928
|
+
: warn(`neither a path nor a pattern in ${PROFILE_PATH} covers this repository root (${ctx.repoRoot}): the profile was generated for another location, so a run loading it would find its edit, write, read and script rules matching nothing here — re-run \`${CLI} init --force\` from the checkout the profile should be generated for, which writes a .bak sibling before regenerating it`);
|
|
2929
|
+
},
|
|
2930
|
+
};
|
|
2931
|
+
/**
|
|
2932
|
+
* The closure that must **not** be in a generated profile: no `deny` entry may name a browser tool.
|
|
2933
|
+
*
|
|
2934
|
+
* A `deny` rule is evaluated before any `allow` and cannot be overridden, so a browser-namespace deny
|
|
2935
|
+
* here revokes the interactive-test agent's own explicitly granted access along with everyone else's.
|
|
2936
|
+
* That closure is made per agent, by each agent definition's `tools:` allowlist, and never in a
|
|
2937
|
+
* settings file. A `fail`: it is the one profile edit that silently disables a whole phase.
|
|
2938
|
+
*
|
|
2939
|
+
* What counts as a browser tool is {@link namesBrowserTool}, whose server names come from the
|
|
2940
|
+
* interactive-test fragment's `enabledMcpjsonServers` — so a deny of some other MCP namespace, which
|
|
2941
|
+
* is an adopter's to write, is neither failed here nor described as a browser deny.
|
|
2942
|
+
*/
|
|
2943
|
+
const PROFILE_BROWSER_DENY_CHECK = {
|
|
2944
|
+
id: 'profile-browser-deny',
|
|
2945
|
+
title: 'the permission profile denies no browser tool',
|
|
2946
|
+
run: (ctx) => {
|
|
2947
|
+
if (ctx.profile === undefined)
|
|
2948
|
+
return unevaluated(`${PROFILE_PATH} could not be read (see the permission-profile check)`);
|
|
2949
|
+
const denied = permissionEntries(ctx.profile, 'deny').filter((entry) => namesBrowserTool(entry));
|
|
2950
|
+
if (denied.length > 0) {
|
|
2951
|
+
return fail(`${PROFILE_PATH} denies ${nameList(denied)}: a deny is evaluated before any allow and cannot be overridden, so this revokes the interactive-test agent's own grant and the phase can never drive a browser — remove the entr${denied.length === 1 ? 'y' : 'ies'}; the browser closure is made per agent, by each agent definition's tools allowlist`);
|
|
2952
|
+
}
|
|
2953
|
+
return pass(`no entry in ${PROFILE_PATH}'s deny list names a browser tool`);
|
|
2954
|
+
},
|
|
2955
|
+
};
|
|
2956
|
+
/**
|
|
2957
|
+
* Does the profile still carry the `deny` floor a generated one ships with — the recursive removal
|
|
2958
|
+
* and the destructive branch operations no unattended run is asked about?
|
|
2959
|
+
*
|
|
2960
|
+
* The floor is the shipped template's own list ({@link shippedDenyFloor}), so this check cannot
|
|
2961
|
+
* vouch for a floor by consulting a second copy of it. A `warn` rather than a `fail`: the profile is
|
|
2962
|
+
* a file the adopter owns and may deliberately have restructured, and a run missing an entry is
|
|
2963
|
+
* worse-defended rather than unable to proceed. It is also the state a profile generated by an
|
|
2964
|
+
* earlier release is legitimately in, which is worth a line and not worth a non-zero exit.
|
|
2965
|
+
*/
|
|
2966
|
+
const PROFILE_DENY_FLOOR_CHECK = {
|
|
2967
|
+
id: 'profile-deny-floor',
|
|
2968
|
+
title: 'the permission profile keeps the deny floor a generated one ships with',
|
|
2969
|
+
run: (ctx) => {
|
|
2970
|
+
if (ctx.profile === undefined)
|
|
2971
|
+
return unevaluated(`${PROFILE_PATH} could not be read (see the permission-profile check)`);
|
|
2972
|
+
const floor = shippedDenyFloor();
|
|
2973
|
+
if (floor.length === 0) {
|
|
2974
|
+
return warn(`the shipped permission-profile template ${PROFILE_TEMPLATE_PATH} declares no deny entries, so there is no floor to compare ${PROFILE_PATH} against: this is a fault in this CLI's packaging rather than in the repository it was run against`);
|
|
2975
|
+
}
|
|
2976
|
+
const denied = permissionEntries(ctx.profile, 'deny');
|
|
2977
|
+
const missing = floor.filter((entry) => !denied.includes(entry));
|
|
2978
|
+
if (missing.length > 0) {
|
|
2979
|
+
return warn(`${PROFILE_PATH}'s deny list is missing ${missing.length} of the ${floor.length} entries a generated profile ships with: ${nameList(missing)} — an unattended run is one generated command away from each of them and would never be asked about it. Restore them, or regenerate the profile with \`${CLI} init --force\`, which writes a .bak sibling first`);
|
|
2980
|
+
}
|
|
2981
|
+
return pass(`${PROFILE_PATH} carries all ${floor.length} deny entries a generated profile ships with`);
|
|
2982
|
+
},
|
|
2983
|
+
};
|
|
2984
|
+
/** A group's heading, then its entries one per line, so a paste lands in the right place. */
|
|
2985
|
+
function renderRootGroup(label, root, rules) {
|
|
2986
|
+
return `${label} — ${root}:\n${rules.join('\n')}`;
|
|
2987
|
+
}
|
|
2988
|
+
/**
|
|
2989
|
+
* The two fixed halves of {@link bashScriptRule}'s output, taken from the builder itself over a path
|
|
2990
|
+
* neither half can contain — so the helper form is recognised here rather than spelled a second time,
|
|
2991
|
+
* and a change to that builder cannot leave this reader matching the old shape.
|
|
2992
|
+
*/
|
|
2993
|
+
const HELPER_RULE_PROBE = '/probe';
|
|
2994
|
+
const [HELPER_RULE_OPEN = '', HELPER_RULE_CLOSE = ''] = bashScriptRule(HELPER_RULE_PROBE).split(HELPER_RULE_PROBE);
|
|
2995
|
+
/** Whether an entry is written in the helper form — {@link bashScriptRule}'s output over some path. */
|
|
2996
|
+
function namesHelperScript(entry) {
|
|
2997
|
+
return entry.startsWith(HELPER_RULE_OPEN) && entry.endsWith(HELPER_RULE_CLOSE);
|
|
2998
|
+
}
|
|
2999
|
+
/**
|
|
3000
|
+
* Does the permission profile grant what a run needs at the **plugin's** roots — the locations
|
|
3001
|
+
* `init` does not generate an entry for?
|
|
3002
|
+
*
|
|
3003
|
+
* The roots are knowable ({@link pluginInstallRoot} and {@link pluginRuntimeRoot} read them), but
|
|
3004
|
+
* `init` may run before the plugin is enabled, and the install root carries the plugin version in
|
|
3005
|
+
* its path, so an entry written once goes silently stale on an upgrade and no document can hold one
|
|
3006
|
+
* either: the entries are an operator's to add. Until this check existed the requirement lived only
|
|
3007
|
+
* in prose an adopter reaches by already knowing to look, and what a missing entry costs is a
|
|
3008
|
+
* **silent stall** — a run that reaches one of these helpers with nothing matching parks with no
|
|
3009
|
+
* error. So the check does the one thing a written-down answer cannot: it **re-resolves the roots on
|
|
3010
|
+
* every run** and prints the exact lines to paste, which turns that stall into a copy-paste and
|
|
3011
|
+
* catches a version bump that has silently invalidated entries added against a previous root.
|
|
3012
|
+
*
|
|
3013
|
+
* **Why two roots, and how each is derived.** A helper named in an **instruction file** arrives as
|
|
3014
|
+
* bytes and the agent resolves the root itself; one named in an **agent definition body** has
|
|
3015
|
+
* `${CLAUDE_PLUGIN_ROOT}` substituted by the runtime. Measured 2026-08-26 on a directory-sourced
|
|
3016
|
+
* marketplace, those two routes landed on different directories: a sub-agent resolved
|
|
3017
|
+
* `${CLAUDE_PLUGIN_ROOT}` to `<installLocation>/plugin` while `installPath` named a version-pinned
|
|
3018
|
+
* cache snapshot. Both are therefore graded — the install root from `installed_plugins.json`, the
|
|
3019
|
+
* runtime root from `known_marketplaces.json` plus the marketplace manifest it locates — and where
|
|
3020
|
+
* they resolve to one directory, which is every git-sourced adoption, the two sets dedup to one.
|
|
3021
|
+
*
|
|
3022
|
+
* **What is graded at each, and what deliberately is not.**
|
|
3023
|
+
*
|
|
3024
|
+
* - One rule per helper script at **every** graded root, required **only while `phases.qa` is true**,
|
|
3025
|
+
* because those scripts are the interactive-test phase's alone. With the phase off this check says
|
|
3026
|
+
* nothing whatever about them: a warning nobody with that phase off can act on is one they learn
|
|
3027
|
+
* to skip.
|
|
3028
|
+
* - A `Read` rule at a runtime root that differs from the install root, **not** phase-gated:
|
|
3029
|
+
* instruction files and samples are read by every unattended run, interactive-test phase or not.
|
|
3030
|
+
* - **No `Read` rule over the install root**, and the asymmetry is a measurement rather than a
|
|
3031
|
+
* taste. Measured 2026-08-26, under a generated profile naming no rule over either root: sixteen
|
|
3032
|
+
* `Read` calls under the install root succeeded, over eight distinct instruction files, while ten
|
|
3033
|
+
* under the runtime root were refused in the same run.
|
|
3034
|
+
*
|
|
3035
|
+
* With nothing left to grade — the phase off at a single root — it reports **not graded** and names
|
|
3036
|
+
* which, rather than a pass an adopter would read as coverage.
|
|
3037
|
+
*
|
|
3038
|
+
* The helper names come from **reading `<root>/scripts/`** ({@link pluginHelperScripts}) at each
|
|
3039
|
+
* graded root and taking the union, never from a list kept here: they are declared once, in the
|
|
3040
|
+
* plugin's own `scripts/README.md`, and a copy in this file would be a second declaration that
|
|
3041
|
+
* drifts the first time one is added. Nothing here classifies a helper by its call site either —
|
|
3042
|
+
* the root set is what varies, and the name set stays read from disk.
|
|
3043
|
+
*
|
|
3044
|
+
* **It never fails**, for {@link REPO_REGISTRY_CHECK}'s reason: this is a machine-local gap with an
|
|
3045
|
+
* operator remedy, and the profile is a file the adopter owns. And when no root resolves it invents
|
|
3046
|
+
* none — it names the step and the file it read, because a fabricated path is worse than no path: an
|
|
3047
|
+
* operator would paste it and get a profile that is wrong in a way nothing reports.
|
|
3048
|
+
*
|
|
3049
|
+
* **A helper entry under no resolved root is named in every *graded* disposition and moves no
|
|
3050
|
+
* grade.** A profile carrying dead weight and every required entry still passes; one missing a
|
|
3051
|
+
* required entry still warns. Informational because the remedy is a deletion the adopter owns, and
|
|
3052
|
+
* because a check that failed over an entry costing nothing at run time is one adopters learn to
|
|
3053
|
+
* ignore. Only the helper form is tested: a `Read` granted over a reference implementation outside
|
|
3054
|
+
* the checkout — the parity phase's own case — is correct configuration, and naming it would be a
|
|
3055
|
+
* false positive.
|
|
3056
|
+
*
|
|
3057
|
+
* **Where no root resolves, those entries are counted rather than named**, both forms of them: the
|
|
3058
|
+
* disposition that grades nothing may not call an entry stale or required, but a count says the
|
|
3059
|
+
* check read the file rather than passing over it in silence — and it is the same set `init --force`
|
|
3060
|
+
* carries forward unverified on that arm (`generators/permissionProfile.ts`,
|
|
3061
|
+
* `carriedPluginRootEntries`).
|
|
3062
|
+
*/
|
|
3063
|
+
const PLUGIN_PERMISSIONS_CHECK = {
|
|
3064
|
+
id: 'plugin-permissions',
|
|
3065
|
+
title: "the permission profile grants what a run needs at the plugin's roots",
|
|
3066
|
+
run: (ctx) => {
|
|
3067
|
+
if (ctx.repoRoot === undefined)
|
|
3068
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
3069
|
+
if (ctx.profile === undefined)
|
|
3070
|
+
return unevaluated(`${PROFILE_PATH} could not be read (see the permission-profile check)`);
|
|
3071
|
+
// Read here rather than beside `missing` below, because both the not-graded and the no-root
|
|
3072
|
+
// dispositions return above that point and each interpolates a count out of it. A pure read of
|
|
3073
|
+
// the profile: it neither writes nor throws on a malformed one, and `missing` is still computed
|
|
3074
|
+
// from it where it always was.
|
|
3075
|
+
const allowed = permissionEntries(ctx.profile, 'allow');
|
|
3076
|
+
// Hoisted with it, and for the same reason: the no-root count excludes this checkout's own
|
|
3077
|
+
// wrapper entries, whose repo-root-absolute form is byte-identical to a plugin helper's.
|
|
3078
|
+
const here = normalizedRoot(ctx.repoRoot);
|
|
3079
|
+
const installRoot = pluginInstallRoot(ctx.repoRoot);
|
|
3080
|
+
const runtimeRoot = pluginRuntimeRoot(ctx.repoRoot);
|
|
3081
|
+
// The runtime root leads, because it is the one carrying the extra requirement; de-duplicated,
|
|
3082
|
+
// so the ordinary machine — where the two records name one directory — prints one set of lines.
|
|
3083
|
+
const roots = [...new Set([runtimeRoot, installRoot].filter((root) => root !== undefined))];
|
|
3084
|
+
const [firstRoot, ...furtherRoots] = roots;
|
|
3085
|
+
if (firstRoot === undefined) {
|
|
3086
|
+
// Counted, never named. Both dictated forms are counted — the helper `Bash` and the runtime
|
|
3087
|
+
// root's `Read` — because with no root resolved nothing distinguishes them, and they are the
|
|
3088
|
+
// set `init --force` carries forward unverified on this same arm. Naming one would require
|
|
3089
|
+
// calling it stale or required, which is a judgement no root answered for; the check's standing
|
|
3090
|
+
// rule that it invents nothing holds, so the disposition reports a number and no path.
|
|
3091
|
+
const ungraded = allowed.filter((entry) => {
|
|
3092
|
+
const target = pluginRootEntryTarget(entry);
|
|
3093
|
+
return target !== undefined && !isUnderDirectory(target, here);
|
|
3094
|
+
}).length;
|
|
3095
|
+
const dangling = ungraded === 0
|
|
3096
|
+
? ''
|
|
3097
|
+
: `. ${PROFILE_PATH} carries ${ungraded} absolute-directory \`permissions.allow\` ${entryWord(ungraded)} outside this checkout that nothing here can grade until a root resolves, counted rather than named because calling one stale or required would be a judgement no record on this machine supports; \`${CLI} init --force\` carries ${ungraded === 1 ? 'it' : 'them'} forward unverified in the meantime`;
|
|
3098
|
+
return warn(`${installedPluginsPath()} records no install root for ${PLUGIN_KEY}, so the entries ${PROFILE_PATH} needs for it cannot be named and are not guessed at: enable the plugin — open this repository with the agent runner once, which applies the ${SETTINGS_PATH} keys \`${CLI} init\` wrote — and re-run \`${CLI} doctor\`, which reads the root back and prints the exact lines to paste${dangling}`);
|
|
3099
|
+
}
|
|
3100
|
+
// Entries in the helper form whose directory no root here answers for. Two exclusions, both
|
|
3101
|
+
// load-bearing: the `Read` form is never tested, because a read granted over a reference
|
|
3102
|
+
// implementation outside the checkout is correct configuration; and nothing under this checkout
|
|
3103
|
+
// is, because a wrapper's own `bash <repo root>/<scriptsDir>/<file>.sh` renders byte-identically
|
|
3104
|
+
// to a plugin helper and `pluginRootEntryTarget` deliberately leaves that discrimination here.
|
|
3105
|
+
const resolvedRoots = roots.map(normalizedRoot);
|
|
3106
|
+
const strays = allowed.flatMap((entry) => {
|
|
3107
|
+
const target = namesHelperScript(entry) ? pluginRootEntryTarget(entry) : undefined;
|
|
3108
|
+
if (target === undefined || isUnderDirectory(target, here))
|
|
3109
|
+
return [];
|
|
3110
|
+
return resolvedRoots.some((root) => isUnderDirectory(target, root)) ? [] : [`${entry} — ${target}`];
|
|
3111
|
+
});
|
|
3112
|
+
// Carried into all three surviving dispositions, so what the report says about dead weight does
|
|
3113
|
+
// not depend on which arm this machine happens to be in. Empty set, empty clause — as `partial`.
|
|
3114
|
+
const stray = strays.length === 0
|
|
3115
|
+
? ''
|
|
3116
|
+
: ` ${PROFILE_PATH} also carries ${strays.length} helper ${entryWord(strays.length)} naming a directory no plugin root on this machine resolves — ${nameList(strays)} — so ${strays.length === 1 ? 'it is' : 'they are'} dead weight: the install root carries the plugin version, and an upgrade leaves an entry added against the previous one naming nothing. Nothing else grades ${strays.length === 1 ? 'it' : 'them'}; the entries this machine's roots do need are the ones this check grades. Delete ${strays.length === 1 ? 'it' : 'them'}, or run \`${CLI} init --force\`, which carries forward only the entries at a root that still resolves.`;
|
|
3117
|
+
const split = furtherRoots.length > 0;
|
|
3118
|
+
// A config that did not parse leaves the phase unknowable, so the report says the helper entries
|
|
3119
|
+
// were not graded rather than reading an absent phase as "off" and reporting a repository as
|
|
3120
|
+
// complete that is one unreadable file away from a stall.
|
|
3121
|
+
const phaseKnown = ctx.config !== undefined;
|
|
3122
|
+
const qaOn = ctx.config?.phases?.qa === true;
|
|
3123
|
+
const helpers = qaOn ? [...new Set(roots.flatMap((root) => pluginHelperScripts(root)))].sort() : [];
|
|
3124
|
+
const groups = roots.map((root) => ({
|
|
3125
|
+
root,
|
|
3126
|
+
label: split
|
|
3127
|
+
? root === runtimeRoot
|
|
3128
|
+
? `the directory this marketplace is sourced from (${knownMarketplacesPath()} → \`installLocation\` + the marketplace manifest's plugin \`source\`), which the runtime substitutes for \`\${CLAUDE_PLUGIN_ROOT}\``
|
|
3129
|
+
: `the install root ${installedPluginsPath()} records`
|
|
3130
|
+
: installRoot === undefined
|
|
3131
|
+
? `the directory this marketplace is sourced from (the only root that resolved: ${installedPluginsPath()} records no install root)`
|
|
3132
|
+
: `the one plugin root this machine resolves, recorded in ${installedPluginsPath()}`,
|
|
3133
|
+
required: [
|
|
3134
|
+
// Outside the phase gate, and only where the runtime root is its own directory: reads at the
|
|
3135
|
+
// install root were measured to succeed ungranted, ten at the runtime root to be refused.
|
|
3136
|
+
...(root === installRoot
|
|
3137
|
+
? []
|
|
3138
|
+
: [{ rule: readRule(root), symptom: 'improvises in place of a contract file it is refused' }]),
|
|
3139
|
+
...helpers.map((name) => ({
|
|
3140
|
+
rule: bashScriptRule(pluginHelperPath(root, name)),
|
|
3141
|
+
symptom: 'parks with no error at the first helper script it reaches',
|
|
3142
|
+
})),
|
|
3143
|
+
],
|
|
3144
|
+
}));
|
|
3145
|
+
const required = groups.flatMap((group) => group.required);
|
|
3146
|
+
if (required.length === 0) {
|
|
3147
|
+
const reason = !phaseKnown
|
|
3148
|
+
? `${CONFIG_FILENAME} could not be read, so the phase the helper scripts belong to is unknown (see the config check)`
|
|
3149
|
+
: qaOn
|
|
3150
|
+
? `no helper script was found under ${pluginScriptsDir(firstRoot)}: a plugin root with no scripts directory is a broken or partial install, and re-enabling the plugin is what repairs it`
|
|
3151
|
+
: "phases.qa is off, and the helper scripts are that phase's alone";
|
|
3152
|
+
return pass(`not graded at this machine's plugin root (${firstRoot}), because ${reason}.${stray}`);
|
|
3153
|
+
}
|
|
3154
|
+
// Two graded roots and no helper name under either is still a broken install, and the read rule
|
|
3155
|
+
// alone would otherwise let it pass in silence once pasted.
|
|
3156
|
+
const partial = qaOn && helpers.length === 0
|
|
3157
|
+
? ' No helper script was found under any graded root, so none is required here: that is a broken or partial install, which re-enabling the plugin repairs.'
|
|
3158
|
+
: '';
|
|
3159
|
+
// Stated once, in both dispositions, because an operator reading either has to know why a line
|
|
3160
|
+
// they already pasted at one root reappears at the other, and why only one root carries a read
|
|
3161
|
+
// rule. `coincide` is the ordinary machine: one directory, one set of entries, no read rule.
|
|
3162
|
+
const coincide = !split && installRoot !== undefined;
|
|
3163
|
+
const why = (split
|
|
3164
|
+
? ` Both roots are graded because a helper named in an instruction file is resolved by the agent itself while one named in an agent definition body has \`\${CLAUDE_PLUGIN_ROOT}\` substituted by the runtime, and on this machine those two routes were measured to land on different directories.`
|
|
3165
|
+
: '') +
|
|
3166
|
+
(coincide
|
|
3167
|
+
? ''
|
|
3168
|
+
: ` The \`Read\` entry is graded at the runtime root and not at the install root because reads at the install root were measured (2026-08-26) to succeed under a profile naming no rule over it, while ten at the runtime root were refused in that same run; it is outside the \`phases.qa\` gate, because instruction files and samples are read by every run.`);
|
|
3169
|
+
const missing = required.filter((entry) => !allowed.includes(entry.rule));
|
|
3170
|
+
if (missing.length > 0) {
|
|
3171
|
+
const symptoms = [...new Set(missing.map((entry) => entry.symptom))].join(', and ');
|
|
3172
|
+
const blocks = groups
|
|
3173
|
+
.map((group) => ({ group, rules: group.required.filter((entry) => missing.includes(entry)).map((entry) => entry.rule) }))
|
|
3174
|
+
.filter(({ rules }) => rules.length > 0)
|
|
3175
|
+
.map(({ group, rules }) => renderRootGroup(group.label, group.root, rules));
|
|
3176
|
+
return warn(`${PROFILE_PATH} is missing ${missing.length} of the ${required.length} \`permissions.allow\` ${entryWord(required.length)} this machine's plugin ${split ? 'roots need' : 'root needs'}, so an unattended run ${symptoms}. \`${CLI} init\` does not generate ${missing.length === 1 ? 'it' : 'them'} — a root is machine-local and the install root carries the plugin version, so an entry written once goes stale on an upgrade and this check re-derives ${split ? 'both' : 'it'} instead.${why}${partial}${stray} Add each line below to that list as its own string, unquoted exactly as it stands:\n${blocks.join('\n\n')}`);
|
|
3177
|
+
}
|
|
3178
|
+
// Per-root counts only where there is more than one root to attribute them to; with a single
|
|
3179
|
+
// root the leading total already says how many, and repeating it reads as a second figure.
|
|
3180
|
+
const carried = groups
|
|
3181
|
+
.map((group) => split ? `${group.label} — ${group.root}: ${group.required.length} ${entryWord(group.required.length)}` : `${group.label} — ${group.root}`)
|
|
3182
|
+
.join('; ');
|
|
3183
|
+
return pass(`${PROFILE_PATH} carries all ${required.length} \`permissions.allow\` ${entryWord(required.length)} this machine's plugin ${split ? 'roots need' : 'root needs'} — ${carried}.${why}${partial}${stray}${coincide ? ` \`${readRule(firstRoot)}\` is deliberately not one of them — measured 2026-08-26, reads under that root succeed under a profile carrying no rule naming it.` : ''}`);
|
|
3184
|
+
},
|
|
3185
|
+
};
|
|
3186
|
+
/**
|
|
3187
|
+
* The package specs a declared-server object would fetch from the registry on first use.
|
|
3188
|
+
*
|
|
3189
|
+
* The rule is `npx`'s own, and it is why the derivation is narrow: `npx` runs the first argument that
|
|
3190
|
+
* is not a flag, and the generated wiring puts `-y` in front of it, so that argument is fetched at
|
|
3191
|
+
* first use rather than installed by `init` — nothing is added to the adopter's `package.json` and no
|
|
3192
|
+
* global install happens. A server launched by **any other command** contributes nothing: whatever it
|
|
3193
|
+
* runs is already on the machine or is not, which is the question {@link resolvesOnPath} already
|
|
3194
|
+
* answers for it, and there is no fetch to probe.
|
|
3195
|
+
*/
|
|
3196
|
+
function registrySpecs(servers) {
|
|
3197
|
+
const specs = [];
|
|
3198
|
+
for (const [server, declaration] of Object.entries(servers)) {
|
|
3199
|
+
if (!isJsonObject(declaration))
|
|
3200
|
+
continue;
|
|
3201
|
+
if (declaration['command'] !== REGISTRY_LAUNCHER)
|
|
3202
|
+
continue;
|
|
3203
|
+
const args = declaration['args'];
|
|
3204
|
+
if (!Array.isArray(args))
|
|
3205
|
+
continue;
|
|
3206
|
+
const spec = args.find((argument) => typeof argument === 'string' && argument !== '' && !argument.startsWith('-'));
|
|
3207
|
+
if (spec !== undefined)
|
|
3208
|
+
specs.push({ server, spec });
|
|
3209
|
+
}
|
|
3210
|
+
return specs;
|
|
3211
|
+
}
|
|
3212
|
+
/**
|
|
3213
|
+
* What a failed probe printed — its **stderr** first, which is where `npm` writes registry errors,
|
|
3214
|
+
* then its stdout, then the thrown value's own message when it printed nothing at all (a spawn
|
|
3215
|
+
* failure or the timeout, neither of which is the client speaking).
|
|
3216
|
+
*/
|
|
3217
|
+
function probeFailureText(error) {
|
|
3218
|
+
const streams = error;
|
|
3219
|
+
for (const stream of [streams.stderr, streams.stdout]) {
|
|
3220
|
+
const line = typeof stream === 'string' ? firstLine(stream) : '';
|
|
3221
|
+
if (line !== '')
|
|
3222
|
+
return line;
|
|
3223
|
+
}
|
|
3224
|
+
return firstLine(messageOf(error));
|
|
3225
|
+
}
|
|
3226
|
+
/**
|
|
3227
|
+
* Ask the registry which version one pinned spec has — the whole of what `doctor --check-registry`
|
|
3228
|
+
* adds, and the only thing in this module that reaches a network.
|
|
3229
|
+
*
|
|
3230
|
+
* `npm view <spec> version` is a metadata read: it fetches no package, installs nothing, writes
|
|
3231
|
+
* nothing into the repository and needs no browser. What it answers is the question a first
|
|
3232
|
+
* interactive-test dispatch would otherwise answer hours later — whether **this** pin can be reached
|
|
3233
|
+
* from **this** machine's registry, which an offline, proxied or mirrored adopter finds out at the
|
|
3234
|
+
* worst possible moment. The pin is what raises the stakes: a mirror carrying *some* version of a
|
|
3235
|
+
* package but not the declared one fails where an unpinned fetch might have succeeded.
|
|
3236
|
+
*
|
|
3237
|
+
* The probe follows {@link JQ_CHECK}'s discipline exactly — existence answered by
|
|
3238
|
+
* {@link resolvesOnPath} rather than by the spawn, so "no client" and "the client did not answer" are
|
|
3239
|
+
* two findings instead of one; `execFileSync` with a fixed argument vector, never a shell string,
|
|
3240
|
+
* bounded by a timeout; and nothing thrown, because a probe is a finding to print and one unreachable
|
|
3241
|
+
* package may not cost the report the rest of its answers.
|
|
3242
|
+
*/
|
|
3243
|
+
function probeRegistrySpec(candidate) {
|
|
3244
|
+
if (!resolvesOnPath(REGISTRY_CLIENT))
|
|
3245
|
+
return { ...candidate, outcome: 'no-client' };
|
|
3246
|
+
try {
|
|
3247
|
+
const output = execFileSync(REGISTRY_CLIENT, ['view', candidate.spec, 'version'], {
|
|
3248
|
+
encoding: 'utf8',
|
|
3249
|
+
// stderr is piped, not ignored: `npm` writes its registry errors there and nowhere else, and the
|
|
3250
|
+
// finding this probe exists for is which error it was — E404 (raise the pin) reads differently
|
|
3251
|
+
// from ENOTFOUND or a proxy 403 (fix the mirror), and the warning names both remedies. Naming
|
|
3252
|
+
// all three streams also keeps that text out of this command's own output, which is where an
|
|
3253
|
+
// unspecified `stdio` would forward it.
|
|
3254
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
3255
|
+
timeout: REGISTRY_PROBE_TIMEOUT_MS,
|
|
3256
|
+
windowsHide: true,
|
|
3257
|
+
});
|
|
3258
|
+
return { ...candidate, outcome: 'reachable', version: firstLine(output) };
|
|
3259
|
+
}
|
|
3260
|
+
catch (error) {
|
|
3261
|
+
return { ...candidate, outcome: 'unreachable', printed: probeFailureText(error) };
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
/**
|
|
3265
|
+
* The interactive-test phase's wiring, when that phase is on — and, when it drives a browser, that
|
|
3266
|
+
* the browser half of it lines up.
|
|
3267
|
+
*
|
|
3268
|
+
* Three things have to line up and none of them fails loudly on its own: `.mcp.json` has to declare
|
|
3269
|
+
* the servers, the profile has to start the ones it allow-lists tools of, and the launch command has
|
|
3270
|
+
* to exist on this machine. A missing one leaves a tool that does not exist at run time, and an
|
|
3271
|
+
* un-loaded tool in print mode stalls rather than prompting. All are `warn`: the phase is optional,
|
|
3272
|
+
* and a repository whose browser wiring is incomplete still runs everything else.
|
|
3273
|
+
*
|
|
3274
|
+
* **Whether there is browser wiring to check at all is {@link browserWiringApplies}'s answer, not a
|
|
3275
|
+
* second one spelled here** (choice 1 in the module header). The generators write `.mcp.json` and the
|
|
3276
|
+
* profile's interactive-test fragment on exactly that predicate, so asking a differently-worded
|
|
3277
|
+
* question would make `doctor` report a mobile-configured repository as missing wiring `init`
|
|
3278
|
+
* deliberately withheld — a warning nobody can clear, and a check nobody can clear is a check
|
|
3279
|
+
* everybody starts ignoring.
|
|
3280
|
+
*
|
|
3281
|
+
* **No server is started and no browser is launched** — see choice 3 in the module header.
|
|
3282
|
+
*
|
|
3283
|
+
* **What resolving `npx` does not prove is the fourth thing, and it is why the default run says so.**
|
|
3284
|
+
* The declared servers are launched with `npx -y`, so each pinned package is fetched from the
|
|
3285
|
+
* registry on first use rather than installed by `init`: `npx` resolving says nothing about whether
|
|
3286
|
+
* the pin can be reached from this machine. Under {@link CheckContext.probeRegistry} that is measured
|
|
3287
|
+
* per spec ({@link probeRegistrySpec}) and an unreachable one warns beside the findings above; with
|
|
3288
|
+
* it off — every default run — the pass text states plainly that the packages are fetched on first
|
|
3289
|
+
* use and that this run did not check they can be, and names the flag that does.
|
|
3290
|
+
*/
|
|
3291
|
+
const BROWSER_WIRING_CHECK = {
|
|
3292
|
+
id: 'browser-wiring',
|
|
3293
|
+
title: 'the interactive-test phase can reach the application',
|
|
3294
|
+
run: (ctx) => {
|
|
3295
|
+
if (ctx.repoRoot === undefined)
|
|
3296
|
+
return unevaluated('the repository root did not resolve (see the git check)');
|
|
3297
|
+
if (ctx.config === undefined)
|
|
3298
|
+
return unevaluated(`${CONFIG_FILENAME} could not be read (see the config check)`);
|
|
3299
|
+
if (ctx.config.phases?.qa !== true) {
|
|
3300
|
+
return pass(`phases.qa is off, so no browser wiring is expected: nothing drives a browser, and no ${MCP_PATH} is needed`);
|
|
3301
|
+
}
|
|
3302
|
+
if (!browserWiringApplies(ctx.config)) {
|
|
3303
|
+
// Only a non-browser driver reaches here: the phase is on, and an absent `qa.driver` takes the
|
|
3304
|
+
// schema default, which is the browser one and so goes down the branch below.
|
|
3305
|
+
const driver = ctx.config.qa?.driver ?? DEFAULTS.qa.driver;
|
|
3306
|
+
return pass(`phases.qa is on with qa.driver ${driver}, which reaches the application through a device runner rather than a browser, so no browser wiring is expected: the mobile interactive-test variants are declared-not-implemented in this release and carry built-ins-only tool allowlists, so no MCP server is declared in ${MCP_PATH}, none is named in ${PROFILE_PATH}, and none is started`);
|
|
3307
|
+
}
|
|
3308
|
+
const path = join(ctx.repoRoot, MCP_PATH);
|
|
3309
|
+
let parsed;
|
|
3310
|
+
try {
|
|
3311
|
+
parsed = readJsonFile(path);
|
|
3312
|
+
}
|
|
3313
|
+
catch (error) {
|
|
3314
|
+
return warn(messageOf(error));
|
|
3315
|
+
}
|
|
3316
|
+
if (parsed === undefined) {
|
|
3317
|
+
return warn(`phases.qa is on and there is no ${MCP_PATH} at ${path}, so no browser server is declared and the phase has nothing to drive — run \`${CLI} init\`, which merges the wiring in when the phase is on`);
|
|
3318
|
+
}
|
|
3319
|
+
if (!isJsonObject(parsed))
|
|
3320
|
+
return warn(`${path} is not a JSON object, so it declares no MCP server`);
|
|
3321
|
+
const servers = parsed[SERVERS_KEY];
|
|
3322
|
+
if (!isJsonObject(servers))
|
|
3323
|
+
return warn(`${path} has no \`${SERVERS_KEY}\` object, so it declares no MCP server`);
|
|
3324
|
+
const declared = Object.keys(servers);
|
|
3325
|
+
if (declared.length === 0)
|
|
3326
|
+
return warn(`${path} declares no MCP server under \`${SERVERS_KEY}\``);
|
|
3327
|
+
const problems = [];
|
|
3328
|
+
const started = ctx.profile === undefined ? [] : serversStartedByProfile(ctx.profile);
|
|
3329
|
+
const undeclared = started.filter((name) => !declared.includes(name));
|
|
3330
|
+
if (undeclared.length > 0) {
|
|
3331
|
+
problems.push(`${PROFILE_PATH} starts ${nameList(undeclared)}, which ${MCP_PATH} does not declare, so the server never starts and the tools allow-listed for it do not exist at run time — an un-loaded tool stalls an unattended run rather than failing it`);
|
|
3332
|
+
}
|
|
3333
|
+
if (ctx.profile !== undefined && started.length === 0) {
|
|
3334
|
+
problems.push(`${PROFILE_PATH} has no \`${ENABLED_SERVERS_KEY}\` list, so an unattended run starts none of these servers: the phase was turned on after the profile was generated — regenerate it with \`${CLI} init --force\`, which writes a .bak sibling first. No flag is part of the remedy: init reads ${CONFIG_FILENAME} on every run and never overwrites it, and this check reaches here only with phases.qa already true in that file, so --force regenerates the profile from the config in effect — the one this check just read`);
|
|
3335
|
+
}
|
|
3336
|
+
const unresolved = [];
|
|
3337
|
+
for (const name of declared) {
|
|
3338
|
+
const server = servers[name];
|
|
3339
|
+
const command = isJsonObject(server) ? server['command'] : undefined;
|
|
3340
|
+
if (typeof command !== 'string' || command === '') {
|
|
3341
|
+
unresolved.push(`${name} (declares no launch command)`);
|
|
3342
|
+
}
|
|
3343
|
+
else if (!resolvesOnPath(command)) {
|
|
3344
|
+
unresolved.push(`${name} (${command} does not resolve on PATH)`);
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
if (unresolved.length > 0) {
|
|
3348
|
+
problems.push(`${nameList(unresolved)} — the phase would fail to start ${unresolved.length === 1 ? 'that server' : 'those servers'} on this machine`);
|
|
3349
|
+
}
|
|
3350
|
+
// What a resolved `npx` does not answer: whether the pins it would fetch can be reached from
|
|
3351
|
+
// here. Derived on every run so the sentence below can name them; probed only when asked.
|
|
3352
|
+
const specs = registrySpecs(servers);
|
|
3353
|
+
const specList = nameList(specs.map((candidate) => candidate.spec));
|
|
3354
|
+
const probes = ctx.probeRegistry ? specs.map(probeRegistrySpec) : [];
|
|
3355
|
+
const unreachable = probes.filter((probe) => probe.outcome === 'unreachable');
|
|
3356
|
+
if (unreachable.length > 0) {
|
|
3357
|
+
const said = unreachable
|
|
3358
|
+
.map((probe) => `${probe.spec} (${probe.server}: ${JSON.stringify(probe.printed)})`)
|
|
3359
|
+
.join(', ');
|
|
3360
|
+
problems.push(`\`${REGISTRY_CLIENT} view <spec> version\` could not reach ${said} from this machine's registry, so the interactive-test phase would fail at its first dispatch rather than here: these packages are fetched on first use, and a pinned version a mirror does not carry fails where an unpinned fetch might have succeeded — check the registry, proxy or mirror this machine resolves, or raise the pin to a version it carries`);
|
|
3361
|
+
}
|
|
3362
|
+
// The question was never put, which is neither a reachable package nor an unreachable one: said
|
|
3363
|
+
// as its own sentence, wherever the grade lands, so nobody reads it as a registry problem.
|
|
3364
|
+
const noClient = probes.some((probe) => probe.outcome === 'no-client')
|
|
3365
|
+
? ` The registry probe ran nothing: ${REGISTRY_CLIENT} does not resolve on PATH, so whether ${specList} can be fetched is still unchecked — this is an absent client rather than an unreachable registry, and \`${REGISTRY_LAUNCHER}\` launching these servers comes from the same install.`
|
|
3366
|
+
: '';
|
|
3367
|
+
if (problems.length > 0)
|
|
3368
|
+
return warn(`${problems.join('; ')}${noClient}`);
|
|
3369
|
+
const reached = probes.filter((probe) => probe.outcome === 'reachable');
|
|
3370
|
+
// Keyed on what was measured, never on the flag: `--check-registry` with no `npx`-launched
|
|
3371
|
+
// server declared, or with no `npm` on PATH, puts no question to a registry at all, and a line
|
|
3372
|
+
// that says otherwise is contradicted by the `noClient` sentence beside it.
|
|
3373
|
+
const resolution = reached.length > 0
|
|
3374
|
+
? ', and the registry was read for metadata only'
|
|
3375
|
+
: ', because this check is command resolution only';
|
|
3376
|
+
const registryNote = specs.length === 0
|
|
3377
|
+
? ''
|
|
3378
|
+
: ctx.probeRegistry
|
|
3379
|
+
? reached.length === 0
|
|
3380
|
+
? ''
|
|
3381
|
+
: ` ${reached.map((probe) => `${probe.spec} → ${probe.version}`).join(', ')} — each pinned package was reached in the registry with \`${REGISTRY_CLIENT} view <spec> version\`, a metadata read that fetched and installed nothing.`
|
|
3382
|
+
: ` These servers are launched with \`${REGISTRY_LAUNCHER} -y\`, so each pinned package — ${specList} — is fetched from the registry on first use rather than installed now; this run did not check that it can be, and \`${CLI} doctor --check-registry\` does.`;
|
|
3383
|
+
return pass(`${MCP_PATH} declares ${nameList(declared)} and every launch command resolves on PATH; no server was started and no browser was launched${resolution}.${registryNote}${noClient}`);
|
|
3384
|
+
},
|
|
3385
|
+
};
|
|
3386
|
+
/**
|
|
3387
|
+
* The checks, in the order they are evaluated and reported: the host and the tools first, then the
|
|
3388
|
+
* repository's own wiring, then the permission profile and the browser half that depends on it.
|
|
3389
|
+
*
|
|
3390
|
+
* A reader works down that order, and so does a fix: a failed `git` check makes every question below
|
|
3391
|
+
* it unanswerable, and a missing profile makes the three profile checks unanswerable, so the first
|
|
3392
|
+
* failure in the list is almost always the one to act on. The two machine-level tool checks are kept
|
|
3393
|
+
* at the top for the same reason: they answer for the guards' and the scripts' behaviour, which a
|
|
3394
|
+
* repository-level finding further down would otherwise be read as the cause of.
|
|
3395
|
+
*
|
|
3396
|
+
* `jj-repository` sits immediately under `git` because it is that check's own question one step on:
|
|
3397
|
+
* `git` establishes that there **is** a work tree, and this says what **shape** of work tree it is.
|
|
3398
|
+
* Every branch line below it is qualified by that answer — the detection rung a detached `HEAD`
|
|
3399
|
+
* costs, and the caller set the committed pre-push hook actually reaches — which is why it is read
|
|
3400
|
+
* before them rather than after. On a plain git repository it says so in one line and grades nothing.
|
|
3401
|
+
*
|
|
3402
|
+
* `default-branch` and `remote` are the two repository-level questions placed between them, because
|
|
3403
|
+
* the first is the `git` check's own follow-up — the same repository, asked whether the name the
|
|
3404
|
+
* config carries is a branch it has — and the second completes it: a reader whose `default-branch`
|
|
3405
|
+
* line just passed reads next whether that name exists on a remote, which is the form the flow
|
|
3406
|
+
* actually needs it in. Neither `jq` answer can cause either or be caused by them, and everything
|
|
3407
|
+
* below them is independent of the branch.
|
|
3408
|
+
*
|
|
3409
|
+
* `base-freshness` closes that trio, immediately under `remote`, because it asks of the same ref the
|
|
3410
|
+
* one further question there is — not whether `origin/<defaultBranch>` exists but whether it is
|
|
3411
|
+
* current — and it is unanswerable until `remote`'s is answered, which is why it reports "not
|
|
3412
|
+
* graded" wherever that check has already failed rather than restating the failure.
|
|
3413
|
+
*
|
|
3414
|
+
* `pre-push-guard` closes that branch block, immediately under `base-freshness`, because it asks the
|
|
3415
|
+
* one remaining question about the same value: `default-branch` asks whether the configured name
|
|
3416
|
+
* resolves, `remote` and `base-freshness` ask what a run's checkout can be cut from, and this asks
|
|
3417
|
+
* whether the guard on disk is the one that value describes. It is placed after the trio rather than
|
|
3418
|
+
* before it so a reader who has just read three lines about one name reads next what enforces it.
|
|
3419
|
+
* `protected-set` sits immediately under it and closes that block from the opposite side: the two ask
|
|
3420
|
+
* whether the guard on disk matches the configuration, and whether the configuration still names
|
|
3421
|
+
* branches this repository has.
|
|
3422
|
+
*
|
|
3423
|
+
* `command-wrappers` sits immediately under `config`, which is the reading order of that block: is
|
|
3424
|
+
* the file valid, then do its command keys hold what a run can execute, then is the state directory
|
|
3425
|
+
* usable. The two are neighbours rather than one check because a key this one warns about is a key
|
|
3426
|
+
* the `config` line above it has just passed as structurally fine. `command-permissions` follows
|
|
3427
|
+
* directly under it and `command-resolves` closes the four, in the order they narrow on one key: the
|
|
3428
|
+
* **slot** (`config`, is it still the placeholder), the **value** (`command-wrappers`, is it the
|
|
3429
|
+
* wrapper invocation), the **entries** the value implies (`command-permissions`, does the profile
|
|
3430
|
+
* carry them and is the wrapper there), and then whether the thing the line actually runs exists on
|
|
3431
|
+
* this machine (`command-resolves`). Each reads something the one above it does not — the second
|
|
3432
|
+
* file for `command-permissions`, the machine for `command-resolves` — so a reader whose lines above
|
|
3433
|
+
* have all passed reads each as what a valid, filled-in, allow-listed config can still be missing.
|
|
3434
|
+
*
|
|
3435
|
+
* `setup-analysis` sits next to `artifact-tree` because the two answer the neighbouring halves of
|
|
3436
|
+
* "what did `init` write": the directories a run puts artifacts in, and the documents the agents it
|
|
3437
|
+
* dispatches read their rules from. `task-offer-rules` sits immediately under `setup-analysis`
|
|
3438
|
+
* because the two read the same file and grade the same namespace, and an operator reads them
|
|
3439
|
+
* together. `layer-profile` follows under those two, because
|
|
3440
|
+
* it asks the remaining question about the same act — whether the profile routing a dispatch to one
|
|
3441
|
+
* of those documents was detected or fallen back to — and it is the line a reader whose
|
|
3442
|
+
* `setup-analysis` line just passed needs next:
|
|
3443
|
+
* filled documents behind a profile nobody looked at is exactly the state the two lines together
|
|
3444
|
+
* distinguish. `layer-drift` closes that block, under the check whose subject it is gated on: the two
|
|
3445
|
+
* partition the repositories between them, one grading the profile that carries no row scoped below
|
|
3446
|
+
* the repository root and the other grading only the profiles that carry one, so no repository is
|
|
3447
|
+
* reported twice.
|
|
3448
|
+
*
|
|
3449
|
+
* `notifications` sits directly under `run-watcher` because the two are about the same thing from an
|
|
3450
|
+
* operator's side: whether an unattended run can be started unattended and then be *heard from*. The
|
|
3451
|
+
* watcher is the process that fires the events and the notifier is what carries them, so a reader
|
|
3452
|
+
* whose watcher line just warned reads the delivery line next rather than hunting for it below the
|
|
3453
|
+
* profile checks. `repo-registry`, `machine-footprint` and `daemon-path` close that same
|
|
3454
|
+
* daemon-adjacent block — backend, watcher, delivery, whether a daemon was ever actually installed
|
|
3455
|
+
* from this checkout, what else the machine around it is armed to run, and then whether the one that
|
|
3456
|
+
* was installed can reach its toolchain — so the six lines an operator asking "can this repository
|
|
3457
|
+
* run unattended" reads are consecutive. `machine-footprint` sits immediately under `repo-registry`
|
|
3458
|
+
* because the two are one question at two scopes — that check answers for *this* checkout, this one
|
|
3459
|
+
* for the machine around it — and they are meant to be read together. `daemon-path` comes last of
|
|
3460
|
+
* the six because it is the only one that grades an *installed* unit: it has nothing to say until the
|
|
3461
|
+
* five above it are answered, and it says so rather than guessing.
|
|
3462
|
+
*
|
|
3463
|
+
* `plugin-permissions` closes the profile block for the same shape of reason: it is the only profile
|
|
3464
|
+
* question whose other half is not in the repository at all — the plugin's machine-local install
|
|
3465
|
+
* root — so it is answerable only once the profile itself has been read, and a reader whose
|
|
3466
|
+
* profile lines all passed reads it as the last thing that can still be missing from that file.
|
|
3467
|
+
*/
|
|
3468
|
+
export const CHECKS = Object.freeze([
|
|
3469
|
+
GIT_CHECK,
|
|
3470
|
+
JJ_REPOSITORY_CHECK,
|
|
3471
|
+
DEFAULT_BRANCH_CHECK,
|
|
3472
|
+
REMOTE_CHECK,
|
|
3473
|
+
BASE_FRESHNESS_CHECK,
|
|
3474
|
+
PRE_PUSH_GUARD_CHECK,
|
|
3475
|
+
PROTECTED_SET_CHECK,
|
|
3476
|
+
JQ_CHECK,
|
|
3477
|
+
WORKTREE_CHECK,
|
|
3478
|
+
DAEMON_BACKEND_CHECK,
|
|
3479
|
+
WATCHER_CHECK,
|
|
3480
|
+
NOTIFICATIONS_CHECK,
|
|
3481
|
+
REPO_REGISTRY_CHECK,
|
|
3482
|
+
MACHINE_FOOTPRINT_CHECK,
|
|
3483
|
+
DAEMON_PATH_CHECK,
|
|
3484
|
+
CONFIG_CHECK,
|
|
3485
|
+
COMMAND_WRAPPERS_CHECK,
|
|
3486
|
+
COMMAND_PERMISSIONS_CHECK,
|
|
3487
|
+
COMMAND_RESOLVES_CHECK,
|
|
3488
|
+
STATE_DIR_CHECK,
|
|
3489
|
+
STATE_DIR_TREE_CHECK,
|
|
3490
|
+
SETUP_ANALYSIS_CHECK,
|
|
3491
|
+
TASK_OFFER_RULES_CHECK,
|
|
3492
|
+
LAYER_PROFILE_CHECK,
|
|
3493
|
+
LAYER_DRIFT_CHECK,
|
|
3494
|
+
IGNORE_RULES_CHECK,
|
|
3495
|
+
PLUGIN_WIRING_CHECK,
|
|
3496
|
+
PROFILE_CHECK,
|
|
3497
|
+
PROFILE_PATHS_CHECK,
|
|
3498
|
+
PROFILE_BROWSER_DENY_CHECK,
|
|
3499
|
+
PROFILE_DENY_FLOOR_CHECK,
|
|
3500
|
+
PLUGIN_PERMISSIONS_CHECK,
|
|
3501
|
+
BROWSER_WIRING_CHECK,
|
|
3502
|
+
]);
|
|
3503
|
+
/**
|
|
3504
|
+
* Evaluate every check in order and return one result each.
|
|
3505
|
+
*
|
|
3506
|
+
* **The try/catch is here rather than in each check**, so the property the module header states is
|
|
3507
|
+
* structural: a check cannot forget to catch, because it is not the thing catching. A thrown value
|
|
3508
|
+
* becomes that check's `fail` naming the check that threw, and the rest of the list still runs.
|
|
3509
|
+
*/
|
|
3510
|
+
export function runChecks(ctx) {
|
|
3511
|
+
return CHECKS.map((check) => {
|
|
3512
|
+
let outcome;
|
|
3513
|
+
try {
|
|
3514
|
+
outcome = check.run(ctx);
|
|
3515
|
+
}
|
|
3516
|
+
catch (error) {
|
|
3517
|
+
outcome = fail(`the check itself threw and is reported as a failure rather than being allowed to stop the run: ${messageOf(error)}`);
|
|
3518
|
+
}
|
|
3519
|
+
return { id: check.id, title: check.title, ...outcome };
|
|
3520
|
+
});
|
|
3521
|
+
}
|
|
3522
|
+
/** How many results ended in each status — the numbers the command's summary line renders. */
|
|
3523
|
+
export function countByStatus(results) {
|
|
3524
|
+
return {
|
|
3525
|
+
pass: results.filter((result) => result.status === 'pass').length,
|
|
3526
|
+
warn: results.filter((result) => result.status === 'warn').length,
|
|
3527
|
+
fail: results.filter((result) => result.status === 'fail').length,
|
|
3528
|
+
};
|
|
3529
|
+
}
|
|
3530
|
+
//# sourceMappingURL=checks.js.map
|