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,1368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stack detection: which layer preset an adopting repository gets, and the evidence for it.
|
|
3
|
+
*
|
|
4
|
+
* **The rule this module exists to enforce: detection is pure file-existence and top-level
|
|
5
|
+
* manifest-key inspection, evaluated as an ordered first-match-wins table.** No content
|
|
6
|
+
* heuristics, no scoring, no language model. `init` has to be reproducible — a second `init`
|
|
7
|
+
* against the same tree must reach the same answer, and an adopter must be able to re-derive what
|
|
8
|
+
* `init` decided by reading this table — and a judgement call cannot offer that. Refining a
|
|
9
|
+
* profile with judgement is `/harness-analyze`'s job, not this table's: it **proposes** a revision
|
|
10
|
+
* and applies it through `config set layers` after a yes, never writing `harness.config.json`
|
|
11
|
+
* itself. That is why the last row is an unconditional fallback rather than a refusal: an
|
|
12
|
+
* unrecognised layout is exactly the repository `/harness-analyze` exists to handle, and refusing
|
|
13
|
+
* there would block adoption on it.
|
|
14
|
+
*
|
|
15
|
+
* **{@link SIGNALS} is exported as data** so `docs/cli.md` documents the same rows the code
|
|
16
|
+
* evaluates, in the same order, instead of a prose paraphrase that drifts from them.
|
|
17
|
+
*
|
|
18
|
+
* What this module does **not** do: it never formats a wrapper-script invocation and never reads
|
|
19
|
+
* `scriptsDir`. Detection yields a preset name; `detect/presets.ts` turns that into layers and
|
|
20
|
+
* **raw** command lines; the wrapper scripts those raw lines end up inside, and the
|
|
21
|
+
* `bash <scriptsDir>/<name>.sh` value that lands in `commands.*`, belong to the wrapper-script
|
|
22
|
+
* writer and the config generator respectively.
|
|
23
|
+
*/
|
|
24
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
25
|
+
import { posix, relative, resolve } from 'node:path';
|
|
26
|
+
import { DETECTION_PRESET_NAMES } from '../config/model.js';
|
|
27
|
+
import { EXIT, HarnessError } from '../core/errors.js';
|
|
28
|
+
import { isJsonObject, readJsonFile } from '../core/json.js';
|
|
29
|
+
import { insideRepo } from '../core/paths.js';
|
|
30
|
+
/**
|
|
31
|
+
* The layer presets, in signal-table order — the schema's `detection.preset` enum, re-exported
|
|
32
|
+
* from `config/model.ts` rather than declared a second time here, so the enum a config is validated
|
|
33
|
+
* against and the set this table's rows are typed by cannot disagree.
|
|
34
|
+
*
|
|
35
|
+
* This is also the set `--preset` is validated against — see {@link parsePresetName} — so adding a
|
|
36
|
+
* preset means adding it to `config/model.ts`, to the schema's `detection.preset` enum, giving it a
|
|
37
|
+
* row in {@link SIGNALS} and giving it layers in `detect/presets.ts`. Nothing else enumerates them.
|
|
38
|
+
*/
|
|
39
|
+
export const PRESET_NAMES = DETECTION_PRESET_NAMES;
|
|
40
|
+
/** {@link DetectionResult.matchedSignal} when `--preset` bypassed the table. */
|
|
41
|
+
export const FORCED_SIGNAL_ID = 'forced';
|
|
42
|
+
/** {@link DetectionResult.matchedSignal} when no signal matched and the `flat` row caught the run. */
|
|
43
|
+
export const FLAT_FALLBACK_SIGNAL_ID = 'flat:fallback';
|
|
44
|
+
/** The warning the `flat` fallback row carries, so an unrecognised layout says what to do next. */
|
|
45
|
+
export const FLAT_FALLBACK_WARNING = 'unrecognised layout — using the `flat` preset; run `/harness-analyze` to refine the layer profile';
|
|
46
|
+
/**
|
|
47
|
+
* The npm manifest, read for its top-level `workspaces` key and its `scripts` **names**.
|
|
48
|
+
*
|
|
49
|
+
* Exported for one consumer outside this module: the undetected-command warning in
|
|
50
|
+
* `detect/presets.ts` enumerates the manifests `init` reads, and a second spelling of this name there
|
|
51
|
+
* would let the sentence and the reader drift apart.
|
|
52
|
+
*/
|
|
53
|
+
export const PACKAGE_MANIFEST = 'package.json';
|
|
54
|
+
/** The npm lockfile, whose presence is the whole difference between `npm ci` and `npm install`. */
|
|
55
|
+
export const PACKAGE_LOCKFILE = 'package-lock.json';
|
|
56
|
+
/** Workspace-tool manifests. Existence only — none of them is parsed. */
|
|
57
|
+
export const WORKSPACE_MANIFESTS = ['pnpm-workspace.yaml', 'lerna.json', 'turbo.json', 'nx.json'];
|
|
58
|
+
/** The three directory names of a layered clean-architecture source tree. */
|
|
59
|
+
export const LAYERED_DIR_NAMES = ['data', 'domain', 'presentation'];
|
|
60
|
+
/**
|
|
61
|
+
* How many of {@link LAYERED_DIR_NAMES} must be present for the layered signal to match.
|
|
62
|
+
*
|
|
63
|
+
* Two rather than three: a repository that has grown `data/` and `domain/` but keeps its UI
|
|
64
|
+
* elsewhere is still a layered tree, and requiring all three would push it to `flat` — the one
|
|
65
|
+
* outcome that costs the adopter a hand-written layer list. One would be too little: a lone
|
|
66
|
+
* `data/` directory is a common name in trees that are not layered at all.
|
|
67
|
+
*/
|
|
68
|
+
export const LAYERED_MIN_MATCHES = 2;
|
|
69
|
+
/** Directory names that mark a request-handling layer. Checked in this order. */
|
|
70
|
+
export const API_DIR_NAMES = ['routes', 'controllers', 'handlers', 'api'];
|
|
71
|
+
/** Python packaging manifests. Existence only — none of them is parsed. */
|
|
72
|
+
export const PYTHON_MANIFESTS = ['pyproject.toml', 'setup.py', 'setup.cfg'];
|
|
73
|
+
/** The Go module manifest. Existence only. */
|
|
74
|
+
export const GO_MANIFEST = 'go.mod';
|
|
75
|
+
/**
|
|
76
|
+
* The Dart/Flutter package manifest. Existence only, for every row of the signal table.
|
|
77
|
+
*
|
|
78
|
+
* Its content is read in exactly one place — {@link DetectContext.declaresFlutterSdk}, called from
|
|
79
|
+
* `detect/presets.ts`'s app-versus-package arm. File existence alone cannot tell an application
|
|
80
|
+
* whose entry point moved out of `lib/main.dart` from a plain package, and the cost of choosing
|
|
81
|
+
* wrong is asymmetric in both directions (stated on `FLUTTER_APP_MARKERS`). That read is one
|
|
82
|
+
* line-shaped probe deciding **which tool an already-selected preset gets**, never which preset is
|
|
83
|
+
* selected, so the signal table's no-content-heuristics property — `docs/cli.md` §4 — is untouched.
|
|
84
|
+
*/
|
|
85
|
+
export const PUBSPEC_MANIFEST = 'pubspec.yaml';
|
|
86
|
+
/** The line a `pubspec.yaml` carries for a dependency on the Flutter SDK — `flutter:` then `sdk: flutter`. */
|
|
87
|
+
const FLUTTER_SDK_DEPENDENCY = /^\s+sdk:\s*flutter\s*$/m;
|
|
88
|
+
/**
|
|
89
|
+
* The Android application manifest, relative to a module directory. Existence only.
|
|
90
|
+
*
|
|
91
|
+
* It is what tells an Android module from a plain JVM one: both carry `build.gradle` /
|
|
92
|
+
* `settings.gradle`, so neither of those names can decide the question, and reading a Gradle file
|
|
93
|
+
* for an `com.android.application` plugin line would be the content heuristic this module refuses
|
|
94
|
+
* to make.
|
|
95
|
+
*/
|
|
96
|
+
export const ANDROID_MANIFEST = 'src/main/AndroidManifest.xml';
|
|
97
|
+
/**
|
|
98
|
+
* The module directories an Android application manifest is looked for in, in order: the
|
|
99
|
+
* conventional `app` module of a `gradlew`-rooted project, then the manifest root itself for a
|
|
100
|
+
* single-module repository whose `src/main/` sits at the top.
|
|
101
|
+
*
|
|
102
|
+
* Searched under {@link DetectContext.manifestRoots} — the same list the manifest-shaped rows use —
|
|
103
|
+
* and never under {@link CANDIDATE_ROOT_DIR_NAMES}, so a Flutter repository, whose manifest is at
|
|
104
|
+
* `android/app/src/main/`, is not reached from the repository root.
|
|
105
|
+
*
|
|
106
|
+
* Exported for one consumer outside this module: the undetected-command warning in
|
|
107
|
+
* `detect/presets.ts` enumerates the manifests `init` reads, and the Android entry has to carry the
|
|
108
|
+
* same module directories this list searches.
|
|
109
|
+
*/
|
|
110
|
+
export const ANDROID_MODULE_DIRS = ['app', '.'];
|
|
111
|
+
/** The Maven project manifest. Existence only — no POM element is parsed. */
|
|
112
|
+
export const MAVEN_MANIFEST = 'pom.xml';
|
|
113
|
+
/**
|
|
114
|
+
* The Gradle project manifests, in match order. Existence only — no Gradle script is read.
|
|
115
|
+
*
|
|
116
|
+
* A settings file first because it marks the **root** of a Gradle build, which is the directory
|
|
117
|
+
* `-p` has to name, and a `build.gradle` alone can be a subproject's. Kotlin DSL ahead of Groovy at
|
|
118
|
+
* each pair only so a project carrying both reports one of them deterministically.
|
|
119
|
+
*
|
|
120
|
+
* These names do not tell an Android project from a plain JVM one — both carry them — which is why
|
|
121
|
+
* {@link ANDROID_MANIFEST} decides that, on the row ordered above the JVM rows.
|
|
122
|
+
*/
|
|
123
|
+
export const GRADLE_MANIFESTS = ['settings.gradle.kts', 'settings.gradle', 'build.gradle.kts', 'build.gradle'];
|
|
124
|
+
/**
|
|
125
|
+
* The .NET solution suffixes, in match order — matched by **suffix** rather than by name, because a
|
|
126
|
+
* solution is named after the product and there is no fixed file name to look for.
|
|
127
|
+
* {@link findFileWithSuffix} is the locator shape that exists for exactly this.
|
|
128
|
+
*
|
|
129
|
+
* `.slnx` is the XML solution format the .NET 9 SDK ships and `dotnet sln migrate` converts to; every
|
|
130
|
+
* `dotnet` verb this module emits accepts it in the same position as a `.sln`. `.sln` is tried first
|
|
131
|
+
* only so a repository mid-migration, carrying both, reports one of them deterministically.
|
|
132
|
+
*/
|
|
133
|
+
export const DOTNET_SOLUTION_SUFFIXES = ['.sln', '.slnx'];
|
|
134
|
+
/**
|
|
135
|
+
* The .NET project-file suffixes, in match order — C#, F#, Visual Basic.
|
|
136
|
+
*
|
|
137
|
+
* They are the fallback for a repository that ships no solution, which is ordinary for a single
|
|
138
|
+
* service. Order between them decides nothing an adopter would notice: a project carries one of
|
|
139
|
+
* the three, and a repository mixing languages is a solution repository, which the suffix above
|
|
140
|
+
* answers first.
|
|
141
|
+
*/
|
|
142
|
+
export const DOTNET_PROJECT_SUFFIXES = ['.csproj', '.fsproj', '.vbproj'];
|
|
143
|
+
/**
|
|
144
|
+
* The Swift Package Manager manifest. Existence only — the `Package.swift` file is a Swift program,
|
|
145
|
+
* and running it to learn its targets is neither a file-existence test nor something a detection
|
|
146
|
+
* pass may do.
|
|
147
|
+
*/
|
|
148
|
+
export const SWIFT_PACKAGE_MANIFEST = 'Package.swift';
|
|
149
|
+
/**
|
|
150
|
+
* The Cargo package manifest. Existence only — neither its `[package]` table nor its `[workspace]`
|
|
151
|
+
* one is parsed, which is what keeps a workspace root and a plain crate on the same row.
|
|
152
|
+
*/
|
|
153
|
+
export const CARGO_MANIFEST = 'Cargo.toml';
|
|
154
|
+
/**
|
|
155
|
+
* The Bundler manifest. Existence only — a `Gemfile` is a Ruby program, and running it to learn
|
|
156
|
+
* which gems or groups it declares is neither a file-existence test nor something a detection pass
|
|
157
|
+
* may do. One name covers every Ruby repository that declares dependencies at all: a Rails
|
|
158
|
+
* application, a gem with a development `Gemfile` beside its gemspec, and a plain script directory
|
|
159
|
+
* alike.
|
|
160
|
+
*
|
|
161
|
+
* **The converse is what the rows have to be written against, and it is false**: a repository with a
|
|
162
|
+
* `Gemfile` is not thereby a Ruby repository. Ruby's packaging is the standard way to pin *mobile
|
|
163
|
+
* and static-site tooling*, so a `Gemfile` sits at the root of the React Native project template
|
|
164
|
+
* (CocoaPods, fastlane), of native iOS repositories (fastlane) and of a Jekyll site built beside an
|
|
165
|
+
* npm front end. This is `PACKAGE_MANIFEST`'s "routinely appears in a repository of another stack"
|
|
166
|
+
* property, on a second manifest — so the row and the command family both carry the
|
|
167
|
+
* {@link declaresNodeScript} guard.
|
|
168
|
+
*/
|
|
169
|
+
export const BUNDLER_MANIFEST = 'Gemfile';
|
|
170
|
+
/**
|
|
171
|
+
* The Composer manifest. Existence only — and unusually for this table it *could* be inspected, since
|
|
172
|
+
* it is JSON and {@link DetectContext.rootPackageJson}'s reader shape is the precedent for a
|
|
173
|
+
* top-level key test. No row asks it anything: `composer.json` is PHP's only dependency manifest, so
|
|
174
|
+
* its presence is already the whole answer, and a key test that decided nothing further would only
|
|
175
|
+
* invite a reader to think one was read.
|
|
176
|
+
*/
|
|
177
|
+
export const COMPOSER_MANIFEST = 'composer.json';
|
|
178
|
+
/**
|
|
179
|
+
* The CMake project manifest. Existence only — a `CMakeLists.txt` is a script in CMake's own
|
|
180
|
+
* language, and running it, or scanning it for the targets and test registrations it declares, is
|
|
181
|
+
* neither a file-existence test nor something a detection pass may do. Existence is enough: the
|
|
182
|
+
* command family this selects configures and builds whatever the file declares, without needing to
|
|
183
|
+
* know what that is.
|
|
184
|
+
*/
|
|
185
|
+
export const CMAKE_MANIFEST = 'CMakeLists.txt';
|
|
186
|
+
/**
|
|
187
|
+
* The Xcode workspace suffix, named on its own because {@link XCODE_PROJECT_SUFFIXES} order and the
|
|
188
|
+
* `xcodebuild` flag that goes with it are the same fact spelled twice otherwise.
|
|
189
|
+
*/
|
|
190
|
+
export const XCODE_WORKSPACE_SUFFIX = '.xcworkspace';
|
|
191
|
+
/**
|
|
192
|
+
* The Xcode container suffixes, in match order — the workspace first, because it is what
|
|
193
|
+
* `xcodebuild` prefers when a repository holds both: a project built on its own misses the schemes
|
|
194
|
+
* and package dependencies its workspace declares.
|
|
195
|
+
*
|
|
196
|
+
* Matched by **suffix** for {@link DOTNET_SOLUTION_SUFFIXES}' reason — both are named after the
|
|
197
|
+
* product, so there is no fixed file name to look for — and both are **directories** rather than
|
|
198
|
+
* files, which is the half of {@link findFileWithSuffix} that searches
|
|
199
|
+
* {@link DetectContext.childDirNames}.
|
|
200
|
+
*/
|
|
201
|
+
export const XCODE_PROJECT_SUFFIXES = [XCODE_WORKSPACE_SUFFIX, '.xcodeproj'];
|
|
202
|
+
/**
|
|
203
|
+
* Where an Xcode container keeps the schemes it shares with every checkout, relative to that
|
|
204
|
+
* container. A scheme outside it is a user-local file that no other machine has, so it cannot be
|
|
205
|
+
* named in a command line a repository ships.
|
|
206
|
+
*
|
|
207
|
+
* Exported for one consumer outside this module: the note `detect/presets.ts` raises when no single
|
|
208
|
+
* shared scheme decided a command, which has to name the directory that was looked in.
|
|
209
|
+
*/
|
|
210
|
+
export const XCODE_SHARED_SCHEMES_DIR = 'xcshareddata/xcschemes';
|
|
211
|
+
/** The scheme-file extension, stripped so what is left is the name `-scheme` takes. */
|
|
212
|
+
const XCODE_SCHEME_SUFFIX = '.xcscheme';
|
|
213
|
+
/**
|
|
214
|
+
* Manifests `init` does **not** read, but that an adopter outside the supported stacks will have.
|
|
215
|
+
*
|
|
216
|
+
* **This list exists to make a sentence true, never to select a preset or derive a command.** Its one
|
|
217
|
+
* consumer is the undetected-command warning, which names what was looked for and what is actually
|
|
218
|
+
* there instead of claiming the repository holds no manifest — so adding a name here changes what that
|
|
219
|
+
* warning says and nothing else. The closure rule that keeps it honest: a name added to any list a
|
|
220
|
+
* locator above reads — {@link PYTHON_MANIFESTS}, {@link GO_MANIFEST}, {@link MAVEN_MANIFEST},
|
|
221
|
+
* {@link GRADLE_MANIFESTS} and every later stack's — must be removed from here, or the warning would
|
|
222
|
+
* report a manifest `init` had just started reading as one it does not. `pom.xml`, `build.gradle`,
|
|
223
|
+
* `build.gradle.kts`, `Cargo.toml`, `Gemfile` and `composer.json` left this list for exactly that
|
|
224
|
+
* reason when the JVM, Cargo, Bundler and Composer rows started reading them.
|
|
225
|
+
*
|
|
226
|
+
* `Makefile` stays, and {@link CMAKE_MANIFEST} was never here to leave: a `Makefile` names no
|
|
227
|
+
* command a locator could derive, because which targets it declares — `check`, `test`, `all`, or
|
|
228
|
+
* none of them — is knowable only by reading the file, which this module's header forbids. A
|
|
229
|
+
* `CMakeLists.txt` needs no such reading, since `cmake` and `ctest` take the same arguments
|
|
230
|
+
* whatever it declares, so it is a manifest `init` reads rather than one it only names.
|
|
231
|
+
*/
|
|
232
|
+
export const UNREAD_MANIFESTS = ['Makefile'];
|
|
233
|
+
/** The marker file that makes a directory an importable Python package. Existence only. */
|
|
234
|
+
const PYTHON_PACKAGE_MARKER = '__init__.py';
|
|
235
|
+
/**
|
|
236
|
+
* The conventional `src` layout directory, used as the documented package-directory fallback.
|
|
237
|
+
*
|
|
238
|
+
* Kept separate from {@link CANDIDATE_ROOT_DIR_NAMES} on purpose, and both readers are why:
|
|
239
|
+
* {@link findPythonPackageDir}'s fallback is a rule about the Python `src`-layout convention and
|
|
240
|
+
* {@link findDotnetProject}'s last step is a rule about the .NET solution layout, neither is a
|
|
241
|
+
* statement about where a directory signal looks, and neither must start following that list when
|
|
242
|
+
* a name is added to it.
|
|
243
|
+
*/
|
|
244
|
+
const SRC_DIR = 'src';
|
|
245
|
+
/**
|
|
246
|
+
* The directory names a directory-shaped signal searches under the application directory, in order —
|
|
247
|
+
* {@link DetectContext.candidateRoots} is the application directory itself followed by these.
|
|
248
|
+
*
|
|
249
|
+
* `internal` and `pkg` are here because they are conventional source roots, not because a repository
|
|
250
|
+
* might happen to use them: `internal/` is Go's compiler-enforced privacy boundary and the most
|
|
251
|
+
* conventional place a Go repository puts its packages, `pkg/` is its long-standing companion, and
|
|
252
|
+
* both are widely borrowed into Node and Python trees that never touched Go. Without them a textbook
|
|
253
|
+
* `internal/{data,domain,presentation}` tree got the same catch-all profile an empty repository does.
|
|
254
|
+
*
|
|
255
|
+
* **Exactly two {@link SIGNALS} rows resolve over these roots**, and both widen together when a name
|
|
256
|
+
* is added: `layered-clean-arch:layer-directories` through {@link findLayeredRoot}, and
|
|
257
|
+
* `api-service:route-directory` through {@link findApiDir}. So `internal/api/` and `pkg/routes/` now
|
|
258
|
+
* match the api row too. That is safe under the table's first-match-wins order — the layered row is
|
|
259
|
+
* evaluated first, so a tree carrying two or more of {@link LAYERED_DIR_NAMES} under `internal/`
|
|
260
|
+
* still answers `layered-clean-arch` — but a tree whose only match is `internal/api/` moves from
|
|
261
|
+
* `flat` to `api-service`.
|
|
262
|
+
*
|
|
263
|
+
* **Not touched, deliberately:** `api-service:go-module` ({@link findGoManifest}) and
|
|
264
|
+
* `python-package:packaging-manifest` ({@link findPythonManifest}, and {@link findPythonPackageDir}
|
|
265
|
+
* behind it) loop {@link DetectContext.manifestRoots} — a different list — and are unaffected.
|
|
266
|
+
*
|
|
267
|
+
* Both directory-shaped descriptions below are generated from this constant so the sentence and the
|
|
268
|
+
* search cannot drift. One consumer states the same list in prose and is **not** generated from it:
|
|
269
|
+
* `docs/cli.md` §4, rows 3 and 5.
|
|
270
|
+
*/
|
|
271
|
+
export const CANDIDATE_ROOT_DIR_NAMES = ['src', 'app', 'lib', 'internal', 'pkg'];
|
|
272
|
+
/** {@link CANDIDATE_ROOT_DIR_NAMES} as directory paths, for the two descriptions that name them. */
|
|
273
|
+
const CANDIDATE_ROOT_PATHS = CANDIDATE_ROOT_DIR_NAMES.map((name) => `${name}/`);
|
|
274
|
+
/**
|
|
275
|
+
* The trailing clause of a directory-shaped signal's description, generated from the roots it
|
|
276
|
+
* searches. `slice` on both halves rather than an indexed last element: the list is a constant, and
|
|
277
|
+
* a total expression cannot render `undefined` into a sentence if it ever stops being one.
|
|
278
|
+
*/
|
|
279
|
+
const CANDIDATE_ROOTS_CLAUSE = `under the application directory or its ${CANDIDATE_ROOT_PATHS.slice(0, -1).join(', ')} or ${CANDIDATE_ROOT_PATHS.slice(-1).join('')}`;
|
|
280
|
+
/**
|
|
281
|
+
* Directory names never treated as the Python package directory. They are ordinary siblings of a
|
|
282
|
+
* package, and one of them carrying an `__init__.py` (a test package, most often) would otherwise
|
|
283
|
+
* win on alphabetical order.
|
|
284
|
+
*/
|
|
285
|
+
const NON_PACKAGE_DIR_NAMES = new Set([
|
|
286
|
+
'build',
|
|
287
|
+
'dist',
|
|
288
|
+
'docs',
|
|
289
|
+
'examples',
|
|
290
|
+
'node_modules',
|
|
291
|
+
'test',
|
|
292
|
+
'tests',
|
|
293
|
+
'venv',
|
|
294
|
+
]);
|
|
295
|
+
/** A repo-relative path in the harness's own form: forward slashes, no trailing slash, `.` for the root. */
|
|
296
|
+
function toRepoRelative(value) {
|
|
297
|
+
const normalized = posix.normalize(value.split('\\').join('/')).replace(/\/+$/, '');
|
|
298
|
+
return normalized === '' || normalized === '.' ? '.' : normalized;
|
|
299
|
+
}
|
|
300
|
+
/** Join repo-relative segments, collapsing the `.` root so `.` + `src` is `src`, not `./src`. */
|
|
301
|
+
function joinRepoRelative(base, ...segments) {
|
|
302
|
+
return toRepoRelative(base === '.' ? posix.join(...segments) : posix.join(base, ...segments));
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* One repository, probed.
|
|
306
|
+
*
|
|
307
|
+
* It exists so the signal rows, the locators below and `detect/presets.ts` share **one** reading
|
|
308
|
+
* of the filesystem and **one** warning list: `package.json` is parsed at most once per run, and a
|
|
309
|
+
* warning raised by a locator two rows apart from where it is reported still reaches the caller.
|
|
310
|
+
*
|
|
311
|
+
* Every path a caller hands in or gets back is **repo-relative** (`docs/config.md` §5); the
|
|
312
|
+
* absolute form exists only inside this class, where the filesystem needs it.
|
|
313
|
+
*/
|
|
314
|
+
export class DetectContext {
|
|
315
|
+
/** Absolute path of the repository root. */
|
|
316
|
+
repoRoot;
|
|
317
|
+
/** Repo-relative application directory — `.` when the repository is the application. */
|
|
318
|
+
appDir;
|
|
319
|
+
/**
|
|
320
|
+
* The roots a directory-shaped signal looks under, in order: the application directory itself,
|
|
321
|
+
* then each of {@link CANDIDATE_ROOT_DIR_NAMES} beneath it.
|
|
322
|
+
*/
|
|
323
|
+
candidateRoots;
|
|
324
|
+
/** The roots a manifest-shaped signal looks in: the application directory and the repository root. */
|
|
325
|
+
manifestRoots;
|
|
326
|
+
#seen = new Set();
|
|
327
|
+
#pending = [];
|
|
328
|
+
/**
|
|
329
|
+
* The note channel's own dedup set and pending list — **independent** of the warning ones.
|
|
330
|
+
*
|
|
331
|
+
* A message may legitimately be raised once as a note and once as a warning by different phases;
|
|
332
|
+
* one shared `#seen` would silently drop the second.
|
|
333
|
+
*/
|
|
334
|
+
#seenNotes = new Set();
|
|
335
|
+
#pendingNotes = [];
|
|
336
|
+
#packageJsonRead = false;
|
|
337
|
+
#packageJson;
|
|
338
|
+
#appPackageJsonRead = false;
|
|
339
|
+
#appPackageJson;
|
|
340
|
+
/**
|
|
341
|
+
* @param repoRoot absolute repository root, from `core/git.ts`.
|
|
342
|
+
* @param appDir `harness.config.json`'s `appDir` (or `--app-dir`): repo-relative, or absolute.
|
|
343
|
+
*
|
|
344
|
+
* An `appDir` outside the repository is refused rather than clamped: it is a typo or a
|
|
345
|
+
* mis-scoped invocation, and detecting the wrong tree would wire the repository against a stack
|
|
346
|
+
* it does not contain.
|
|
347
|
+
*/
|
|
348
|
+
constructor(repoRoot, appDir = '.') {
|
|
349
|
+
this.repoRoot = resolve(repoRoot);
|
|
350
|
+
const absoluteAppDir = resolve(this.repoRoot, appDir);
|
|
351
|
+
if (!insideRepo(this.repoRoot, absoluteAppDir)) {
|
|
352
|
+
throw new HarnessError(`appDir ${JSON.stringify(appDir)} is outside the repository at ${this.repoRoot}`);
|
|
353
|
+
}
|
|
354
|
+
this.appDir = toRepoRelative(relative(this.repoRoot, absoluteAppDir));
|
|
355
|
+
this.candidateRoots = [
|
|
356
|
+
this.appDir,
|
|
357
|
+
...CANDIDATE_ROOT_DIR_NAMES.map((name) => joinRepoRelative(this.appDir, name)),
|
|
358
|
+
];
|
|
359
|
+
this.manifestRoots = this.appDir === '.' ? ['.'] : [this.appDir, '.'];
|
|
360
|
+
}
|
|
361
|
+
/** The absolute form of a repo-relative path, for the filesystem calls below and nothing else. */
|
|
362
|
+
absolute(repoRelativePath) {
|
|
363
|
+
return resolve(this.repoRoot, repoRelativePath);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Record something the adopter should know. Deduplicated by message, so a locator called from
|
|
367
|
+
* both a signal row and a preset builder cannot report the same thing twice.
|
|
368
|
+
*/
|
|
369
|
+
warn(message) {
|
|
370
|
+
if (this.#seen.has(message))
|
|
371
|
+
return;
|
|
372
|
+
this.#seen.add(message);
|
|
373
|
+
this.#pending.push(message);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Take the warnings raised since the last drain. Each phase — detection, then preset building —
|
|
377
|
+
* reports the warnings it caused, and no phase re-reports an earlier one.
|
|
378
|
+
*/
|
|
379
|
+
drainWarnings() {
|
|
380
|
+
const drained = this.#pending;
|
|
381
|
+
this.#pending = [];
|
|
382
|
+
return drained;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Record something the adopter should know that is **not** a fault — what a correct run should say
|
|
386
|
+
* about itself. Deduplicated by message for {@link warn}'s reason, on its own set.
|
|
387
|
+
*
|
|
388
|
+
* A generated value that is right for most adopters but which some will want to change belongs
|
|
389
|
+
* here: a warning that fires on every correct run is how a warning list stops being read
|
|
390
|
+
* (`generators/harnessConfig.ts`, `BuildConfigOptions.noteIfWritten`).
|
|
391
|
+
*/
|
|
392
|
+
note(message) {
|
|
393
|
+
if (this.#seenNotes.has(message))
|
|
394
|
+
return;
|
|
395
|
+
this.#seenNotes.add(message);
|
|
396
|
+
this.#pendingNotes.push(message);
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Take the notes raised since the last drain. Same phase discipline as {@link drainWarnings}:
|
|
400
|
+
* each phase reports the notes it caused, and no phase re-reports an earlier one.
|
|
401
|
+
*/
|
|
402
|
+
drainNotes() {
|
|
403
|
+
const drained = this.#pendingNotes;
|
|
404
|
+
this.#pendingNotes = [];
|
|
405
|
+
return drained;
|
|
406
|
+
}
|
|
407
|
+
/** True when a repo-relative path is an existing directory. */
|
|
408
|
+
dirExists(repoRelativePath) {
|
|
409
|
+
return statSync(this.absolute(repoRelativePath), { throwIfNoEntry: false })?.isDirectory() ?? false;
|
|
410
|
+
}
|
|
411
|
+
/** True when a repo-relative path is an existing regular file. */
|
|
412
|
+
fileExists(repoRelativePath) {
|
|
413
|
+
return statSync(this.absolute(repoRelativePath), { throwIfNoEntry: false })?.isFile() ?? false;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* True when a `pubspec.yaml` declares a dependency on the Flutter SDK.
|
|
417
|
+
*
|
|
418
|
+
* The probe matches **one line shape** — `sdk: flutter`, under either `dependencies:` or
|
|
419
|
+
* `dev_dependencies:` — and does not parse the YAML. A `flutter_test` dev-dependency therefore
|
|
420
|
+
* answers true, which is the correct positive: a package importing `package:flutter_test` hits
|
|
421
|
+
* the same unresolved-import failure under `dart analyze` that an application does. An unreadable
|
|
422
|
+
* or absent manifest answers `false`; absence is an ordinary answer here as it is in
|
|
423
|
+
* {@link dirExists} and {@link fileExists}, and the caller keeps its marker arm.
|
|
424
|
+
*
|
|
425
|
+
* **No other detection row may call this.** The family gate and preset selection stay pure file
|
|
426
|
+
* existence; see {@link PUBSPEC_MANIFEST}.
|
|
427
|
+
*/
|
|
428
|
+
declaresFlutterSdk(repoRelativePath) {
|
|
429
|
+
try {
|
|
430
|
+
return FLUTTER_SDK_DEPENDENCY.test(readFileSync(this.absolute(repoRelativePath), 'utf8'));
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* The immediate sub-directory names of a repo-relative directory, sorted, dot-directories
|
|
438
|
+
* excluded — so a caller that scans children is deterministic regardless of directory order on
|
|
439
|
+
* disk. An unreadable or absent directory yields an empty list; absence is an ordinary answer to
|
|
440
|
+
* every question this module asks.
|
|
441
|
+
*/
|
|
442
|
+
childDirNames(repoRelativePath) {
|
|
443
|
+
try {
|
|
444
|
+
return readdirSync(this.absolute(repoRelativePath), { withFileTypes: true })
|
|
445
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
446
|
+
.map((entry) => entry.name)
|
|
447
|
+
.sort();
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
return [];
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* The immediate file names of a repo-relative directory — the file twin of
|
|
455
|
+
* {@link childDirNames}, same contract: sorted, dot-files excluded, an unreadable or absent
|
|
456
|
+
* directory yielding an empty list.
|
|
457
|
+
*
|
|
458
|
+
* It exists so a manifest named by **suffix** rather than by name — `*.sln`, `*.xcodeproj` —
|
|
459
|
+
* can be found without a glob, and so that a root holding two of them resolves to the same one
|
|
460
|
+
* on every run regardless of directory order on disk. {@link findFileWithSuffix} is its caller.
|
|
461
|
+
*/
|
|
462
|
+
childFileNames(repoRelativePath) {
|
|
463
|
+
try {
|
|
464
|
+
return readdirSync(this.absolute(repoRelativePath), { withFileTypes: true })
|
|
465
|
+
.filter((entry) => entry.isFile() && !entry.name.startsWith('.'))
|
|
466
|
+
.map((entry) => entry.name)
|
|
467
|
+
.sort();
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
return [];
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* The repository root's `package.json`, parsed once and cached.
|
|
475
|
+
*
|
|
476
|
+
* **Only the root manifest, deliberately.** The raw command lines derived from it — `npm run
|
|
477
|
+
* build`, `npm test` — are executed from the repository root, so a script found in a nested
|
|
478
|
+
* manifest would produce a line that does not run. A nested application's commands are left
|
|
479
|
+
* unresolved (placeholder plus a warning) rather than guessed at.
|
|
480
|
+
*
|
|
481
|
+
* A malformed manifest is a **warning and treated as absent**, not a refusal: detection never
|
|
482
|
+
* blocks adoption, and the unresolved commands it leads to are reported by their own warnings.
|
|
483
|
+
*/
|
|
484
|
+
rootPackageJson() {
|
|
485
|
+
if (this.#packageJsonRead)
|
|
486
|
+
return this.#packageJson;
|
|
487
|
+
this.#packageJsonRead = true;
|
|
488
|
+
this.#packageJson = this.#readPackageManifest(PACKAGE_MANIFEST);
|
|
489
|
+
return this.#packageJson;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* The **application directory's** own `package.json`, parsed once and cached, or `undefined` when
|
|
493
|
+
* the application is the repository — where {@link rootPackageJson} already owns that file.
|
|
494
|
+
*
|
|
495
|
+
* **It does not widen command derivation**, and nothing here reads it for one: `rootPackageJson`'s
|
|
496
|
+
* only-the-root rule stands for every script-derived command, for the reason stated there. Its one
|
|
497
|
+
* consumer is {@link appScriptNames}, which exists solely so the undetected-command warning can name
|
|
498
|
+
* a script it can see rather than deny that any manifest declares one.
|
|
499
|
+
*
|
|
500
|
+
* Same malformed-manifest policy as the root's, through the same reader: a warning, and the manifest
|
|
501
|
+
* treated as absent.
|
|
502
|
+
*/
|
|
503
|
+
nestedPackageJson() {
|
|
504
|
+
if (this.appDir === '.')
|
|
505
|
+
return undefined;
|
|
506
|
+
if (this.#appPackageJsonRead)
|
|
507
|
+
return this.#appPackageJson;
|
|
508
|
+
this.#appPackageJsonRead = true;
|
|
509
|
+
this.#appPackageJson = this.#readPackageManifest(joinRepoRelative(this.appDir, PACKAGE_MANIFEST));
|
|
510
|
+
return this.#appPackageJson;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Read one npm manifest, or answer `undefined` and say why.
|
|
514
|
+
*
|
|
515
|
+
* The two readers above share it so the malformed-manifest policy {@link rootPackageJson} states has
|
|
516
|
+
* one implementation, and the root and the nested manifest cannot come to be reported differently.
|
|
517
|
+
*/
|
|
518
|
+
#readPackageManifest(repoRelativePath) {
|
|
519
|
+
try {
|
|
520
|
+
const parsed = readJsonFile(this.absolute(repoRelativePath));
|
|
521
|
+
if (isJsonObject(parsed))
|
|
522
|
+
return parsed;
|
|
523
|
+
if (parsed !== undefined) {
|
|
524
|
+
this.warn(`${repoRelativePath} is not a JSON object, so no npm command was detected from it`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
catch (error) {
|
|
528
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
529
|
+
this.warn(`${repoRelativePath} could not be read, so no npm command was detected from it: ${detail}`);
|
|
530
|
+
}
|
|
531
|
+
return undefined;
|
|
532
|
+
}
|
|
533
|
+
/** True when the root `package.json` declares a top-level `workspaces` key. */
|
|
534
|
+
hasWorkspacesKey() {
|
|
535
|
+
const manifest = this.rootPackageJson();
|
|
536
|
+
return manifest !== undefined && manifest['workspaces'] !== undefined;
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* The **names** of the root manifest's `scripts` entries that hold a non-empty string.
|
|
540
|
+
*
|
|
541
|
+
* A script's body is never inspected — knowing that `build` exists is what a raw `npm run build`
|
|
542
|
+
* needs, and reading what it does would be the content heuristic this module refuses to make.
|
|
543
|
+
*/
|
|
544
|
+
scriptNames() {
|
|
545
|
+
return scriptNamesOf(this.rootPackageJson());
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
/** The `scripts` names of one parsed manifest that hold a non-empty string. */
|
|
549
|
+
function scriptNamesOf(manifest) {
|
|
550
|
+
const scripts = manifest?.['scripts'];
|
|
551
|
+
if (!isJsonObject(scripts))
|
|
552
|
+
return new Set();
|
|
553
|
+
return new Set(Object.keys(scripts).filter((name) => typeof scripts[name] === 'string' && scripts[name] !== ''));
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* The `scripts` names of the application directory's own manifest, or the empty set when the
|
|
557
|
+
* application is the repository — {@link DetectContext.scriptNames} is that repository's reader.
|
|
558
|
+
*
|
|
559
|
+
* **This widens nothing.** It derives no command and selects no preset; it is what lets the
|
|
560
|
+
* undetected-command warning say *this manifest declares the script, and here is the root-anchored
|
|
561
|
+
* line that runs it* instead of denying that any manifest holds the command. Only the npm family needs
|
|
562
|
+
* it: {@link findPythonManifest} and {@link findGoManifest} already search
|
|
563
|
+
* {@link DetectContext.manifestRoots}, so a nested Python or Go manifest **is** read and its commands
|
|
564
|
+
* **are** emitted. The npm family's deliberate root-only rule is the one that produces a placeholder on
|
|
565
|
+
* a repository whose nested manifest declares the script.
|
|
566
|
+
*/
|
|
567
|
+
export function appScriptNames(context) {
|
|
568
|
+
return scriptNamesOf(context.nestedPackageJson());
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* The candidate root holding the most of `data/`, `domain/`, `presentation/`, or `undefined` when
|
|
572
|
+
* none holds any.
|
|
573
|
+
*
|
|
574
|
+
* "Most, ties broken by candidate order" rather than "first with at least one" is what lets the
|
|
575
|
+
* signal row and the layer preset call the same locator and always agree: a tree whose `appDir`
|
|
576
|
+
* has a stray `data/` while `appDir/src` has all three answers `src` to both questions, instead of
|
|
577
|
+
* matching on one root and building layers from another.
|
|
578
|
+
*/
|
|
579
|
+
export function findLayeredRoot(context) {
|
|
580
|
+
let best;
|
|
581
|
+
for (const root of context.candidateRoots) {
|
|
582
|
+
const dirs = [];
|
|
583
|
+
for (const name of LAYERED_DIR_NAMES) {
|
|
584
|
+
const path = joinRepoRelative(root, name);
|
|
585
|
+
if (context.dirExists(path))
|
|
586
|
+
dirs.push({ name, path });
|
|
587
|
+
}
|
|
588
|
+
if (dirs.length > (best?.dirs.length ?? 0))
|
|
589
|
+
best = { root, dirs };
|
|
590
|
+
}
|
|
591
|
+
return best;
|
|
592
|
+
}
|
|
593
|
+
/** The first {@link API_DIR_NAMES} directory under a candidate root, repo-relative. */
|
|
594
|
+
export function findApiDir(context) {
|
|
595
|
+
for (const root of context.candidateRoots) {
|
|
596
|
+
for (const name of API_DIR_NAMES) {
|
|
597
|
+
const path = joinRepoRelative(root, name);
|
|
598
|
+
if (context.dirExists(path))
|
|
599
|
+
return path;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return undefined;
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* The repo-relative directory a repo-relative manifest path sits in — `.` for a manifest at the
|
|
606
|
+
* repository root.
|
|
607
|
+
*
|
|
608
|
+
* **Never read {@link DetectContext.appDir} in its place.** That is where the tool was *pointed*;
|
|
609
|
+
* this is where the manifest was actually *found*, and the two differ whenever
|
|
610
|
+
* {@link DetectContext.manifestRoots} falls through to the root — a repository whose application
|
|
611
|
+
* directory carries no manifest and whose root does.
|
|
612
|
+
*
|
|
613
|
+
* Normalised through the same private normaliser every other path in this module goes through, so a
|
|
614
|
+
* `.` root, a trailing separator and a backslash separator all come back in one form.
|
|
615
|
+
*/
|
|
616
|
+
export function manifestDirectory(manifestPath) {
|
|
617
|
+
return toRepoRelative(posix.dirname(toRepoRelative(manifestPath)));
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* The first of `names` that exists in a manifest root, repo-relative — manifest roots outer, names
|
|
621
|
+
* inner, so a name found in the application directory wins over an earlier name at the repository
|
|
622
|
+
* root.
|
|
623
|
+
*
|
|
624
|
+
* **The one implementation of the manifest-locator shape.** Every by-name locator below is a call
|
|
625
|
+
* to this with its own list, so "which root, in what order, and what a miss returns" is decided
|
|
626
|
+
* once instead of re-spelled per stack. A locator that needs a different rule — a suffix match
|
|
627
|
+
* ({@link findFileWithSuffix}), a companion file read beside the hit ({@link findNodeInstallSite})
|
|
628
|
+
* — states it rather than passing through here.
|
|
629
|
+
*/
|
|
630
|
+
function findFirstManifest(context, names) {
|
|
631
|
+
for (const root of context.manifestRoots) {
|
|
632
|
+
for (const name of names) {
|
|
633
|
+
const path = joinRepoRelative(root, name);
|
|
634
|
+
if (context.fileExists(path))
|
|
635
|
+
return path;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return undefined;
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* The first entry in a manifest root whose name ends in `suffix`, repo-relative, or `undefined`.
|
|
642
|
+
*
|
|
643
|
+
* **It searches files and directories both**, because the suffix-shaped manifests are of both
|
|
644
|
+
* shapes: `MyApp.sln` is a file and `MyApp.xcodeproj` is a directory, and a locator that saw only
|
|
645
|
+
* one of them would miss half the repositories it exists for. Files are listed first, so a root
|
|
646
|
+
* carrying both shapes answers with the file.
|
|
647
|
+
*
|
|
648
|
+
* "First" means first in the **sorted** listing of {@link DetectContext.childFileNames} /
|
|
649
|
+
* {@link DetectContext.childDirNames}, not first on disk — so a root holding two solutions
|
|
650
|
+
* resolves to the same one on every run, which is what {@link SIGNALS}' reproducibility rule
|
|
651
|
+
* requires of anything that scans a directory.
|
|
652
|
+
*/
|
|
653
|
+
function findFileWithSuffix(context, suffix) {
|
|
654
|
+
for (const root of context.manifestRoots) {
|
|
655
|
+
for (const name of [...context.childFileNames(root), ...context.childDirNames(root)]) {
|
|
656
|
+
if (name.endsWith(suffix))
|
|
657
|
+
return joinRepoRelative(root, name);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return undefined;
|
|
661
|
+
}
|
|
662
|
+
/** The first {@link PYTHON_MANIFESTS} file in a manifest root, repo-relative. */
|
|
663
|
+
export function findPythonManifest(context) {
|
|
664
|
+
return findFirstManifest(context, PYTHON_MANIFESTS);
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* The first {@link PACKAGE_MANIFEST} in a manifest root, with the lockfile question answered beside
|
|
668
|
+
* it rather than at the repository root.
|
|
669
|
+
*
|
|
670
|
+
* **This does not widen {@link DetectContext.rootPackageJson}**, whose only-the-root rule stands for
|
|
671
|
+
* every script-derived command: `npm run build` and `npm test` resolve a script *name* against the
|
|
672
|
+
* manifest the runner reads, and that runner is invoked from the repository root, so a name found in
|
|
673
|
+
* a nested manifest would produce a line that does not run. Its one consumer is the dependency
|
|
674
|
+
* install — at both of the sites that derive it, with and without a root manifest — which is a whole
|
|
675
|
+
* command line rather than a script name, and can therefore be pointed at the directory the manifest
|
|
676
|
+
* is in.
|
|
677
|
+
*
|
|
678
|
+
* It answers the lockfile itself because {@link PACKAGE_MANIFEST} and the path join are
|
|
679
|
+
* module-private: a caller holding only `dir` cannot ask the question about it.
|
|
680
|
+
*/
|
|
681
|
+
export function findNodeInstallSite(context) {
|
|
682
|
+
for (const root of context.manifestRoots) {
|
|
683
|
+
const path = joinRepoRelative(root, PACKAGE_MANIFEST);
|
|
684
|
+
if (context.fileExists(path)) {
|
|
685
|
+
return { manifest: path, dir: root, hasLockfile: context.fileExists(joinRepoRelative(root, PACKAGE_LOCKFILE)) };
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return undefined;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* True when the repository root declares a workspace by any of the means detection recognises: the
|
|
692
|
+
* npm `workspaces` key, or one of {@link WORKSPACE_MANIFESTS}.
|
|
693
|
+
*
|
|
694
|
+
* Exported so the dependency-install carve-out and the two `monorepo` signal rows answer one
|
|
695
|
+
* question: a root that installs every member at once must not be given a member-anchored install.
|
|
696
|
+
* The rows keep their own tests because each has to return the evidence string it matched on; this
|
|
697
|
+
* is the same condition asked without that answer.
|
|
698
|
+
*/
|
|
699
|
+
export function isWorkspaceRoot(context) {
|
|
700
|
+
return context.hasWorkspacesKey() || WORKSPACE_MANIFESTS.some((name) => context.fileExists(name));
|
|
701
|
+
}
|
|
702
|
+
/** The {@link GO_MANIFEST} file in a manifest root, repo-relative. */
|
|
703
|
+
export function findGoManifest(context) {
|
|
704
|
+
return findFirstManifest(context, [GO_MANIFEST]);
|
|
705
|
+
}
|
|
706
|
+
/** The {@link PUBSPEC_MANIFEST} file in a manifest root, repo-relative. */
|
|
707
|
+
export function findFlutterManifest(context) {
|
|
708
|
+
return findFirstManifest(context, [PUBSPEC_MANIFEST]);
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* The Android module of a repository: the first {@link ANDROID_MODULE_DIRS} entry under a manifest
|
|
712
|
+
* root that carries an {@link ANDROID_MANIFEST}, or `undefined`.
|
|
713
|
+
*
|
|
714
|
+
* Manifest roots outer, module names inner, so an `app/` module in the application directory wins
|
|
715
|
+
* over that root's own `src/main/`.
|
|
716
|
+
*
|
|
717
|
+
* **The root is returned beside the module, and cannot be recovered from it.** The layer arm needs
|
|
718
|
+
* the module — the sources are under it — while the command family needs the Gradle project
|
|
719
|
+
* directory, which is where `gradlew` and `settings.gradle` sit and what `-p` has to name; a
|
|
720
|
+
* `gradle -p <module>` at a subproject is a project without the settings file that declares it.
|
|
721
|
+
* The two differ by the `app` segment, and stripping that segment is not a total operation: an
|
|
722
|
+
* application directory literally named `app` whose own `src/main/` holds the manifest answers
|
|
723
|
+
* module `app`, root `app`, which is indistinguishable from the `app`-module case by path alone.
|
|
724
|
+
*/
|
|
725
|
+
export function findAndroidModule(context) {
|
|
726
|
+
for (const root of context.manifestRoots) {
|
|
727
|
+
for (const name of ANDROID_MODULE_DIRS) {
|
|
728
|
+
const module = joinRepoRelative(root, name);
|
|
729
|
+
if (context.fileExists(joinRepoRelative(module, ANDROID_MANIFEST)))
|
|
730
|
+
return { module, root };
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return undefined;
|
|
734
|
+
}
|
|
735
|
+
/** The {@link MAVEN_MANIFEST} file in a manifest root, repo-relative. */
|
|
736
|
+
export function findMavenManifest(context) {
|
|
737
|
+
return findFirstManifest(context, [MAVEN_MANIFEST]);
|
|
738
|
+
}
|
|
739
|
+
/** The first {@link GRADLE_MANIFESTS} file in a manifest root, repo-relative. */
|
|
740
|
+
export function findGradleManifest(context) {
|
|
741
|
+
return findFirstManifest(context, GRADLE_MANIFESTS);
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* The .NET solution or project a `dotnet` command line names, repo-relative, in three steps:
|
|
745
|
+
* a `*.sln`/`*.slnx` in a manifest root, else a `*.csproj`/`*.fsproj`/`*.vbproj` in a manifest
|
|
746
|
+
* root, else one in a **single** level of directories under that root's `src/`.
|
|
747
|
+
*
|
|
748
|
+
* A solution first because it is the whole build: `dotnet build MyApp.sln` compiles every project
|
|
749
|
+
* in it, while a project file names one, and a repository that ships a solution meant it.
|
|
750
|
+
*
|
|
751
|
+
* **The depth bound is exactly one level under `src/`, and it is deliberate.** A project file may
|
|
752
|
+
* legitimately sit anywhere in a solution, so following it further would be a recursive scan of the
|
|
753
|
+
* tree — a search rather than the existence test this table is defined as. One level covers the
|
|
754
|
+
* conventional `src/<Project>/<Project>.csproj` layout and stops. The scan is over
|
|
755
|
+
* {@link DetectContext.childDirNames}, which is sorted, so a `src/` holding several projects
|
|
756
|
+
* resolves to the same one on every run.
|
|
757
|
+
*/
|
|
758
|
+
export function findDotnetProject(context) {
|
|
759
|
+
for (const suffix of DOTNET_SOLUTION_SUFFIXES) {
|
|
760
|
+
const solution = findFileWithSuffix(context, suffix);
|
|
761
|
+
if (solution !== undefined)
|
|
762
|
+
return solution;
|
|
763
|
+
}
|
|
764
|
+
for (const root of context.manifestRoots) {
|
|
765
|
+
const found = findDotnetProjectFileIn(context, root);
|
|
766
|
+
if (found !== undefined)
|
|
767
|
+
return found;
|
|
768
|
+
}
|
|
769
|
+
for (const root of context.manifestRoots) {
|
|
770
|
+
const srcDir = joinRepoRelative(root, SRC_DIR);
|
|
771
|
+
for (const name of context.childDirNames(srcDir)) {
|
|
772
|
+
const found = findDotnetProjectFileIn(context, joinRepoRelative(srcDir, name));
|
|
773
|
+
if (found !== undefined)
|
|
774
|
+
return found;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return undefined;
|
|
778
|
+
}
|
|
779
|
+
/** The first {@link DOTNET_PROJECT_SUFFIXES} file directly in one directory, repo-relative. */
|
|
780
|
+
function findDotnetProjectFileIn(context, dir) {
|
|
781
|
+
for (const name of context.childFileNames(dir)) {
|
|
782
|
+
if (DOTNET_PROJECT_SUFFIXES.some((suffix) => name.endsWith(suffix)))
|
|
783
|
+
return joinRepoRelative(dir, name);
|
|
784
|
+
}
|
|
785
|
+
return undefined;
|
|
786
|
+
}
|
|
787
|
+
/** The {@link SWIFT_PACKAGE_MANIFEST} file in a manifest root, repo-relative. */
|
|
788
|
+
export function findSwiftPackageManifest(context) {
|
|
789
|
+
return findFirstManifest(context, [SWIFT_PACKAGE_MANIFEST]);
|
|
790
|
+
}
|
|
791
|
+
/** The {@link CARGO_MANIFEST} file in a manifest root, repo-relative. */
|
|
792
|
+
export function findCargoManifest(context) {
|
|
793
|
+
return findFirstManifest(context, [CARGO_MANIFEST]);
|
|
794
|
+
}
|
|
795
|
+
/** The {@link BUNDLER_MANIFEST} file in a manifest root, repo-relative. */
|
|
796
|
+
export function findBundlerManifest(context) {
|
|
797
|
+
return findFirstManifest(context, [BUNDLER_MANIFEST]);
|
|
798
|
+
}
|
|
799
|
+
/** The {@link COMPOSER_MANIFEST} file in a manifest root, repo-relative. */
|
|
800
|
+
export function findComposerManifest(context) {
|
|
801
|
+
return findFirstManifest(context, [COMPOSER_MANIFEST]);
|
|
802
|
+
}
|
|
803
|
+
/** The {@link CMAKE_MANIFEST} file in a manifest root, repo-relative. */
|
|
804
|
+
export function findCmakeManifest(context) {
|
|
805
|
+
return findFirstManifest(context, [CMAKE_MANIFEST]);
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* The root-manifest script names that make a repository's own stack Node, for {@link
|
|
809
|
+
* declaresNodeScript}.
|
|
810
|
+
*
|
|
811
|
+
* **The same names `NODE_SCRIPT_CANDIDATES` in `detect/presets.ts` holds**, flattened — the guard
|
|
812
|
+
* fires exactly when the npm command family would answer, which is the invariant that keeps a row
|
|
813
|
+
* declining a repository only where another derivation is waiting for it. The two lists live in two
|
|
814
|
+
* modules because `detect/presets.ts` imports this one, so a closure case asserts them equal
|
|
815
|
+
* (`test/stack-presets.test.mjs`) rather than one importing the other into a cycle.
|
|
816
|
+
*/
|
|
817
|
+
export const NODE_ROW_GUARD_SCRIPTS = [
|
|
818
|
+
'typecheck',
|
|
819
|
+
'check-types',
|
|
820
|
+
'tsc',
|
|
821
|
+
'lint',
|
|
822
|
+
'test',
|
|
823
|
+
'build',
|
|
824
|
+
'dev',
|
|
825
|
+
'start',
|
|
826
|
+
'serve',
|
|
827
|
+
];
|
|
828
|
+
/**
|
|
829
|
+
* True when the repository root's npm manifest declares a script one of the four command keys
|
|
830
|
+
* accepts — file existence plus a top-level `scripts` key inspection, so it stays inside this
|
|
831
|
+
* module's rule.
|
|
832
|
+
*
|
|
833
|
+
* It exists for the manifests that routinely appear in a repository whose stack is **not** the one
|
|
834
|
+
* they name — `CMakeLists.txt` for a `node-gyp`/`cmake-js` addon, `Gemfile` for the CocoaPods and
|
|
835
|
+
* fastlane pin the React Native template ships at the repository root — where the matching row's
|
|
836
|
+
* "this manifest names the repository's own stack" premise does not hold. Node is the one stack with
|
|
837
|
+
* no row of its own, so first-match-wins order cannot protect it the way it protects Python and
|
|
838
|
+
* Rust: there is no npm row for such a repository to be caught by higher up.
|
|
839
|
+
*
|
|
840
|
+
* Exported because `detect/presets.ts` gates the Bundler **command family** on the same predicate:
|
|
841
|
+
* a row guard alone does not move the commands, since that family is tried above `npm` whether or
|
|
842
|
+
* not its row matched (`bundlerCommands`).
|
|
843
|
+
*/
|
|
844
|
+
export function declaresNodeScript(context) {
|
|
845
|
+
const scripts = context.scriptNames();
|
|
846
|
+
return NODE_ROW_GUARD_SCRIPTS.some((name) => scripts.has(name));
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* The Xcode container a command line names, repo-relative: the first {@link XCODE_PROJECT_SUFFIXES}
|
|
850
|
+
* entry in a manifest root, suffixes outer.
|
|
851
|
+
*
|
|
852
|
+
* **Suffixes outer, roots inner** — the reverse of {@link findFirstManifest}'s nesting, and the
|
|
853
|
+
* difference is the point: a workspace anywhere the search reaches beats a project anywhere it
|
|
854
|
+
* reaches, because building the project of a workspace on its own is the wrong build rather than a
|
|
855
|
+
* further-away one. Within one suffix, {@link findFileWithSuffix}'s sorted order keeps a root
|
|
856
|
+
* holding two containers answering the same one on every run.
|
|
857
|
+
*/
|
|
858
|
+
export function findXcodeProject(context) {
|
|
859
|
+
for (const suffix of XCODE_PROJECT_SUFFIXES) {
|
|
860
|
+
const found = findFileWithSuffix(context, suffix);
|
|
861
|
+
if (found !== undefined)
|
|
862
|
+
return found;
|
|
863
|
+
}
|
|
864
|
+
return undefined;
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* The **shared** scheme names of an Xcode container, sorted, with {@link XCODE_SCHEME_SUFFIX}
|
|
868
|
+
* stripped — the empty list when it shares none.
|
|
869
|
+
*
|
|
870
|
+
* Shared schemes only, and that is the whole selectivity: `xcodebuild -scheme` takes a name, a
|
|
871
|
+
* user-local scheme under `xcuserdata/` exists on one developer's machine and on no CI runner, and
|
|
872
|
+
* naming one would generate a command line that fails everywhere but where `init` ran. Sorted
|
|
873
|
+
* because {@link DetectContext.childFileNames} is, so the one-scheme test below and the name it
|
|
874
|
+
* yields are the same on every run.
|
|
875
|
+
*/
|
|
876
|
+
export function findSharedSchemes(context, project) {
|
|
877
|
+
return context
|
|
878
|
+
.childFileNames(joinRepoRelative(project, XCODE_SHARED_SCHEMES_DIR))
|
|
879
|
+
.filter((name) => name.endsWith(XCODE_SCHEME_SUFFIX))
|
|
880
|
+
.map((name) => name.slice(0, -XCODE_SCHEME_SUFFIX.length));
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* The first {@link UNREAD_MANIFESTS} file in a manifest root, repo-relative.
|
|
884
|
+
*
|
|
885
|
+
* Same shape as the locators above and a different purpose: nothing is derived from what it finds. It
|
|
886
|
+
* answers *what is there instead*, for the warning that would otherwise tell an adopter their
|
|
887
|
+
* repository holds no manifest while one sits at its root.
|
|
888
|
+
*/
|
|
889
|
+
export function findUnreadManifest(context) {
|
|
890
|
+
for (const root of context.manifestRoots) {
|
|
891
|
+
for (const name of UNREAD_MANIFESTS) {
|
|
892
|
+
const path = joinRepoRelative(root, name);
|
|
893
|
+
if (context.fileExists(path))
|
|
894
|
+
return path;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
return undefined;
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* The locators for the manifests `init` reads, in the order {@link PACKAGE_MANIFEST} and the
|
|
901
|
+
* constants beside it are enumerated by `READ_MANIFESTS` (`detect/presets.ts`).
|
|
902
|
+
*
|
|
903
|
+
* **A list of locators rather than a second list of names**, so a name added to any constant a
|
|
904
|
+
* locator reads is picked up here with no edit at all, and the only thing a newly supported stack
|
|
905
|
+
* has to add is the row it adds to `READ_MANIFESTS` anyway. The Android entry is spelled out
|
|
906
|
+
* because its locator answers a module rather than a manifest path, and the .NET entry is one row
|
|
907
|
+
* for the two `READ_MANIFESTS` carries because {@link findDotnetProject} answers both.
|
|
908
|
+
*/
|
|
909
|
+
const READ_MANIFEST_LOCATORS = [
|
|
910
|
+
(context) => findFirstManifest(context, [PACKAGE_MANIFEST]),
|
|
911
|
+
findPythonManifest,
|
|
912
|
+
findGoManifest,
|
|
913
|
+
findFlutterManifest,
|
|
914
|
+
(context) => {
|
|
915
|
+
const found = findAndroidModule(context);
|
|
916
|
+
return found === undefined ? undefined : joinRepoRelative(found.module, ANDROID_MANIFEST);
|
|
917
|
+
},
|
|
918
|
+
findMavenManifest,
|
|
919
|
+
findGradleManifest,
|
|
920
|
+
findDotnetProject,
|
|
921
|
+
findSwiftPackageManifest,
|
|
922
|
+
findXcodeProject,
|
|
923
|
+
findCargoManifest,
|
|
924
|
+
findBundlerManifest,
|
|
925
|
+
findComposerManifest,
|
|
926
|
+
findCmakeManifest,
|
|
927
|
+
];
|
|
928
|
+
/**
|
|
929
|
+
* Every manifest `init` reads that is present, repo-relative, in {@link READ_MANIFEST_LOCATORS}'
|
|
930
|
+
* order, deduplicated — the empty list when none is.
|
|
931
|
+
*
|
|
932
|
+
* **What was present, never what supplied the commands.** This order is not `COMMAND_FAMILIES`'
|
|
933
|
+
* (`detect/presets.ts`), so no entry may be paired with a command family by position; the family
|
|
934
|
+
* that answered names its own manifest (`PresetProfile.commandManifest`).
|
|
935
|
+
*
|
|
936
|
+
* Deduplicated because one file can answer two locators — {@link findDotnetProject}'s solution and
|
|
937
|
+
* project arms are one locator, but a stack added later need not be — and a path printed twice reads
|
|
938
|
+
* as two manifests.
|
|
939
|
+
*/
|
|
940
|
+
export function findReadManifests(context) {
|
|
941
|
+
const found = [];
|
|
942
|
+
for (const locate of READ_MANIFEST_LOCATORS) {
|
|
943
|
+
const path = locate(context);
|
|
944
|
+
if (path !== undefined && !found.includes(path))
|
|
945
|
+
found.push(path);
|
|
946
|
+
}
|
|
947
|
+
return found;
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* The first manifest `init` **does** read that exists in a manifest root, repo-relative.
|
|
951
|
+
*
|
|
952
|
+
* {@link findUnreadManifest}'s counterpart, and nothing is derived from what it finds either. It
|
|
953
|
+
* answers *a manifest that could have answered this key is here*, for the warning arm that would
|
|
954
|
+
* otherwise tell an adopter their repository holds no manifest `init` reads while one sits at its
|
|
955
|
+
* root — the shape a partially-resolving family leaves behind, since the family search stops at the
|
|
956
|
+
* first family whose **derivation returns a command set**, not at the first whose manifest exists
|
|
957
|
+
* (`detect/presets.ts`, `commandFamilies`), so a family may read its manifest and decline.
|
|
958
|
+
*
|
|
959
|
+
* Expressed over {@link findReadManifests} rather than over the locators directly, so the probe and
|
|
960
|
+
* the list cannot come to enumerate differently. Nothing is derived from it here either: this path
|
|
961
|
+
* raises no warning and no note.
|
|
962
|
+
*/
|
|
963
|
+
export function findAnyReadManifest(context) {
|
|
964
|
+
return findReadManifests(context)[0];
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* The Python package directory, repo-relative: the first immediate sub-directory of a manifest
|
|
968
|
+
* root carrying an `__init__.py`, else that root's `src/`, else `undefined`.
|
|
969
|
+
*
|
|
970
|
+
* The `src/` fallback covers the `src`-layout convention, where the importable package is one
|
|
971
|
+
* level further down; naming `src` there is enough for a layer path, and picking which package
|
|
972
|
+
* inside it is the layer is a judgement call and therefore `/harness-analyze`'s.
|
|
973
|
+
*/
|
|
974
|
+
export function findPythonPackageDir(context) {
|
|
975
|
+
for (const root of context.manifestRoots) {
|
|
976
|
+
for (const name of context.childDirNames(root)) {
|
|
977
|
+
if (NON_PACKAGE_DIR_NAMES.has(name))
|
|
978
|
+
continue;
|
|
979
|
+
const path = joinRepoRelative(root, name);
|
|
980
|
+
if (context.fileExists(joinRepoRelative(path, PYTHON_PACKAGE_MARKER)))
|
|
981
|
+
return path;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
for (const root of context.manifestRoots) {
|
|
985
|
+
const path = joinRepoRelative(root, SRC_DIR);
|
|
986
|
+
if (context.dirExists(path))
|
|
987
|
+
return path;
|
|
988
|
+
}
|
|
989
|
+
return undefined;
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* The detection table, in evaluation order. **First match wins and the rest are not evaluated**,
|
|
993
|
+
* so the order is the decision: a workspace repository is a monorepo even when one of its packages
|
|
994
|
+
* is layered, and a layered tree is layered even when it also has a `routes/` directory.
|
|
995
|
+
*
|
|
996
|
+
* The last row matches unconditionally, which is what makes detection total.
|
|
997
|
+
*/
|
|
998
|
+
export const SIGNALS = [
|
|
999
|
+
{
|
|
1000
|
+
id: 'monorepo:workspaces-key',
|
|
1001
|
+
preset: 'monorepo',
|
|
1002
|
+
description: 'the repository root package.json declares a top-level `workspaces` key',
|
|
1003
|
+
test: (context) => (context.hasWorkspacesKey() ? { evidence: `${PACKAGE_MANIFEST}#workspaces` } : undefined),
|
|
1004
|
+
},
|
|
1005
|
+
{
|
|
1006
|
+
id: 'monorepo:workspace-manifest',
|
|
1007
|
+
preset: 'monorepo',
|
|
1008
|
+
description: `a workspace-tool manifest at the repository root: ${WORKSPACE_MANIFESTS.join(', ')}`,
|
|
1009
|
+
test: (context) => {
|
|
1010
|
+
for (const name of WORKSPACE_MANIFESTS) {
|
|
1011
|
+
if (context.fileExists(name))
|
|
1012
|
+
return { evidence: name };
|
|
1013
|
+
}
|
|
1014
|
+
return undefined;
|
|
1015
|
+
},
|
|
1016
|
+
},
|
|
1017
|
+
{
|
|
1018
|
+
id: 'layered-clean-arch:layer-directories',
|
|
1019
|
+
preset: 'layered-clean-arch',
|
|
1020
|
+
description: `at least ${LAYERED_MIN_MATCHES} of ${LAYERED_DIR_NAMES.map((name) => `${name}/`).join(', ')} ${CANDIDATE_ROOTS_CLAUSE}`,
|
|
1021
|
+
test: (context) => {
|
|
1022
|
+
const match = findLayeredRoot(context);
|
|
1023
|
+
if (match === undefined || match.dirs.length < LAYERED_MIN_MATCHES)
|
|
1024
|
+
return undefined;
|
|
1025
|
+
return { evidence: match.dirs.map((dir) => dir.path).join(', ') };
|
|
1026
|
+
},
|
|
1027
|
+
},
|
|
1028
|
+
{
|
|
1029
|
+
id: 'python-package:packaging-manifest',
|
|
1030
|
+
preset: 'python-package',
|
|
1031
|
+
description: `a Python packaging manifest: ${PYTHON_MANIFESTS.join(', ')}`,
|
|
1032
|
+
test: (context) => {
|
|
1033
|
+
const manifest = findPythonManifest(context);
|
|
1034
|
+
return manifest === undefined ? undefined : { evidence: manifest };
|
|
1035
|
+
},
|
|
1036
|
+
},
|
|
1037
|
+
{
|
|
1038
|
+
id: 'api-service:route-directory',
|
|
1039
|
+
preset: 'api-service',
|
|
1040
|
+
description: `a ${API_DIR_NAMES.map((name) => `${name}/`).join(', ')} directory ${CANDIDATE_ROOTS_CLAUSE}`,
|
|
1041
|
+
test: (context) => {
|
|
1042
|
+
const dir = findApiDir(context);
|
|
1043
|
+
return dir === undefined ? undefined : { evidence: dir };
|
|
1044
|
+
},
|
|
1045
|
+
},
|
|
1046
|
+
{
|
|
1047
|
+
id: 'api-service:go-module',
|
|
1048
|
+
preset: 'api-service',
|
|
1049
|
+
description: `a ${GO_MANIFEST} file`,
|
|
1050
|
+
test: (context) => {
|
|
1051
|
+
const manifest = findGoManifest(context);
|
|
1052
|
+
return manifest === undefined ? undefined : { evidence: manifest };
|
|
1053
|
+
},
|
|
1054
|
+
},
|
|
1055
|
+
{
|
|
1056
|
+
// **Below `layered-clean-arch:layer-directories`, deliberately.** A Flutter application laid
|
|
1057
|
+
// out as `lib/{data,domain,presentation}` already answers that row, with its three real layers
|
|
1058
|
+
// pointed at the per-layer conventions documents; claiming it here would replace that profile
|
|
1059
|
+
// with a single `lib` layer. Such a tree never reaches this row, and gains only the command
|
|
1060
|
+
// pair below, which is the whole of what it was missing.
|
|
1061
|
+
//
|
|
1062
|
+
// **First of the rows added for a stack that nests another stack's tree.** A Flutter repository
|
|
1063
|
+
// carries `android/` and `ios/` subtrees, so being claimed here is what keeps the
|
|
1064
|
+
// `android-gradle` and `apple-native` rows from ever seeing it. Belt-and-braces rather than
|
|
1065
|
+
// load-bearing: both of those locators probe manifest roots — the application directory and the
|
|
1066
|
+
// repository root — and never descend into `android/` or `ios/`, so ordering alone is not what
|
|
1067
|
+
// the separation rests on.
|
|
1068
|
+
id: 'flutter:pubspec-manifest',
|
|
1069
|
+
preset: 'flutter',
|
|
1070
|
+
description: `a ${PUBSPEC_MANIFEST} file`,
|
|
1071
|
+
test: (context) => {
|
|
1072
|
+
const manifest = findFlutterManifest(context);
|
|
1073
|
+
return manifest === undefined ? undefined : { evidence: manifest };
|
|
1074
|
+
},
|
|
1075
|
+
},
|
|
1076
|
+
{
|
|
1077
|
+
// **How this row settles a collision file existence can answer.** An Android project and a
|
|
1078
|
+
// plain JVM one share `build.gradle` and `settings.gradle`, so neither name tells them apart;
|
|
1079
|
+
// an `AndroidManifest.xml` is present in the first and in no plain JVM module, and it is a file
|
|
1080
|
+
// existence test rather than a read of a Gradle script's plugin line. That is why this row is
|
|
1081
|
+
// ordered ahead of the `jvm` rows below: those match on the shared Gradle manifests, and
|
|
1082
|
+
// evaluating them first would claim every Android repository.
|
|
1083
|
+
//
|
|
1084
|
+
// **A Flutter repository does not match here**, and not only because `flutter:pubspec-manifest`
|
|
1085
|
+
// is evaluated above: its Android manifest sits at `android/app/src/main/AndroidManifest.xml`,
|
|
1086
|
+
// and `findAndroidModule` searches `manifestRoots` — the application directory and the
|
|
1087
|
+
// repository root — so `android/` is not a root it ever looks under.
|
|
1088
|
+
id: 'android-gradle:android-manifest',
|
|
1089
|
+
preset: 'android-gradle',
|
|
1090
|
+
description: `an ${ANDROID_MODULE_DIRS.map((module) => (module === '.' ? ANDROID_MANIFEST : `${module}/${ANDROID_MANIFEST}`)).join(' or ')} file`,
|
|
1091
|
+
test: (context) => {
|
|
1092
|
+
const found = findAndroidModule(context);
|
|
1093
|
+
return found === undefined ? undefined : { evidence: joinRepoRelative(found.module, ANDROID_MANIFEST) };
|
|
1094
|
+
},
|
|
1095
|
+
},
|
|
1096
|
+
{
|
|
1097
|
+
// **Below `android-gradle:android-manifest`, and that order is the whole separation.** An
|
|
1098
|
+
// Android project carries the same `build.gradle` / `settings.gradle` these two rows match on,
|
|
1099
|
+
// so evaluating them first would claim every Android repository; the row above matches on an
|
|
1100
|
+
// `AndroidManifest.xml`, which no plain JVM module has. Reordering these three rows silently
|
|
1101
|
+
// re-presets every Android adopter, which is why a control case asserts the order directly
|
|
1102
|
+
// (`test/stack-presets.test.mjs`).
|
|
1103
|
+
//
|
|
1104
|
+
// **Two rows, one preset**, the shape `api-service` already has: a preset is a layer profile,
|
|
1105
|
+
// and Maven and Gradle JVM projects share the `src/main/<java|kotlin>` source root and differ
|
|
1106
|
+
// only in the runner — so a second preset would buy a second name and the same profile. Which
|
|
1107
|
+
// build tool answers is `SIGNAL_COMMAND_FAMILY`'s question, not this table's: the row that
|
|
1108
|
+
// matched hoists that build tool's command family.
|
|
1109
|
+
id: 'jvm:maven-manifest',
|
|
1110
|
+
preset: 'jvm',
|
|
1111
|
+
description: `a ${MAVEN_MANIFEST} file`,
|
|
1112
|
+
test: (context) => {
|
|
1113
|
+
const manifest = findMavenManifest(context);
|
|
1114
|
+
return manifest === undefined ? undefined : { evidence: manifest };
|
|
1115
|
+
},
|
|
1116
|
+
},
|
|
1117
|
+
{
|
|
1118
|
+
// Maven ahead of Gradle: a repository carrying both is a Maven build with a Gradle script
|
|
1119
|
+
// beside it far more often than the reverse, and first-match-wins has to answer one of them.
|
|
1120
|
+
id: 'jvm:gradle-manifest',
|
|
1121
|
+
preset: 'jvm',
|
|
1122
|
+
description: `a Gradle project manifest: ${GRADLE_MANIFESTS.join(', ')}`,
|
|
1123
|
+
test: (context) => {
|
|
1124
|
+
const manifest = findGradleManifest(context);
|
|
1125
|
+
return manifest === undefined ? undefined : { evidence: manifest };
|
|
1126
|
+
},
|
|
1127
|
+
},
|
|
1128
|
+
{
|
|
1129
|
+
// **Suffix-matched, which is what makes this row different from every manifest row above it.**
|
|
1130
|
+
// A .NET solution and project are named after the product, so there is no fixed file name to
|
|
1131
|
+
// test for; `findFileWithSuffix` resolves the sorted-first entry in a root, which is what keeps
|
|
1132
|
+
// a repository holding two solutions answering the same one on every run.
|
|
1133
|
+
//
|
|
1134
|
+
// **One preset for a .NET backend and a .NET frontend, deliberately.** They share the build
|
|
1135
|
+
// tool, these manifests and the `src/` source root, so the layer profile is the same; what
|
|
1136
|
+
// would tell them apart is the optional-phase set, and `generators/harnessConfig.ts` takes
|
|
1137
|
+
// that from `init`'s flags rather than from the preset — so a second row and a second name
|
|
1138
|
+
// would carry no behaviour at all. The shape `jvm` already has, one step further.
|
|
1139
|
+
//
|
|
1140
|
+
// **Below the JVM rows and above the fallback.** Nothing above matches on a .NET name — the
|
|
1141
|
+
// depth bound of `findDotnetProject` keeps it out of any other stack's tree — so its position
|
|
1142
|
+
// costs nothing; it sits low because a `src/`-nested project is the widest test in the table.
|
|
1143
|
+
id: 'dotnet:solution-or-project',
|
|
1144
|
+
preset: 'dotnet',
|
|
1145
|
+
description: `a ${DOTNET_SOLUTION_SUFFIXES.join('/')} solution, or a ${DOTNET_PROJECT_SUFFIXES.join('/')} project at a manifest root or one level under its ${SRC_DIR}/`,
|
|
1146
|
+
test: (context) => {
|
|
1147
|
+
const project = findDotnetProject(context);
|
|
1148
|
+
return project === undefined ? undefined : { evidence: project };
|
|
1149
|
+
},
|
|
1150
|
+
},
|
|
1151
|
+
{
|
|
1152
|
+
// **The table's second suffix-matched row**, and its `.xcodeproj` / `.xcworkspace` halves are
|
|
1153
|
+
// **directories** — the shape `findFileWithSuffix` searches `childDirNames` for. A SwiftPM
|
|
1154
|
+
// package is matched by name instead, so one row covers both of the ways an Apple-native
|
|
1155
|
+
// repository declares itself.
|
|
1156
|
+
//
|
|
1157
|
+
// **One preset for iOS and macOS**, because nothing a file-existence test can ask separates
|
|
1158
|
+
// them: the manifests, the `Sources/` root and the toolchain are identical, and the platform
|
|
1159
|
+
// appears only inside a scheme or a target setting this table may not read. So the name says
|
|
1160
|
+
// `apple-native` rather than implying an iOS-only scope.
|
|
1161
|
+
//
|
|
1162
|
+
// **Below `flutter:pubspec-manifest`, which is what keeps a Flutter repository's `ios/` tree out
|
|
1163
|
+
// of this row.** Belt-and-braces rather than load-bearing, exactly as that row's own comment
|
|
1164
|
+
// says: both locators here probe manifest roots — the application directory and the repository
|
|
1165
|
+
// root — and never descend into `ios/`, so ordering alone is not what the separation rests on.
|
|
1166
|
+
//
|
|
1167
|
+
// A `Podfile` is **not** matched on: CocoaPods sits beside either container rather than instead
|
|
1168
|
+
// of one, so it decides nothing this row asks, and reading it would answer no question the
|
|
1169
|
+
// `.xcworkspace` it generates has not already answered.
|
|
1170
|
+
id: 'apple-native:swift-package-or-xcode-project',
|
|
1171
|
+
preset: 'apple-native',
|
|
1172
|
+
description: `a ${SWIFT_PACKAGE_MANIFEST} file, or a ${XCODE_PROJECT_SUFFIXES.map((suffix) => `*${suffix}`).join('/')} container, at a manifest root`,
|
|
1173
|
+
test: (context) => {
|
|
1174
|
+
const found = findSwiftPackageManifest(context) ?? findXcodeProject(context);
|
|
1175
|
+
return found === undefined ? undefined : { evidence: found };
|
|
1176
|
+
},
|
|
1177
|
+
},
|
|
1178
|
+
{
|
|
1179
|
+
// **A Cargo workspace root carries this same manifest, and is deliberately not told from a
|
|
1180
|
+
// plain crate.** Distinguishing them would mean reading the file for a `[workspace]` table, and
|
|
1181
|
+
// what it would buy is a per-member layer list — a judgement call, and therefore
|
|
1182
|
+
// `/harness-analyze`'s. A workspace root that keeps a `src/` gets the single source-root layer,
|
|
1183
|
+
// one that does not gets `general` alone, and either is better than the `flat` fallback.
|
|
1184
|
+
//
|
|
1185
|
+
// **Above the fallback and below every row before it**, which costs nothing: no earlier row
|
|
1186
|
+
// matches on a Rust name, and no row above this one matches on a `Cargo.toml`.
|
|
1187
|
+
//
|
|
1188
|
+
// **This row carries no `declaresNodeScript` guard, and that is a decision rather than an
|
|
1189
|
+
// omission.** A `Cargo.toml` does appear in a tree whose own stack is Node — a napi-rs or neon
|
|
1190
|
+
// addon is the routine case, and it is the larger population, not a corner — so this row moves
|
|
1191
|
+
// such a repository from `flat` to `rust-cargo` and hoists `cargo` over the npm scripts its root
|
|
1192
|
+
// manifest declares. It is accepted because what the family answers with is real: the
|
|
1193
|
+
// lint-and-format type-check line `cargoCommands` derives, and `cargo test`, verify the crate
|
|
1194
|
+
// that is actually in the tree, where rows 14
|
|
1195
|
+
// and 16 are guarded precisely because theirs are not (`bundle install` alone verifies nothing,
|
|
1196
|
+
// `ctest` runs against a build tree registering no tests). The price, stated because it is not
|
|
1197
|
+
// free: `depInstall` becomes `cargo fetch`, which resolves the key and so suppresses
|
|
1198
|
+
// `buildPreset`'s root npm fallback, and no worktree the flow cuts gets `node_modules` — the
|
|
1199
|
+
// addon's JavaScript-side tests are then the adopter's to wire into `harness.config.json` by
|
|
1200
|
+
// hand. Recorded in `docs/cli.md` §4 (family-order residual) and asserted in
|
|
1201
|
+
// `test/stack-presets.test.mjs` (the napi-rs case).
|
|
1202
|
+
id: 'rust-cargo:cargo-manifest',
|
|
1203
|
+
preset: 'rust-cargo',
|
|
1204
|
+
description: `a ${CARGO_MANIFEST} file`,
|
|
1205
|
+
test: (context) => {
|
|
1206
|
+
const manifest = findCargoManifest(context);
|
|
1207
|
+
return manifest === undefined ? undefined : { evidence: manifest };
|
|
1208
|
+
},
|
|
1209
|
+
},
|
|
1210
|
+
{
|
|
1211
|
+
// **The collision this row deliberately loses: a Rails application.** Its `app/controllers`
|
|
1212
|
+
// matches `api-service:route-directory` far above here, so it never reaches this row — and that
|
|
1213
|
+
// is the better of the two answers, because `api=app/controllers` names a real request-handling
|
|
1214
|
+
// layer where this row would name only the `app/` source root above it. It loses nothing by
|
|
1215
|
+
// losing: the Bundler command family is selected independently of the preset, so a Rails
|
|
1216
|
+
// repository keeps the `api-service` profile *and* gets `bundle exec` lines.
|
|
1217
|
+
//
|
|
1218
|
+
// **The collision this row must not lose, and the reason for the guard: a repository that is not
|
|
1219
|
+
// Ruby at all.** `Gemfile` is Ruby's only dependency manifest, but it is not evidence that the
|
|
1220
|
+
// repository's stack is Ruby — the React Native project template ships one at the root to pin
|
|
1221
|
+
// CocoaPods and fastlane, and a Jekyll site ships one beside an npm build ({@link
|
|
1222
|
+
// BUNDLER_MANIFEST}). Unguarded, an RN repository was detected `ruby-bundler`, took the `general`
|
|
1223
|
+
// layer only, and had both required keys written as placeholders while the npm scripts sitting in
|
|
1224
|
+
// its root manifest went unread. So the row declines a repository whose root `package.json`
|
|
1225
|
+
// declares a script the npm family would answer with ({@link declaresNodeScript}) — the same
|
|
1226
|
+
// guard, for the same reason, as `cmake-cpp:cmake-lists` below.
|
|
1227
|
+
//
|
|
1228
|
+
// What reaches this row is therefore every other Ruby repository — a gem, a script collection,
|
|
1229
|
+
// a Sinatra service without a route directory — and matching it by name asks nothing further:
|
|
1230
|
+
// `Gemfile` is Ruby's only dependency manifest.
|
|
1231
|
+
//
|
|
1232
|
+
// **The guard is half the fix and the smaller half**, because the Bundler command family is
|
|
1233
|
+
// selected independently of the preset and is tried above `npm`: a declined row leaves the
|
|
1234
|
+
// commands where they were. `COMMAND_FAMILIES`' order is deliberately *not* what was changed —
|
|
1235
|
+
// moving `bundler` below `npm` would take the Rails case above with it, since a Rails
|
|
1236
|
+
// application hoists nothing (its row is directory-shaped) and routinely carries an
|
|
1237
|
+
// asset-pipeline `package.json`, so it would answer `npm run build` instead of `bundle exec
|
|
1238
|
+
// rspec`. The family carries the guard on its own terms instead (`detect/presets.ts`,
|
|
1239
|
+
// `bundlerCommands`).
|
|
1240
|
+
id: 'ruby-bundler:gemfile',
|
|
1241
|
+
preset: 'ruby-bundler',
|
|
1242
|
+
description: `a ${BUNDLER_MANIFEST} file, in a repository whose root ${PACKAGE_MANIFEST} declares no script the npm commands are derived from`,
|
|
1243
|
+
test: (context) => {
|
|
1244
|
+
if (declaresNodeScript(context))
|
|
1245
|
+
return undefined;
|
|
1246
|
+
const manifest = findBundlerManifest(context);
|
|
1247
|
+
return manifest === undefined ? undefined : { evidence: manifest };
|
|
1248
|
+
},
|
|
1249
|
+
},
|
|
1250
|
+
{
|
|
1251
|
+
// **The collision this row deliberately loses, for `ruby-bundler:gemfile`'s reason: a Laravel
|
|
1252
|
+
// application.** Its root `routes/` matches `api-service:route-directory` far above here, so it
|
|
1253
|
+
// never reaches this row, and `api=routes` names a real request-handling layer where this row
|
|
1254
|
+
// would name only the `app/` source root above it. It loses nothing by losing: the Composer
|
|
1255
|
+
// command family is selected independently of the preset, so a Laravel repository keeps the
|
|
1256
|
+
// `api-service` profile *and* gets its `composer`, PHPStan and PHPUnit lines.
|
|
1257
|
+
//
|
|
1258
|
+
// What reaches this row is therefore every other PHP repository — a PSR-4 library, a Symfony
|
|
1259
|
+
// service with no route directory — and matching it by name asks nothing further:
|
|
1260
|
+
// `composer.json` is PHP's only dependency manifest.
|
|
1261
|
+
id: 'php-composer:composer-manifest',
|
|
1262
|
+
preset: 'php-composer',
|
|
1263
|
+
description: `a ${COMPOSER_MANIFEST} file`,
|
|
1264
|
+
test: (context) => {
|
|
1265
|
+
const manifest = findComposerManifest(context);
|
|
1266
|
+
return manifest === undefined ? undefined : { evidence: manifest };
|
|
1267
|
+
},
|
|
1268
|
+
},
|
|
1269
|
+
{
|
|
1270
|
+
// **Last of the manifest rows, and the position is the argument.** Every row above matches on a
|
|
1271
|
+
// file that names the repository's *own* stack; a `CMakeLists.txt` is the one manifest here that
|
|
1272
|
+
// routinely describes a **sub-component** of a repository whose stack is something else — a
|
|
1273
|
+
// native extension in a Python distribution, a `node-gyp` addon, a Rust FFI shim. Evaluated any
|
|
1274
|
+
// higher it would claim all three, which is why it is evaluated below them: a Python
|
|
1275
|
+
// distribution keeps `python-package` and its `mypy`/`pytest` lines and a Cargo crate keeps
|
|
1276
|
+
// `rust-cargo`.
|
|
1277
|
+
//
|
|
1278
|
+
// **Order settles two of those three populations; the third needs the guard, and the reason is
|
|
1279
|
+
// that Node has no row of its own.** An npm repository reaches `flat` (or a directory-shaped
|
|
1280
|
+
// row) by fallback rather than by a positive match, so there is nothing above this row for a
|
|
1281
|
+
// `node-gyp`/`cmake-js` addon to be caught by — and claiming it was silent: both required keys
|
|
1282
|
+
// resolved to CMake lines that never type-check the published TypeScript and run `ctest` against
|
|
1283
|
+
// a build tree registering no tests, with no warning and no note. So the row declines a
|
|
1284
|
+
// repository whose root manifest declares a script the npm family would answer with
|
|
1285
|
+
// ({@link declaresNodeScript}), and what reaches this row is the repository whose only manifest
|
|
1286
|
+
// is CMake's **or** whose root `package.json` declares none of those scripts.
|
|
1287
|
+
//
|
|
1288
|
+
// Matched by name and asking nothing further, for `rust-cargo:cargo-manifest`'s reason: `cmake`
|
|
1289
|
+
// and `ctest` take the same arguments whatever the file declares, so its presence is the whole
|
|
1290
|
+
// test and reading it would answer no question this row asks.
|
|
1291
|
+
id: 'cmake-cpp:cmake-lists',
|
|
1292
|
+
preset: 'cmake-cpp',
|
|
1293
|
+
description: `a ${CMAKE_MANIFEST} file, in a repository whose root ${PACKAGE_MANIFEST} declares no script the npm commands are derived from`,
|
|
1294
|
+
test: (context) => {
|
|
1295
|
+
if (declaresNodeScript(context))
|
|
1296
|
+
return undefined;
|
|
1297
|
+
const manifest = findCmakeManifest(context);
|
|
1298
|
+
return manifest === undefined ? undefined : { evidence: manifest };
|
|
1299
|
+
},
|
|
1300
|
+
},
|
|
1301
|
+
{
|
|
1302
|
+
id: FLAT_FALLBACK_SIGNAL_ID,
|
|
1303
|
+
preset: 'flat',
|
|
1304
|
+
description: 'nothing above matched — the unconditional fallback',
|
|
1305
|
+
warning: FLAT_FALLBACK_WARNING,
|
|
1306
|
+
test: () => ({ evidence: 'no signal matched' }),
|
|
1307
|
+
},
|
|
1308
|
+
];
|
|
1309
|
+
/**
|
|
1310
|
+
* `--preset <name>` validated against {@link PRESET_NAMES}.
|
|
1311
|
+
*
|
|
1312
|
+
* Exported so the `init` flag surface can refuse a typo at parse time, before anything is written,
|
|
1313
|
+
* rather than at the moment detection runs. An unknown name is a usage refusal and carries the
|
|
1314
|
+
* default exit code.
|
|
1315
|
+
*/
|
|
1316
|
+
export function parsePresetName(value) {
|
|
1317
|
+
const match = PRESET_NAMES.find((name) => name === value);
|
|
1318
|
+
if (match === undefined) {
|
|
1319
|
+
throw new HarnessError(`unknown preset ${JSON.stringify(value)}: expected one of ${PRESET_NAMES.join(', ')}`);
|
|
1320
|
+
}
|
|
1321
|
+
return match;
|
|
1322
|
+
}
|
|
1323
|
+
/**
|
|
1324
|
+
* Decide the layer preset for a repository.
|
|
1325
|
+
*
|
|
1326
|
+
* @param repoRoot absolute repository root.
|
|
1327
|
+
* @param appDir `--app-dir`, or `harness.config.json`'s `appDir`; `.` when the repository is the
|
|
1328
|
+
* application.
|
|
1329
|
+
* @param forcedPreset `--preset`, which bypasses the table entirely — no row is evaluated, so the
|
|
1330
|
+
* result reports {@link FORCED_SIGNAL_ID} and the summary says what actually happened rather
|
|
1331
|
+
* than naming a signal that was never tested.
|
|
1332
|
+
*
|
|
1333
|
+
* Never returns a refusal for an unrecognised repository: the last row of {@link SIGNALS} matches
|
|
1334
|
+
* unconditionally and carries {@link FLAT_FALLBACK_WARNING}.
|
|
1335
|
+
*/
|
|
1336
|
+
export function detectPreset(repoRoot, appDir = '.', forcedPreset) {
|
|
1337
|
+
const context = new DetectContext(repoRoot, appDir);
|
|
1338
|
+
if (forcedPreset !== undefined) {
|
|
1339
|
+
const preset = parsePresetName(forcedPreset);
|
|
1340
|
+
return {
|
|
1341
|
+
preset,
|
|
1342
|
+
matchedSignal: FORCED_SIGNAL_ID,
|
|
1343
|
+
evidence: undefined,
|
|
1344
|
+
warnings: context.drainWarnings(),
|
|
1345
|
+
notes: context.drainNotes(),
|
|
1346
|
+
context,
|
|
1347
|
+
};
|
|
1348
|
+
}
|
|
1349
|
+
for (const signal of SIGNALS) {
|
|
1350
|
+
const match = signal.test(context);
|
|
1351
|
+
if (match === undefined)
|
|
1352
|
+
continue;
|
|
1353
|
+
if (signal.warning !== undefined)
|
|
1354
|
+
context.warn(signal.warning);
|
|
1355
|
+
return {
|
|
1356
|
+
preset: signal.preset,
|
|
1357
|
+
matchedSignal: signal.id,
|
|
1358
|
+
evidence: match.evidence,
|
|
1359
|
+
warnings: context.drainWarnings(),
|
|
1360
|
+
notes: context.drainNotes(),
|
|
1361
|
+
context,
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
// Unreachable while the table ends in an unconditional row. It is an internal fault rather than
|
|
1365
|
+
// an adopter-fixable one, so it exits INTERNAL instead of pretending to a preset.
|
|
1366
|
+
throw new HarnessError('stack detection fell through the signal table, which must end in an unconditional row', EXIT.INTERNAL);
|
|
1367
|
+
}
|
|
1368
|
+
//# sourceMappingURL=signals.js.map
|