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,632 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generator: the adopting repository's `harness.config.json`.
|
|
3
|
+
*
|
|
4
|
+
* It assembles one object out of stack detection and the explicit `init` flags, refuses to write
|
|
5
|
+
* one it can already tell is invalid, and enqueues it under the `create-if-absent` contract so a
|
|
6
|
+
* second `init` never rewrites a config the adopter has edited unless it was asked to — which is
|
|
7
|
+
* the whole of what `--reset-config` is. It is the artifact the schema gate
|
|
8
|
+
* validates, so everything below is written to satisfy `schemas/harness.config.schema.json` rather
|
|
9
|
+
* than to look tidy.
|
|
10
|
+
*
|
|
11
|
+
* ## Three non-obvious choices, and where each comes from
|
|
12
|
+
*
|
|
13
|
+
* 1. **The generated file omits `$schema`.** The key is optional, and `docs/config.md` §5 fixes its
|
|
14
|
+
* frame: "its value is a URI or a path resolved relative to this file's own location, not a
|
|
15
|
+
* repo-relative path". An adopter's repository has no stable relative path to a schema shipped
|
|
16
|
+
* inside an installed package, so any value written here would be wrong somewhere.
|
|
17
|
+
* `examples/harness.config.json` keeps the key because that file sits beside the schema.
|
|
18
|
+
* 2. **An optional section is omitted entirely rather than emitted empty or null.**
|
|
19
|
+
* `additionalProperties: false` holds at every level, and an empty `qa: {}` or a
|
|
20
|
+
* `parity: { }` says nothing a reader or `doctor` can act on while looking like a decision that
|
|
21
|
+
* was made. Off means absent. The same reasoning omits `clientEnvPrefix`: its default is `null`,
|
|
22
|
+
* nothing in a repository states it, and a reader that wants its effective value reads
|
|
23
|
+
* `DEFAULTS`.
|
|
24
|
+
* 3. **`commands.typecheck` / `test` / `devServer` hold the wrapper *invocation*; `build` and
|
|
25
|
+
* `depInstall` hold the **raw** detected line** (the `commands` object of
|
|
26
|
+
* `examples/harness.config.json`).
|
|
27
|
+
* The invocation string is never formatted here — it comes from `generators/scripts.ts`'s
|
|
28
|
+
* {@link wrapperInvocation}, and the file name from its {@link WRAPPER_SCRIPTS} table — because
|
|
29
|
+
* the value written here, the wrapper file that generator writes, and the permission profile's
|
|
30
|
+
* repo-relative allow entry are three views of one string, and drift between them is invisible
|
|
31
|
+
* until an unattended run stalls on a command matching neither `allow` nor `deny`.
|
|
32
|
+
*
|
|
33
|
+
* **Every path written into this file is repo-relative** (`docs/config.md` §5), `commands.*`
|
|
34
|
+
* included: commands are executed from the repository root. The profile's absolute and
|
|
35
|
+
* worktree-glob twins of a wrapper path exist so that this repo-relative value can stay
|
|
36
|
+
* schema-conformant — writing an absolute path here to "match the profile" would break the rule the
|
|
37
|
+
* twins were added to preserve.
|
|
38
|
+
*
|
|
39
|
+
* ## What this generator deliberately does not decide
|
|
40
|
+
*
|
|
41
|
+
* - **Flag-shaped refusals.** A `--state-dir` naming a dot-directory is an adopter-fixable usage
|
|
42
|
+
* error that the `init` flag surface refuses before anything is assembled. The
|
|
43
|
+
* {@link EXIT.INTERNAL} throw below is the backstop for the other case — a config this generator
|
|
44
|
+
* itself assembled wrongly, which no adopter can fix.
|
|
45
|
+
* - **`deploy`.** It is an adopter-added section this release never generates; the wrapper-script
|
|
46
|
+
* and permission-profile generators both gate their deploy handling on its presence, so emitting
|
|
47
|
+
* an empty one here would turn a deliberate absence into a half-configured deploy.
|
|
48
|
+
*/
|
|
49
|
+
import { checkConfigShape, formatProblem, hasErrors } from '../config/check.js';
|
|
50
|
+
import { configExists, loadConfig, saveConfig } from '../config/io.js';
|
|
51
|
+
import { asQaDriver, CONFIG_FILENAME, CONFIG_VERSION, DEFAULTS, isPlaceholder, QA_DRIVER_IMPLEMENTED, qaDriverChoices, } from '../config/model.js';
|
|
52
|
+
import { EXIT, HarnessError } from '../core/errors.js';
|
|
53
|
+
import { branchResolves, checkedOutBranch, configuredInitDefaultBranch, hasCommits, remoteHeadBranch, } from '../core/git.js';
|
|
54
|
+
import { defaultProjectName } from '../core/paths.js';
|
|
55
|
+
import { buildPreset } from '../detect/presets.js';
|
|
56
|
+
import { WRAPPER_SCRIPTS, wrapperInvocation } from './scripts.js';
|
|
57
|
+
/**
|
|
58
|
+
* Branch names a repository's integration line conventionally has — the trigger for the **second**
|
|
59
|
+
* arm of {@link warnAboutGuessedBranch}, and nothing else.
|
|
60
|
+
*
|
|
61
|
+
* It does not decide the value: {@link resolveDefaultBranch} detects that, and a repository whose
|
|
62
|
+
* integration line is called `develop` or `trunk` is wired to *that* name rather than overridden
|
|
63
|
+
* with a conventional one. Its role narrowed when the guard gained an arm that tests **resolution**:
|
|
64
|
+
* a guessed name resolving to no branch here is wrong whatever it is called, and that arm catches
|
|
65
|
+
* the case this list is blind to by construction — a conventional `main` guessed into a repository
|
|
66
|
+
* that has no `main`, which is the commonest wrong answer. What is left for the list is the weaker
|
|
67
|
+
* signal the resolution test cannot give: a name that *does* resolve but reads like the feature
|
|
68
|
+
* branch `init` happened to run from, wired in as the line work merges back into.
|
|
69
|
+
*
|
|
70
|
+
* The constant keeps its name and its two entries, so it stays greppable against the finding.
|
|
71
|
+
*/
|
|
72
|
+
const DEFAULT_LOOKING_BRANCHES = ['main', 'master'];
|
|
73
|
+
/**
|
|
74
|
+
* How the CLI is typed, for the remedies the guard prints. The `npx` prefix rule is stated once in
|
|
75
|
+
* `commands/init.ts`, beside its own `CLI`.
|
|
76
|
+
*/
|
|
77
|
+
const CLI = 'npx autonomous-sdlc-harness';
|
|
78
|
+
/**
|
|
79
|
+
* Seed for `qa.credentialsPath`: the gitignored file the interactive test phase reads accounts
|
|
80
|
+
* from. Exported because the generator that writes the `.example` companion has to put it beside
|
|
81
|
+
* the file an adopter is being told to create, and a second spelling here would separate them.
|
|
82
|
+
*/
|
|
83
|
+
export const QA_CREDENTIALS_PATH = '.claude/qa-accounts.env';
|
|
84
|
+
/** Seed for `docs.root` when `--docs-root` is not given. */
|
|
85
|
+
const DEFAULT_DOCS_ROOT = 'docs';
|
|
86
|
+
/**
|
|
87
|
+
* Seed for `pushEnvPath`, written whatever the phases are: the *path* is repo-scoped configuration
|
|
88
|
+
* that is the same for everyone who clones, while the file it names is machine-local and is added
|
|
89
|
+
* to `.gitignore` by the repo-root generator. Exported for the same reason as
|
|
90
|
+
* {@link QA_CREDENTIALS_PATH}: its `.example` companion is written beside it.
|
|
91
|
+
*/
|
|
92
|
+
export const PUSH_ENV_PATH = '.claude/push-notify.env';
|
|
93
|
+
/** The five command keys, in schema order, so the assembled object serializes in that order. */
|
|
94
|
+
const COMMAND_PLAN = [
|
|
95
|
+
{ key: 'typecheck', wrapped: true },
|
|
96
|
+
{ key: 'test', wrapped: true },
|
|
97
|
+
{ key: 'build', wrapped: false },
|
|
98
|
+
{ key: 'devServer', wrapped: true },
|
|
99
|
+
{ key: 'depInstall', wrapped: false },
|
|
100
|
+
];
|
|
101
|
+
/**
|
|
102
|
+
* The wrapper file one command key is invoked through, read from the wrapper table rather than
|
|
103
|
+
* spelled out, so no `.sh` name is written down twice in the CLI.
|
|
104
|
+
*/
|
|
105
|
+
function wrapperFileFor(key) {
|
|
106
|
+
const script = WRAPPER_SCRIPTS.find((entry) => entry.key === key);
|
|
107
|
+
if (script === undefined) {
|
|
108
|
+
throw new HarnessError(`no wrapper script is declared for the ${key} command, so the value written into commands.${key} could not be derived from the wrapper table: the config generator and the wrapper generator disagree about the wrapper set, which is a fault in this CLI rather than in the repository it was run against`, EXIT.INTERNAL);
|
|
109
|
+
}
|
|
110
|
+
return script.file;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The `commands` object: the wrapper invocation for the three wrapped keys, the raw detected line
|
|
114
|
+
* for the two that are not, and an optional key detection did not resolve left absent.
|
|
115
|
+
*
|
|
116
|
+
* **A key still holding the placeholder is written as the placeholder**, not as an invocation: the
|
|
117
|
+
* config check's warning and `doctor` both look for that marker, and the wrapper generator writes
|
|
118
|
+
* no script for it — so pointing `commands.typecheck` at a wrapper that was never written would
|
|
119
|
+
* turn a visible "you still have to set this" into a command that fails on a missing file.
|
|
120
|
+
*/
|
|
121
|
+
function buildCommands(scriptsDir, raw) {
|
|
122
|
+
const commands = {};
|
|
123
|
+
for (const entry of COMMAND_PLAN) {
|
|
124
|
+
const line = raw[entry.key];
|
|
125
|
+
if (line === undefined)
|
|
126
|
+
continue;
|
|
127
|
+
commands[entry.key] =
|
|
128
|
+
entry.wrapped && !isPlaceholder(line) ? wrapperInvocation(scriptsDir, wrapperFileFor(entry.key)) : line;
|
|
129
|
+
}
|
|
130
|
+
// `RawCommands` types both verifiers as required and the preset builder fills either one it
|
|
131
|
+
// could not detect with a placeholder, so the loop cannot leave them unset. Checked rather than
|
|
132
|
+
// asserted, because the cast below is exactly what would hide it if that ever changed.
|
|
133
|
+
if (commands.typecheck === undefined || commands.test === undefined) {
|
|
134
|
+
throw new HarnessError('the detected command set is missing typecheck or test, which the config schema requires: the two verification commands are what "this change is done" means', EXIT.INTERNAL);
|
|
135
|
+
}
|
|
136
|
+
return commands;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The `detection` object: stack detection's own result, copied into the config it produced, so an
|
|
140
|
+
* adopted repository states which layout was detected and on what evidence — which is what stops a
|
|
141
|
+
* `flat` fallback profile from being indistinguishable from a deliberately chosen one.
|
|
142
|
+
*
|
|
143
|
+
* Keys are set in the schema's order, as everywhere else in this generator. **`evidence` is set
|
|
144
|
+
* only when there is one** — absent rather than present-and-`undefined`, the discipline
|
|
145
|
+
* {@link buildPreset}'s `rawCommands` uses — so a `--preset` run lands `signal: "forced"`
|
|
146
|
+
* (`FORCED_SIGNAL_ID`, `detect/signals.ts`) with **no** `evidence` key at all, because no signal row
|
|
147
|
+
* was evaluated and there is nothing it matched on. That absence is what a reader of the adoption
|
|
148
|
+
* commit tells a forced preset from a detected one by.
|
|
149
|
+
*
|
|
150
|
+
* **`commandFamily` follows the same discipline** and for the same reason as recording it at all:
|
|
151
|
+
* the family that derived `commands.*` is selected independently of the preset, so it cannot be read
|
|
152
|
+
* back off `preset`, and a run with no family to name writes no key rather than a null.
|
|
153
|
+
*
|
|
154
|
+
* **What "no family to name" means is the caller's gate, not this function's.** A family answers off
|
|
155
|
+
* a *command set* (`buildPreset`'s loop, `detect/presets.ts`), which is not the two required keys: the
|
|
156
|
+
* Xcode arm can return an empty object, `composer`/`bundler` a `depInstall` alone, `npm` a `build`
|
|
157
|
+
* alone. Recording such a winner would put a family id beside a placeholder `commands.typecheck` and
|
|
158
|
+
* `commands.test` — the state every description of this key tells its reader cannot occur. So
|
|
159
|
+
* {@link buildConfig} passes the id only where {@link PresetProfile.resolvedRequired} is non-empty,
|
|
160
|
+
* on the same single-derivation rule the reporting lines in `detect/presets.ts` read it by, and the
|
|
161
|
+
* written key means **this family derived at least one of the two required commands**.
|
|
162
|
+
*
|
|
163
|
+
* **No `review` key is written here.** It is the `/harness-analyze` command's to record through
|
|
164
|
+
* `config set detection.review`, and an empty object would read as a review that happened.
|
|
165
|
+
*
|
|
166
|
+
* **Written on the run that generates the file, and never refreshed by a plain re-run.**
|
|
167
|
+
* `harness.config.json` is one of the artifacts `--force` does not upgrade (`docs/cli.md` §3), so a
|
|
168
|
+
* re-run keeps whatever record the file already holds and `--reset-config` is what rebuilds it —
|
|
169
|
+
* the same sentence {@link writeHarnessConfig}'s deferred start-over note already makes about every
|
|
170
|
+
* other re-derived value. The finding this implements said `init --force` refreshes it; that was
|
|
171
|
+
* corrected against `docs/cli.md` §3, and no refresh path is added here.
|
|
172
|
+
*/
|
|
173
|
+
function buildDetection(detection, commandFamily) {
|
|
174
|
+
const record = { preset: detection.preset, signal: detection.matchedSignal };
|
|
175
|
+
if (detection.evidence !== undefined)
|
|
176
|
+
record.evidence = detection.evidence;
|
|
177
|
+
if (commandFamily !== undefined)
|
|
178
|
+
record.commandFamily = commandFamily;
|
|
179
|
+
return record;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The two incantations that actually change a `defaultBranch` this guard has just questioned, and
|
|
183
|
+
* the closing clause of both its arms.
|
|
184
|
+
*
|
|
185
|
+
* **A bare `--default-branch` is deliberately not among them.** This guard's messages reach an
|
|
186
|
+
* adopter only through {@link BuildConfigOptions.warnIfWritten}, which publishes them exactly on the
|
|
187
|
+
* runs where this config *landed* — so by the time one is read the repository has a
|
|
188
|
+
* {@link CONFIG_FILENAME}, and the next `init` keeps that file and discards the flag while printing
|
|
189
|
+
* a note that reads as if it had been applied. Naming the flag alone is what sent an adopter to the
|
|
190
|
+
* one route that could not work.
|
|
191
|
+
*
|
|
192
|
+
* `config set` is named first because it changes the single key and re-derives nothing else.
|
|
193
|
+
* `--reset-config`'s cost is quoted from that flag's own `INIT_OPTIONS` summary (`commands/init.ts`)
|
|
194
|
+
* rather than paraphrased, so the flag is not described two ways in one tool.
|
|
195
|
+
*/
|
|
196
|
+
const GUESSED_BRANCH_REMEDY = `run \`git branch -a\` to see what this repository has, then name its integration line with \`${CLI} config set defaultBranch <branch>\`, which changes that one key — or \`${CLI} init --reset-config --default-branch <branch>\`, which rebuilds ${CONFIG_FILENAME} from detection and the flags, after a .bak`;
|
|
197
|
+
/**
|
|
198
|
+
* The warning the three *guessing* rungs carry, and neither of the two that state rather than guess.
|
|
199
|
+
*
|
|
200
|
+
* Two ordered arms, and the order is the point:
|
|
201
|
+
*
|
|
202
|
+
* 1. **The name resolves to nothing here.** That is wrong whatever the name is, because both readers
|
|
203
|
+
* of the value are then reading a ref that is not there. The consequence clause is worded from
|
|
204
|
+
* `doctor`'s `DEFAULT_BRANCH_CHECK` failure sentence (`doctor/checks.ts`), which asks this same
|
|
205
|
+
* question of this same value later, so `init` and `doctor` do not spell one fact two ways.
|
|
206
|
+
* 2. **The name resolves, but is not one an integration line conventionally has**
|
|
207
|
+
* ({@link DEFAULT_LOOKING_BRANCHES}) — the weaker signal, and the arm that was the whole guard
|
|
208
|
+
* before: `init` run from a feature branch, with an `origin/HEAD` that names nothing to outrank
|
|
209
|
+
* it.
|
|
210
|
+
*
|
|
211
|
+
* **{@link hasCommits} gates arm 1, and that gate is the arm's own precondition rather than a
|
|
212
|
+
* nicety.** In a repository with no commit *nothing* resolves, so an ungated arm 1 would fire on
|
|
213
|
+
* every `--git-init` adoption and on every repository wired between `git init` and its first commit
|
|
214
|
+
* — which is exactly the noise this warning exists not to be. The same split is why `doctor` grades
|
|
215
|
+
* that state a `warn` rather than a `fail`.
|
|
216
|
+
*
|
|
217
|
+
* The value is kept rather than replaced — replacing it is what the removed heuristic did, and it
|
|
218
|
+
* turned "your integration line is called `develop`" into a config nobody was told was wrong. So the
|
|
219
|
+
* detected name is written, and the remedies that correct it are named in the same line
|
|
220
|
+
* ({@link GUESSED_BRANCH_REMEDY}).
|
|
221
|
+
*
|
|
222
|
+
* **Deferred**, by {@link buildConfig}'s rule: both sentences assert what `defaultBranch` and
|
|
223
|
+
* `protectedBranches` *were written as*, which is false of a re-run that kept a file naming something
|
|
224
|
+
* else.
|
|
225
|
+
*/
|
|
226
|
+
function warnAboutGuessedBranch(repoRoot, branch, source, defer) {
|
|
227
|
+
if (hasCommits(repoRoot) && branchResolves(repoRoot, branch) === 'none') {
|
|
228
|
+
defer(`defaultBranch and protectedBranches were taken from ${source} as ${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. To fix it, ${GUESSED_BRANCH_REMEDY}`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (DEFAULT_LOOKING_BRANCHES.includes(branch))
|
|
232
|
+
return;
|
|
233
|
+
defer(`defaultBranch and protectedBranches were taken from ${source} as ${JSON.stringify(branch)}, with no origin/HEAD to confirm it, and that is not one of the names an integration line conventionally has (${DEFAULT_LOOKING_BRANCHES.join(', ')}): if init ran from a feature branch it has just wired that branch in as the line work merges back into, so ${GUESSED_BRANCH_REMEDY}`);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* The clause rungs 3 and 4 open with: what the rungs above them failed to answer, worded from the
|
|
237
|
+
* fact this checkout actually presented.
|
|
238
|
+
*
|
|
239
|
+
* A detached `HEAD` and a repository with no branch at all both demote the detection a rung, but
|
|
240
|
+
* only one of them is a repository with nothing to offer. Saying *no checked-out branch answered*
|
|
241
|
+
* over a detached `HEAD` reads as the second while describing the first — and on a colocated `jj`
|
|
242
|
+
* repository, where a detached `HEAD` is the steady state after every `jj new`, that is the wording
|
|
243
|
+
* every adoption run would land. So the detached case names the fact it met, inside a note that
|
|
244
|
+
* already closes by naming `--default-branch`; every other answer keeps today's sentence exactly.
|
|
245
|
+
*/
|
|
246
|
+
function unansweredAbove(head, alsoInitDefaultBranch) {
|
|
247
|
+
if (head.kind === 'detached') {
|
|
248
|
+
const detached = 'no origin/HEAD is known here and HEAD is detached, so the checked-out branch could not answer — a detached HEAD is the ordinary working state of a colocated jj repository, and it is what demoted this detection a rung';
|
|
249
|
+
return alsoInitDefaultBranch ? `${detached}, and no init.defaultBranch answered either` : detached;
|
|
250
|
+
}
|
|
251
|
+
return alsoInitDefaultBranch
|
|
252
|
+
? 'no origin/HEAD, no checked-out branch and no init.defaultBranch answered'
|
|
253
|
+
: 'no origin/HEAD and no checked-out branch answered';
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* `defaultBranch`, by **detection rather than assumption**, in one settled order:
|
|
257
|
+
*
|
|
258
|
+
* 0. `--default-branch`, which wins outright and is reported as the source it is;
|
|
259
|
+
* 1. `origin/HEAD` — the repository's own statement of its integration line, and the one rung never
|
|
260
|
+
* second-guessed;
|
|
261
|
+
* 2. the branch `HEAD` names — **including one no commit has landed on yet**, which is the state of
|
|
262
|
+
* every repository `init` wires that has no commit, whether `--git-init` created it or the
|
|
263
|
+
* adopter did and never committed into it, and the branch `init`'s own first commit is about to
|
|
264
|
+
* create ({@link checkedOutBranch} answers it there). That probe has **three** answers, not two,
|
|
265
|
+
* and the rung falls through on either of the other two — a detached `HEAD` and a repository with
|
|
266
|
+
* no branch to name — carrying which of them it met into the notes rungs 3 and 4 write
|
|
267
|
+
* ({@link unansweredAbove});
|
|
268
|
+
* 3. git's configured `init.defaultBranch`;
|
|
269
|
+
* 4. the schema default.
|
|
270
|
+
*
|
|
271
|
+
* The order is the whole of it. `master` repositories are common, and a `main` assumed over one is
|
|
272
|
+
* silent in every direction it is wrong: it is a valid config, so nothing refuses it, while every
|
|
273
|
+
* branch diff, every review's diff base and the protected-branch guard are all taken from it. That
|
|
274
|
+
* is why each rung reports **which one answered** through {@link BuildConfigOptions.noteIfWritten}
|
|
275
|
+
* rather than only reporting the value, and why every rung that is a *guess* — 2, 3 and 4 —
|
|
276
|
+
* additionally runs {@link warnAboutGuessedBranch} over what it produced. **Rung 4 included**:
|
|
277
|
+
* writing the schema default into a repository that has no branch by that name is the guess that is
|
|
278
|
+
* wrong most often, and it is the one rung that used to say nothing at all.
|
|
279
|
+
*
|
|
280
|
+
* Rungs 0 and 1 stay silent, each for its own reason: `--default-branch` is the adopter's own
|
|
281
|
+
* statement of the value, which `doctor`'s `default-branch` check grades standing rather than at
|
|
282
|
+
* write time; and `origin/HEAD` is the repository's own answer, the rung this header already calls
|
|
283
|
+
* never second-guessed.
|
|
284
|
+
*
|
|
285
|
+
* `doctor`'s `default-branch` check is the other half: it re-reads the configured name and reports
|
|
286
|
+
* whether the repository can still resolve it.
|
|
287
|
+
*
|
|
288
|
+
* **Every one of the five rung notes is deferred** ({@link BuildConfigOptions.noteIfWritten}), by
|
|
289
|
+
* {@link buildConfig}'s rule: each says what `defaultBranch` and `protectedBranches` were set from,
|
|
290
|
+
* which is false of a re-run that kept a file naming something else. The `note` parameter is that
|
|
291
|
+
* deferred sink.
|
|
292
|
+
*/
|
|
293
|
+
function resolveDefaultBranch(repoRoot, flag, defer, note) {
|
|
294
|
+
if (flag !== undefined) {
|
|
295
|
+
note(`defaultBranch and protectedBranches were set from --default-branch as ${JSON.stringify(flag)}, so nothing about the repository was consulted`);
|
|
296
|
+
return flag;
|
|
297
|
+
}
|
|
298
|
+
const fromRemoteHead = remoteHeadBranch(repoRoot);
|
|
299
|
+
if (fromRemoteHead !== undefined) {
|
|
300
|
+
note(`defaultBranch and protectedBranches were detected from origin/HEAD as ${JSON.stringify(fromRemoteHead)}, which is the repository's own statement of the line work merges back into; --default-branch overrides it`);
|
|
301
|
+
return fromRemoteHead;
|
|
302
|
+
}
|
|
303
|
+
const head = checkedOutBranch(repoRoot);
|
|
304
|
+
if (head.kind === 'branch') {
|
|
305
|
+
note(`no origin/HEAD is known here, so defaultBranch and protectedBranches were taken from the checked-out branch ${JSON.stringify(head.name)}; --default-branch overrides it`);
|
|
306
|
+
warnAboutGuessedBranch(repoRoot, head.name, 'the checked-out branch', defer);
|
|
307
|
+
return head.name;
|
|
308
|
+
}
|
|
309
|
+
const configured = configuredInitDefaultBranch(repoRoot);
|
|
310
|
+
if (configured !== undefined) {
|
|
311
|
+
note(`${unansweredAbove(head, false)}, so defaultBranch and protectedBranches were taken from git's init.defaultBranch as ${JSON.stringify(configured)}; --default-branch overrides it`);
|
|
312
|
+
warnAboutGuessedBranch(repoRoot, configured, "git's init.defaultBranch setting", defer);
|
|
313
|
+
return configured;
|
|
314
|
+
}
|
|
315
|
+
const fallback = DEFAULTS.defaultBranch;
|
|
316
|
+
note(`${unansweredAbove(head, true)}, so defaultBranch and protectedBranches were written as the schema default ${JSON.stringify(fallback)}; --default-branch overrides it`);
|
|
317
|
+
warnAboutGuessedBranch(repoRoot, fallback, 'the schema default', defer);
|
|
318
|
+
return fallback;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* The rung-independent half of {@link resolveQaDriver}: return the value that rung settled on, and
|
|
322
|
+
* record once — on **any** rung — the statement owed when it is a driver this release declares but
|
|
323
|
+
* does not implement ({@link QA_DRIVER_IMPLEMENTED}, the one owner of that state). Every rung returns
|
|
324
|
+
* through here, so the statement cannot be attached to some rungs and not others.
|
|
325
|
+
*
|
|
326
|
+
* **Recorded, not emitted.** Whether the statement is *true* is not known here: `resolveQaDriver`
|
|
327
|
+
* runs on the generation path alone, and {@link writeHarnessConfig} discards the whole generated
|
|
328
|
+
* config where a file already exists and `--reset-config` was not given. "Every interactive-test
|
|
329
|
+
* dispatch returns a blocker" is false of a repository whose own file carries a different driver, so
|
|
330
|
+
* only that caller — the one place that knows whether this config lands — decides to publish it. The
|
|
331
|
+
* standing reporter for what is *in* a config is `doctor`'s `browser-wiring` check, which names the
|
|
332
|
+
* driver and its status on every run; this line is about the choice being made here.
|
|
333
|
+
*
|
|
334
|
+
* **Published as a `warn`, not a `note`, deliberately.** The cost lands hours later at the first
|
|
335
|
+
* interactive-test dispatch, and `warn` is the one stream that survives `--quiet` (`core/report.ts`)
|
|
336
|
+
* — which is the unattended run that most needs to have been told. Nothing about the exit status
|
|
337
|
+
* changes: `init` does not fail on a warning, and a legal, schema-valid, deliberately-supported
|
|
338
|
+
* configuration stays writable — the two mobile values exist so a mobile project can record what it
|
|
339
|
+
* is.
|
|
340
|
+
*/
|
|
341
|
+
function settleQaDriver(driver, defer) {
|
|
342
|
+
if (QA_DRIVER_IMPLEMENTED[driver])
|
|
343
|
+
return driver;
|
|
344
|
+
defer(`this run resolved qa.driver to ${JSON.stringify(driver)}, and that variant of the interactive test agent ships declared but not implemented in this release: every interactive-test dispatch returns a blocker instead of running a test. The configuration is otherwise correct and nothing else is withheld by it — if this application is in fact reachable by a browser, \`config set qa.driver ${DEFAULTS.qa.driver}\` in ${CONFIG_FILENAME} is the whole change`);
|
|
345
|
+
return driver;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* `qa.driver`, in one settled order: `--qa-driver`, then the answer to the question `init` puts on a
|
|
349
|
+
* terminal, then the schema default.
|
|
350
|
+
*
|
|
351
|
+
* **Why it is asked at all.** The default drives a browser, and it is the one generated value that
|
|
352
|
+
* can be wrong in a way nothing downstream catches: every other key of a mobile project's config is
|
|
353
|
+
* correct, the file validates, `doctor` passes — and the interactive test phase runs an agent that
|
|
354
|
+
* cannot reach the application. Writing it silently made that a choice nobody was asked to make.
|
|
355
|
+
*
|
|
356
|
+
* The rungs are reported apart rather than together, because the useful thing to say differs by
|
|
357
|
+
* rung: a run that was asked is told what it chose, and only a run that could not be asked is
|
|
358
|
+
* pointed at `config set qa.driver`. Telling an adopter to go and set a key they had just answered
|
|
359
|
+
* is what the note this replaces did.
|
|
360
|
+
*
|
|
361
|
+
* An answer that is not one of the drivers falls back to the default **with a deferred warning**
|
|
362
|
+
* rather than being written — deferred by {@link buildConfig}'s rule, since the sentence asserts what
|
|
363
|
+
* `qa.driver` was written as: the prompt must not be able to produce a config the schema rejects, since
|
|
364
|
+
* {@link writeHarnessConfig}'s guard would then exit {@link EXIT.INTERNAL} and blame this CLI for
|
|
365
|
+
* the adopter's typo. Its list of the legal values comes from {@link qaDriverChoices}, the same
|
|
366
|
+
* renderer `init`'s question and its `--qa-driver` refusal print, so a value's release status is
|
|
367
|
+
* stated identically wherever one is offered or rejected.
|
|
368
|
+
*
|
|
369
|
+
* The per-rung note says *what answered*; {@link settleQaDriver} records a second line saying *what
|
|
370
|
+
* that value costs*, on whichever rung answered, for the caller to publish only where this config is
|
|
371
|
+
* the one that lands. They are kept apart for the same reason the rungs are.
|
|
372
|
+
*
|
|
373
|
+
* **All three rung notes are deferred too** ({@link BuildConfigOptions.noteIfWritten}), by the same
|
|
374
|
+
* rule: each says what `qa.driver` was written as, which a kept re-run's own file may contradict.
|
|
375
|
+
* The `note` parameter is that deferred sink.
|
|
376
|
+
*/
|
|
377
|
+
function resolveQaDriver(flag, ask, note, defer) {
|
|
378
|
+
const fallback = DEFAULTS.qa.driver;
|
|
379
|
+
if (flag !== undefined) {
|
|
380
|
+
note(`the interactive test phase is on, so qa.driver was set from --qa-driver as ${JSON.stringify(flag)} and nothing was asked`);
|
|
381
|
+
return settleQaDriver(flag, defer);
|
|
382
|
+
}
|
|
383
|
+
const answer = ask?.();
|
|
384
|
+
if (answer !== undefined) {
|
|
385
|
+
const chosen = asQaDriver(answer);
|
|
386
|
+
if (chosen !== undefined) {
|
|
387
|
+
note(`the interactive test phase is on, so qa.driver was written as ${JSON.stringify(chosen)} — the answer given when init asked which driver reaches this application; --qa-driver answers it without being asked`);
|
|
388
|
+
return settleQaDriver(chosen, defer);
|
|
389
|
+
}
|
|
390
|
+
defer(`${JSON.stringify(answer)} is not one of the drivers the interactive test phase can run (${qaDriverChoices()}), so qa.driver was written as the default ${JSON.stringify(fallback)} instead: re-run with --qa-driver, or set the key in ${CONFIG_FILENAME} with \`config set qa.driver <value>\``);
|
|
391
|
+
return settleQaDriver(fallback, defer);
|
|
392
|
+
}
|
|
393
|
+
note(`the interactive test phase is on and this run could not be asked which driver it should run — no terminal, or --non-interactive — so qa.driver was written as the documented default ${JSON.stringify(fallback)}, which reaches the application by driving a browser: pass --qa-driver to choose one without being asked, and a project whose application is not a browser application sets qa.driver in ${CONFIG_FILENAME} with \`config set qa.driver <value>\`, which names the values it accepts, before the phase runs`);
|
|
394
|
+
return settleQaDriver(fallback, defer);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Assemble the config from detection plus the flags.
|
|
398
|
+
*
|
|
399
|
+
* Keys are set in the schema's own order, because the write engine serializes the object as it
|
|
400
|
+
* stands and JSON keeps insertion order: assembling in schema order is what makes the generated
|
|
401
|
+
* file read like `examples/harness.config.json` and what makes an unchanged re-write byte-identical.
|
|
402
|
+
* Nothing here touches the filesystem beyond the read-only git probes behind
|
|
403
|
+
* {@link resolveDefaultBranch}.
|
|
404
|
+
*
|
|
405
|
+
* ## Which sink a line goes to — one rule, so a later line is not routed by feel
|
|
406
|
+
*
|
|
407
|
+
* **A line whose sentence asserts what the configuration now holds goes to the deferred sink of its
|
|
408
|
+
* stream — {@link BuildConfigOptions.warnIfWritten} for a warning,
|
|
409
|
+
* {@link BuildConfigOptions.noteIfWritten} for a note; a line about the flags this invocation
|
|
410
|
+
* carried stays on the immediate sink, {@link BuildConfigOptions.warn}.**
|
|
411
|
+
* {@link writeHarnessConfig} discards this whole config on a kept re-run, so the first kind is false
|
|
412
|
+
* there and the second is true either way. The note stream has no immediate sink because no line
|
|
413
|
+
* here needs one: a note about what this invocation carried adds a `note` option beside `warn`,
|
|
414
|
+
* binds it here and wires it in {@link writeHarnessConfig}, rather than being routed by feel into a
|
|
415
|
+
* deferred sink whose sentences it does not fit. By that rule {@link warnAboutGuessedBranch} (what
|
|
416
|
+
* `defaultBranch` and `protectedBranches` were written as), {@link resolveQaDriver}'s unknown-answer
|
|
417
|
+
* arm (what `qa.driver` was written as) and {@link settleQaDriver} (what the written driver costs)
|
|
418
|
+
* are deferred warnings, and {@link resolveDefaultBranch}'s five rung notes together with
|
|
419
|
+
* {@link resolveQaDriver}'s three are deferred notes — every note this assembler has. The
|
|
420
|
+
* `--qa-driver` without `--qa` and `--parity` without `--reference-impl` arms below are not
|
|
421
|
+
* deferred: each is about a flag the adopter typed on this command line.
|
|
422
|
+
*/
|
|
423
|
+
export function buildConfig({ repoRoot, detection, preset, flags, warn, noteIfWritten, warnIfWritten, askDriver, }) {
|
|
424
|
+
const report = warn ?? (() => { });
|
|
425
|
+
const informIfWritten = noteIfWritten ?? (() => { });
|
|
426
|
+
const defer = warnIfWritten ?? (() => { });
|
|
427
|
+
const profile = preset ?? buildPreset(detection);
|
|
428
|
+
const scriptsDir = DEFAULTS.scriptsDir;
|
|
429
|
+
const defaultBranch = resolveDefaultBranch(repoRoot, flags.defaultBranch, defer, informIfWritten);
|
|
430
|
+
const phases = { qa: flags.qa ?? false, docs: flags.docs ?? false, parity: flags.parity ?? false };
|
|
431
|
+
const config = {
|
|
432
|
+
// `$schema` is deliberately absent — see choice 1 in the module header.
|
|
433
|
+
version: CONFIG_VERSION,
|
|
434
|
+
projectName: flags.projectName ?? defaultProjectName(repoRoot),
|
|
435
|
+
defaultBranch,
|
|
436
|
+
// The branch work merges back into is the one an automated run must never push to directly.
|
|
437
|
+
protectedBranches: [defaultBranch],
|
|
438
|
+
stateDir: flags.stateDir ?? DEFAULTS.stateDir,
|
|
439
|
+
appDir: detection.context.appDir,
|
|
440
|
+
scriptsDir,
|
|
441
|
+
githooksDir: DEFAULTS.githooksDir,
|
|
442
|
+
agentModel: DEFAULTS.agentModel,
|
|
443
|
+
pushEnvPath: PUSH_ENV_PATH,
|
|
444
|
+
// `clientEnvPrefix` is deliberately absent — see choice 2 in the module header.
|
|
445
|
+
// Sits directly above the `layers` profile it explains, which is the schema's own order.
|
|
446
|
+
// The family id is recorded only where that family derived one of the two required commands —
|
|
447
|
+
// see {@link buildDetection}'s `commandFamily` paragraph for why answering is not enough.
|
|
448
|
+
detection: buildDetection(detection, profile.resolvedRequired.length === 0 ? undefined : profile.commandFamily),
|
|
449
|
+
// Copied entry by entry so the written config owns its layer objects and a later edit to it
|
|
450
|
+
// cannot reach back into the preset the detection result still holds.
|
|
451
|
+
layers: profile.layers.map((layer) => ({ ...layer })),
|
|
452
|
+
commands: buildCommands(scriptsDir, profile.rawCommands),
|
|
453
|
+
phases,
|
|
454
|
+
};
|
|
455
|
+
if (phases.qa) {
|
|
456
|
+
// `driver` first, in the schema's own key order, and resolved rather than defaulted: which
|
|
457
|
+
// variant of the interactive test agent the phase runs has to be visible in the config an
|
|
458
|
+
// adopter commits, and {@link resolveQaDriver} is what keeps it from being a choice nobody was
|
|
459
|
+
// asked to make. Every rung reports itself, so the run says which one answered.
|
|
460
|
+
const driver = resolveQaDriver(flags.qaDriver, askDriver, informIfWritten, defer);
|
|
461
|
+
config.qa = { driver, portSeed: DEFAULTS.qa.portSeed, credentialsPath: QA_CREDENTIALS_PATH };
|
|
462
|
+
}
|
|
463
|
+
else if (flags.qaDriver !== undefined) {
|
|
464
|
+
// Warned rather than refused, and deliberately not written: no `qa` section exists while the
|
|
465
|
+
// phase is off (choice 2 in the module header), so the value would have nowhere to go and
|
|
466
|
+
// nothing to read it. The flag is only ever given on purpose, so silence would be worse — it
|
|
467
|
+
// reads as a run that turned the phase on.
|
|
468
|
+
report('--qa-driver was given without --qa, so it was not written anywhere: qa.driver is read only while the interactive test phase is on, and no qa section is written while it is off — re-run with --qa to turn the phase on and set the driver in the same run');
|
|
469
|
+
}
|
|
470
|
+
if (phases.docs) {
|
|
471
|
+
config.docs = { root: flags.docsRoot ?? DEFAULT_DOCS_ROOT };
|
|
472
|
+
}
|
|
473
|
+
if (phases.parity) {
|
|
474
|
+
// Omitted rather than written empty when there is nothing to put in it: the phase toggle is
|
|
475
|
+
// still on and the config check warns about the missing path, which is the actionable half.
|
|
476
|
+
if (flags.referenceImpl === undefined) {
|
|
477
|
+
report('--parity was given without --reference-impl, so no parity section was written: set parity.referenceImplPath in harness.config.json before the phase runs, or it has nothing to compare a change against');
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
config.parity = { referenceImplPath: flags.referenceImpl };
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return config;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* The kept-config note's `qa.driver` clause: the driver that file puts in force, or nothing at all.
|
|
487
|
+
*
|
|
488
|
+
* Conditioned on `phases.qa` rather than on the section's presence, because the key is read only
|
|
489
|
+
* while the interactive test phase is on (choice 2 in the module header) — naming a driver for a
|
|
490
|
+
* repository that runs no interactive test would report a value nothing consults. A phase that is on
|
|
491
|
+
* over a file missing the key is left unnamed for the same reason: this note states what it read,
|
|
492
|
+
* and what a config *holds* is `doctor`'s `browser-wiring` check to grade.
|
|
493
|
+
*/
|
|
494
|
+
function keptQaDriverClause(config) {
|
|
495
|
+
const driver = config.phases?.qa === true ? config.qa?.driver : undefined;
|
|
496
|
+
return driver === undefined ? '' : `, and runs the interactive test phase with qa.driver ${JSON.stringify(driver)}`;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Build the config and enqueue it as `create-if-absent`, returning the config **in effect**.
|
|
500
|
+
*
|
|
501
|
+
* The order matters and is the point of the function:
|
|
502
|
+
*
|
|
503
|
+
* 1. assemble, collecting anything the adopter should know — including the lines of **both** streams
|
|
504
|
+
* that are only true if this config is the one that lands, held apart until step 3 has decided
|
|
505
|
+
* ({@link BuildConfigOptions.warnIfWritten}, {@link BuildConfigOptions.noteIfWritten});
|
|
506
|
+
* 2. check the assembled object **before** enqueuing anything — `init` must never produce a config
|
|
507
|
+
* it can already tell is invalid, and an invalid one here is a fault in this CLI, so it exits
|
|
508
|
+
* {@link EXIT.INTERNAL} rather than blaming the repository;
|
|
509
|
+
* 3. decide which config the rest of `init` writes against. An existing file is **always** kept and
|
|
510
|
+
* is that config; only `--reset-config` rebuilds it, in which case the write engine copies it to
|
|
511
|
+
* `<path>.bak` and the fresh one takes effect. `--force` does not enter into it — it upgrades
|
|
512
|
+
* the other `create-if-absent` artifacts and stops at this one, which is exactly what makes
|
|
513
|
+
* *edit the config, re-run `init --force`* regenerate the hook, the profile and the stubs from
|
|
514
|
+
* the edit rather than from a file rebuilt over it. `--dry-run` does not enter into it either:
|
|
515
|
+
* the outcome is computed the same way in both modes, which is what makes a dry run a faithful
|
|
516
|
+
* preview — it reaches this generator only for the **tense** of the start-over note, which would
|
|
517
|
+
* otherwise claim a rebuild and a `.bak` that a preview never made ({@link
|
|
518
|
+
* HarnessConfigOptions.dryRun});
|
|
519
|
+
* 4. enqueue. Always — including when the file is kept, so the run's action log records that it was
|
|
520
|
+
* seen and left alone rather than silently skipped.
|
|
521
|
+
*
|
|
522
|
+
* The write itself, the `.bak` and the `--dry-run` suppression all belong to the write engine; this
|
|
523
|
+
* generator has no filesystem-mutating call.
|
|
524
|
+
*/
|
|
525
|
+
export function writeHarnessConfig({ repoRoot, detection, preset, flags, plan, resetConfig = false, dryRun = false, appDirSource = 'default', askDriver, }) {
|
|
526
|
+
const warnings = [];
|
|
527
|
+
const notes = [];
|
|
528
|
+
// Both held back until `kept` is known, and published or dropped there — one list per stream
|
|
529
|
+
// ({@link BuildConfigOptions.warnIfWritten}, {@link BuildConfigOptions.noteIfWritten}).
|
|
530
|
+
const ifWritten = [];
|
|
531
|
+
const notesIfWritten = [];
|
|
532
|
+
const generated = buildConfig({
|
|
533
|
+
repoRoot,
|
|
534
|
+
detection,
|
|
535
|
+
...(preset === undefined ? {} : { preset }),
|
|
536
|
+
...(askDriver === undefined ? {} : { askDriver }),
|
|
537
|
+
flags,
|
|
538
|
+
warn: (message) => warnings.push(message),
|
|
539
|
+
warnIfWritten: (message) => ifWritten.push(message),
|
|
540
|
+
noteIfWritten: (message) => notesIfWritten.push(message),
|
|
541
|
+
});
|
|
542
|
+
// Errors only. A warning is expected on a generated config — an undetected command's placeholder
|
|
543
|
+
// is one — and the preset builder has already reported the ones an adopter can act on, so
|
|
544
|
+
// repeating them here would print each twice.
|
|
545
|
+
const problems = checkConfigShape(generated);
|
|
546
|
+
if (hasErrors(problems)) {
|
|
547
|
+
const detail = problems
|
|
548
|
+
.filter((problem) => problem.severity === 'error')
|
|
549
|
+
.map(formatProblem)
|
|
550
|
+
.join('; ');
|
|
551
|
+
throw new HarnessError(`init assembled a ${CONFIG_FILENAME} that is already invalid, so nothing was written: ${detail}. This is a fault in this CLI rather than in the repository it was run against`, EXIT.INTERNAL);
|
|
552
|
+
}
|
|
553
|
+
const kept = !resetConfig && configExists(repoRoot);
|
|
554
|
+
let effective = generated;
|
|
555
|
+
if (kept) {
|
|
556
|
+
const loaded = loadConfig(repoRoot);
|
|
557
|
+
if (loaded.config === undefined || hasErrors(loaded.problems)) {
|
|
558
|
+
const detail = loaded.problems.map(formatProblem).join('; ');
|
|
559
|
+
throw new HarnessError(`${CONFIG_FILENAME} is already present at ${loaded.path} but cannot be used, and init will not overwrite it: ${detail}. Fix it, or re-run with --reset-config to copy it to ${CONFIG_FILENAME}.bak and regenerate`);
|
|
560
|
+
}
|
|
561
|
+
effective = loaded.config;
|
|
562
|
+
notes.push(`${CONFIG_FILENAME} already exists and was left exactly as it is, so the rest of init used the values in it; re-run with --reset-config to regenerate it, which copies the current file to ${CONFIG_FILENAME}.bak first`);
|
|
563
|
+
// What replaces the discarded config's **notes**, and the reason those are dropped rather than
|
|
564
|
+
// simply gated: exactly one of `resolveDefaultBranch`'s rungs fires on any run, so dropping it
|
|
565
|
+
// silently would leave the notes block unable to answer *what branch is this repository wired
|
|
566
|
+
// to* at all. This says it from `effective` — the file that is there — instead of from the
|
|
567
|
+
// detection this run threw away, which is the sentence the finding measured as a false report.
|
|
568
|
+
// It names the file rather than saying "that file", so it does not depend on standing next to
|
|
569
|
+
// the note above it in a block whose order is the caller's.
|
|
570
|
+
//
|
|
571
|
+
// `protectedBranches` falls back to the one branch that is protected whatever the key lists
|
|
572
|
+
// (`config/model.ts`), so an absent key is reported as what the guards actually match rather
|
|
573
|
+
// than as the schema's own default, which need not be this file's `defaultBranch`.
|
|
574
|
+
notes.push(`${CONFIG_FILENAME} wires this repository to defaultBranch ${JSON.stringify(effective.defaultBranch)} and protects ${JSON.stringify(effective.protectedBranches ?? [effective.defaultBranch])}${keptQaDriverClause(effective)}, whatever this run's flags and detection resolved: change one of them with \`${CLI} config set <key> <value>\`, which changes that one key — or \`${CLI} init --reset-config\`, which rebuilds ${CONFIG_FILENAME} from detection and the flags, after a .bak`);
|
|
575
|
+
// What replaces the discarded config's warnings rather than leaving the kept path silent: the
|
|
576
|
+
// problems of the file that is actually there, from the same checker `doctor`'s `config` check
|
|
577
|
+
// reports them with. Errors are already the refusal above, so this is the warning severity only
|
|
578
|
+
// — a kept key still holding the placeholder is named as a placeholder *in that file*, which is
|
|
579
|
+
// the sentence the dropped generated ones only looked like.
|
|
580
|
+
warnings.push(...loaded.problems.filter((problem) => problem.severity === 'warning').map(formatProblem));
|
|
581
|
+
}
|
|
582
|
+
// Published only where the generated config is the one that lands. On a kept-config re-run the
|
|
583
|
+
// value this run resolved is not the value the repository carries, so the sentence's own claim —
|
|
584
|
+
// that every interactive-test dispatch returns a blocker — would be false of the repository in
|
|
585
|
+
// front of the adopter, three lines below a note saying the file was left exactly as it is. What
|
|
586
|
+
// is *in* a kept config is `doctor`'s `browser-wiring` check to report, on every run.
|
|
587
|
+
if (!kept)
|
|
588
|
+
warnings.push(...ifWritten);
|
|
589
|
+
// The note stream's half of the same gate, published at the same point and for the same reason:
|
|
590
|
+
// every one of these asserts what a key was written as, and a kept re-run wrote no key. The kept
|
|
591
|
+
// path's replacement is pushed above, so the block says what is in force rather than nothing.
|
|
592
|
+
if (!kept)
|
|
593
|
+
notes.push(...notesIfWritten);
|
|
594
|
+
// A **note**, not a warning: a start-over that was asked for is not something needing attention.
|
|
595
|
+
// It is pushed only when there was a file to rebuild — on a first `init` the flag changes nothing
|
|
596
|
+
// and there is nothing to say. What it has to say is what the rebuild did *not* do: it did not
|
|
597
|
+
// restore the previous file, and it did not regenerate everything else in the repository.
|
|
598
|
+
//
|
|
599
|
+
// Two texts, because three of its claims — the rebuild, the `.bak` the previous file "is at", and
|
|
600
|
+
// the artifacts written from the rebuilt config — are things `--dry-run` does not do, and a note
|
|
601
|
+
// that states them anyway reports a success a real run would not have had (`docs/cli.md` §1).
|
|
602
|
+
// Only the **tense** splits: the `if` above and the `kept` it mirrors stay mode-independent, which
|
|
603
|
+
// is what step 3's JSDoc promises and what makes the preview faithful.
|
|
604
|
+
if (resetConfig && configExists(repoRoot)) {
|
|
605
|
+
// The `appDir` half is **reported, not asserted**: it is re-read from the file being rebuilt
|
|
606
|
+
// only where that file answered ({@link HarnessConfigOptions.appDirSource}). On the other two
|
|
607
|
+
// rungs the sentence names the directory the layer scopes were actually derived under, which is
|
|
608
|
+
// the fact an adopter acts on; *why* rung 2 did not answer is the resolver's warning to give,
|
|
609
|
+
// and it gives one whenever there was a file to answer from (`commands/init.ts`).
|
|
610
|
+
const appDirClause = appDirSource === 'config'
|
|
611
|
+
? `appDir is re-read from the file being rebuilt, so every layer scope is re-derived under the same application directory`
|
|
612
|
+
: `appDir is ${JSON.stringify(detection.context.appDir)} — ${appDirSource === 'flag' ? 'the directory --app-dir named on this command line' : 'the repository root, because the file being rebuilt supplied no appDir this run could use'} — so every layer scope is re-derived under that directory`;
|
|
613
|
+
const consequence = `It is re-derived rather than restored: ${appDirClause}, but any value that lived only in the edited file — a phase toggle, an added protectedBranches entry, a corrected command line — becomes whatever detection and the flags say, so anything you meant to keep has to be given as a flag on this run or set again afterwards with \`config set\`.`;
|
|
614
|
+
// The closing clause carries one exception, and it is stated here because this note is the
|
|
615
|
+
// enumeration of what a rebuild costs. A guard still enforcing the pre-rebuild set is a state
|
|
616
|
+
// the rebuild itself creates, so the run that creates it is the run that clears it
|
|
617
|
+
// (`generators/githooks.ts`); which branch sets those were is that generator's own note to give,
|
|
618
|
+
// and is not restated here.
|
|
619
|
+
notes.push(dryRun
|
|
620
|
+
? `${CONFIG_FILENAME} would be rebuilt from stack detection and this command line because --reset-config was given, and the file that is there would be copied to ${CONFIG_FILENAME}.bak first. ${consequence} Everything generated after it would be written from the rebuilt config, while the create-if-absent artifacts already on disk would be kept unless --force was given too — with one exception, the pre-push guard: where its case label no longer matched the set the rebuilt config resolves it would be re-rendered from that config, after being copied to a .bak beside it, and where the label could not be read at all it would be left exactly as it is. This was a dry run, so nothing was written and nothing was backed up`
|
|
621
|
+
: `${CONFIG_FILENAME} was rebuilt from stack detection and this command line because --reset-config was given, and the file that was there is at ${CONFIG_FILENAME}.bak. ${consequence} Everything generated after it in this run was written from the rebuilt config, while the create-if-absent artifacts already on disk were kept unless --force was given too — with one exception, the pre-push guard: where its case label no longer matched the set the rebuilt config resolves it was re-rendered from that config, after being copied to a .bak beside it, and where the label could not be read at all it was left exactly as it is`);
|
|
622
|
+
}
|
|
623
|
+
// The request answers the overwrite question itself in **both** directions (`config/io.ts`), which
|
|
624
|
+
// is what takes this one artifact out of the run's `--force` entirely: `'never'` keeps the file a
|
|
625
|
+
// `--force` run would otherwise have replaced — and replaced *before* every artifact that reads it
|
|
626
|
+
// was regenerated from the replacement — while `'always'` lets `--reset-config` rebuild it after a
|
|
627
|
+
// `.bak` on a run with no `--force` at all. Neither arm consults the flag, so the contract is
|
|
628
|
+
// structural rather than a condition a later edit could re-introduce.
|
|
629
|
+
const path = saveConfig(repoRoot, generated, plan, { forceOverride: kept ? 'never' : 'always' });
|
|
630
|
+
return { config: effective, path, kept, warnings, notes };
|
|
631
|
+
}
|
|
632
|
+
//# sourceMappingURL=harnessConfig.js.map
|