mapterrain 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +129 -406
- package/SECURITY.md +1 -1
- package/bin/postinstall.js +33 -3
- package/bin/terrain-installer.js +372 -9
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -1,480 +1,203 @@
|
|
|
1
1
|
# Terrain
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Open-source CI pre-flight layer for AI/ML systems and the tests around them. Runs locally. No API key required.**
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
# Homebrew
|
|
7
|
-
brew install pmclSF/terrain/mapterrain
|
|
8
|
-
|
|
9
|
-
# npm
|
|
10
|
-
npm install -g mapterrain
|
|
11
|
-
|
|
12
|
-
cd your-repo
|
|
13
|
-
terrain analyze
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
That's it. No config, no setup, no test execution required.
|
|
17
|
-
|
|
18
|
-
> **New here?** Read the [Quickstart Guide](docs/quickstart.md) to understand your first report in 5 minutes.
|
|
19
|
-
|
|
20
|
-
---
|
|
21
|
-
|
|
22
|
-
Terrain is a test system intelligence platform. It reads your repository — test code, source structure, coverage data, runtime artifacts, ownership files, and local policy — and builds a structural model of how your tests relate to your code. From that model it surfaces risk, quality gaps, redundancy, fragile dependencies, and migration readiness, all without running a single test.
|
|
23
|
-
|
|
24
|
-
The core idea: every codebase has a *test terrain* — the shape of its testing infrastructure, the density of coverage across areas, the hidden fault lines where a fixture change cascades into thousands of tests. Terrain makes that shape visible and navigable so you can make informed decisions about what to test, what to fix, and where to invest.
|
|
25
|
-
|
|
26
|
-
## What "Test Terrain" Means
|
|
27
|
-
|
|
28
|
-
Most teams know what tests they have. Few teams understand the *terrain* underneath:
|
|
29
|
-
|
|
30
|
-
- Which source files have no structural test coverage?
|
|
31
|
-
- Which shared fixtures fan out to thousands of tests, making any change to them a blast-radius problem?
|
|
32
|
-
- Which test clusters are near-duplicates burning CI time?
|
|
33
|
-
- Which areas have tests but weak assertions that wouldn't catch a real regression?
|
|
34
|
-
- When you change `auth/session.ts`, which 41 of your 18,000 tests actually matter?
|
|
35
|
-
|
|
36
|
-
Test terrain is the structural topology of your test system — the dependency graph, the coverage landscape, the duplication clusters, the fanout hotspots, the skip debt. Terrain maps it.
|
|
37
|
-
|
|
38
|
-
## Problems Terrain Solves
|
|
39
|
-
|
|
40
|
-
**"We don't know the state of our test system."** Teams inherit test suites they didn't write. Terrain gives a baseline in seconds: framework mix, coverage confidence, duplication, risk posture.
|
|
41
|
-
|
|
42
|
-
**"CI takes too long and we don't know what to cut."** Terrain identifies redundant tests, high-fanout fixtures, and confidence-based test selection — showing where CI time is wasted and what can be safely reduced.
|
|
43
|
-
|
|
44
|
-
**"We changed auth code — what tests should we worry about?"** Terrain traces your change through the import graph and structural dependencies, returning the impacted tests ranked by confidence, with reason chains explaining each selection.
|
|
45
|
-
|
|
46
|
-
**"A tool flagged something but won't explain why."** Every Terrain finding carries an evidence chain. `terrain explain` shows exactly what signals, dependency paths, and scoring rules produced each decision.
|
|
47
|
-
|
|
48
|
-
**"We're migrating frameworks and need to know what's blocking us."** Migration readiness, blockers by type, and preview-scoped difficulty assessment — all derived from static analysis.
|
|
49
|
-
|
|
50
|
-
## The Four Canonical Workflows
|
|
51
|
-
|
|
52
|
-
Terrain is organized around four questions. Everything else is a supporting view.
|
|
53
|
-
|
|
54
|
-
```
|
|
55
|
-
terrain analyze "What is the state of our test system?"
|
|
56
|
-
terrain insights "What should we fix in our test system?"
|
|
57
|
-
terrain impact "What validations matter for this change?"
|
|
58
|
-
terrain explain "Why did Terrain make this decision?"
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
### 1. Analyze — understand the test system
|
|
62
|
-
|
|
63
|
-
```bash
|
|
64
|
-
terrain analyze
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
```
|
|
68
|
-
Terrain — Test Suite Analysis
|
|
69
|
-
============================================================
|
|
70
|
-
|
|
71
|
-
conftest.py fixture fans out to 3,100 tests — any change retriggers the frame/ suite.
|
|
72
|
-
|
|
73
|
-
Key Findings
|
|
74
|
-
------------------------------------------------------------
|
|
75
|
-
1. [HIGH] 23 source files (18%) have low structural coverage
|
|
76
|
-
2. [HIGH] 8 duplicate test clusters with 0.91+ similarity
|
|
77
|
-
3. [MEDIUM] 34 xfail markers older than 180 days
|
|
78
|
-
|
|
79
|
-
Repository Profile
|
|
80
|
-
------------------------------------------------------------
|
|
81
|
-
Test volume: very large
|
|
82
|
-
CI pressure: high
|
|
83
|
-
Coverage confidence: medium
|
|
84
|
-
Redundancy level: medium
|
|
85
|
-
Fanout burden: high
|
|
86
|
-
|
|
87
|
-
Validation Inventory
|
|
88
|
-
------------------------------------------------------------
|
|
89
|
-
Test files: 1,047
|
|
90
|
-
Test cases: 52,341
|
|
91
|
-
Frameworks:
|
|
92
|
-
pytest 1,047 files [unit]
|
|
93
|
-
|
|
94
|
-
Risk Posture
|
|
95
|
-
------------------------------------------------------------
|
|
96
|
-
health: MODERATE
|
|
97
|
-
coverage_depth: ELEVATED
|
|
98
|
-
coverage_diversity: STRONG
|
|
99
|
-
structural_risk: STRONG
|
|
100
|
-
operational_risk: STRONG
|
|
101
|
-
|
|
102
|
-
Next steps:
|
|
103
|
-
terrain insights prioritized actions and recommendations
|
|
104
|
-
terrain impact what tests matter for this change?
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
### 2. Insights — find what to improve
|
|
108
|
-
|
|
109
|
-
```bash
|
|
110
|
-
terrain insights
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
```
|
|
114
|
-
Terrain — Test System Health Report
|
|
115
|
-
============================================================
|
|
116
|
-
|
|
117
|
-
Health Grade: C
|
|
118
|
-
|
|
119
|
-
Reliability Problems (2)
|
|
120
|
-
[HIGH] 12 flaky tests with >10% failure rate
|
|
121
|
-
[MEDIUM] 34 skipped tests consuming CI resources
|
|
122
|
-
|
|
123
|
-
Coverage Debt (2)
|
|
124
|
-
[HIGH] 23 source files (18%) have low structural coverage
|
|
125
|
-
[MEDIUM] conftest.py fixture fans out to 3,100 tests
|
|
126
|
-
|
|
127
|
-
Recommended Actions
|
|
128
|
-
1. [reliability] Quarantine 12 flaky tests
|
|
129
|
-
why: Flaky tests block unrelated PRs and erode CI trust.
|
|
130
|
-
2. [coverage] Add tests for 23 uncovered source files
|
|
131
|
-
why: Changes in uncovered files cannot trigger test selection.
|
|
132
|
-
3. [optimization] Consolidate 8 duplicate test clusters
|
|
133
|
-
why: 0.91+ similarity — redundant CI cost with no coverage benefit.
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
### 3. Impact — understand what a change affects
|
|
137
|
-
|
|
138
|
-
```bash
|
|
139
|
-
terrain impact --base main
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
```
|
|
143
|
-
Terrain Impact Analysis
|
|
144
|
-
============================================================
|
|
145
|
-
|
|
146
|
-
Summary: 3 file(s) changed, 41 test(s) relevant. Posture: needs_attention.
|
|
147
|
-
|
|
148
|
-
Impacted tests: 127 of 52,341 total
|
|
149
|
-
Coverage confidence: High
|
|
150
|
-
|
|
151
|
-
Recommended Tests (41)
|
|
152
|
-
------------------------------------------------------------
|
|
153
|
-
tests/groupby/test_groupby.py [exact]
|
|
154
|
-
Covers: groupby.py:GroupBy.aggregate
|
|
155
|
-
tests/groupby/test_apply.py [exact]
|
|
156
|
-
Covers: groupby.py:GroupBy.apply
|
|
157
|
-
tests/resample/test_base.py [inferred]
|
|
158
|
-
Reached via shared fixture path
|
|
159
|
-
...and 38 more
|
|
160
|
-
|
|
161
|
-
Protection Gaps
|
|
162
|
-
------------------------------------------------------------
|
|
163
|
-
[medium] pandas/core/groupby/ops.py — no covering tests found
|
|
164
|
-
|
|
165
|
-
Next steps:
|
|
166
|
-
terrain impact --show tests full test list
|
|
167
|
-
terrain impact --show gaps all protection gaps
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
### 4. Explain — understand why
|
|
171
|
-
|
|
172
|
-
```bash
|
|
173
|
-
terrain explain tests/io/json/test_pandas.py
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
```
|
|
177
|
-
Test File: tests/io/json/test_pandas.py
|
|
178
|
-
Framework: pytest
|
|
179
|
-
Tests: 84 Assertions: 312
|
|
180
|
-
|
|
181
|
-
Signals (3):
|
|
182
|
-
[high] networkDependency: 12 tests use @pytest.mark.network — flaky in CI
|
|
183
|
-
[medium] weakAssertion: 8 bare assert statements without descriptive messages
|
|
184
|
-
[low] xfailAccumulation: 3 xfail markers older than 180 days
|
|
185
|
-
|
|
186
|
-
Next steps:
|
|
187
|
-
terrain explain selection explain overall test selection strategy
|
|
188
|
-
terrain impact --show tests see all impacted tests
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
See [Canonical User Journeys](docs/product/canonical-user-journeys.md) for the full workflow specification and [example outputs](docs/examples/) for detailed report samples.
|
|
192
|
-
|
|
193
|
-
## Product Philosophy
|
|
194
|
-
|
|
195
|
-
**Inference first.** Terrain reads code. It parses imports, detects frameworks, resolves coverage relationships, and builds dependency graphs from what already exists in the repository. No annotations, no test tagging, no SDK integration required.
|
|
196
|
-
|
|
197
|
-
**Zero-config by default.** `terrain analyze` works on any repository with test files. Coverage data, runtime artifacts, ownership files, and policy rules are optional inputs that enrich the model — but the core analysis requires nothing beyond the code itself.
|
|
198
|
-
|
|
199
|
-
**Explainability over magic.** Every finding carries evidence: which signal type, what confidence level, what dependency path, what scoring rule. `terrain explain` exposes the full reasoning chain behind any decision. Teams should never wonder *why* Terrain said something.
|
|
200
|
-
|
|
201
|
-
**Conservative under uncertainty.** When Terrain encounters ambiguity — a dependency path with low confidence, a file that might or might not be a test — it flags the uncertainty rather than guessing. Impact analysis uses fallback policies with explicit confidence penalties rather than silently expanding scope.
|
|
202
|
-
|
|
203
|
-
**System health, not individual productivity.** Terrain measures the test system. It never attributes quality to individual developers. Ownership information is used for routing and triage, not scoring.
|
|
204
|
-
|
|
205
|
-
## Who Uses Terrain
|
|
206
|
-
|
|
207
|
-
Terrain is framework-agnostic and language-aware. The same analysis model applies across:
|
|
5
|
+
Rename `customer_name` to `full_name`, and a prompt in another part of the repo may still ask the model for `customer_name`. Change a retriever, and the eval that would catch the quality drop may never run. Terrain catches those gaps in the PR, before the change merges.
|
|
208
6
|
|
|
209
|
-
|
|
210
|
-
- **Backend teams** — Go test suites, pytest collections, JUnit hierarchies, integration test infrastructure
|
|
211
|
-
- **Mobile teams** — cross-platform test suites with standard test frameworks
|
|
212
|
-
- **QA / SDET** — test portfolio management, coverage gap analysis, migration planning across frameworks
|
|
213
|
-
- **SRE / Platform** — CI optimization, test selection for pipelines, policy enforcement
|
|
214
|
-
- **AI / ML evaluation** — evaluation suite structure, benchmark test management, coverage across model behaviors
|
|
7
|
+
Terrain does that by connecting source code, tests, prompts, schemas, eval outputs, runtime artifacts, and coverage reports into one CI-ready protection graph. It turns that graph into ranked findings, explainable impact, JUnit test cases, GitHub annotations, SARIF for security findings, MCP context for AI assistants, and portfolio rollups across repos.
|
|
215
8
|
|
|
216
|
-
|
|
9
|
+
## Why install Terrain?
|
|
217
10
|
|
|
218
|
-
|
|
11
|
+
Most teams already have tests, coverage, and AI evals. The hard part is knowing whether those signals still protect the change in front of you. A developer can see that unit tests passed or that an eval failed, but still not know whether a PR touched an AI/ML surface, which validations should run, what coverage is missing, or why a downstream regression is connected to their diff.
|
|
219
12
|
|
|
220
|
-
Terrain gives
|
|
13
|
+
Terrain gives every PR a pre-merge protection map. In one local command it answers:
|
|
221
14
|
|
|
222
|
-
-
|
|
223
|
-
-
|
|
224
|
-
-
|
|
225
|
-
-
|
|
226
|
-
- **GitHub Action** — drop-in `terrain-ai.yml` workflow template for AI CI gates
|
|
15
|
+
- What is weak or missing in the current test system?
|
|
16
|
+
- What does this PR put at risk?
|
|
17
|
+
- Which tests or evals matter for this change?
|
|
18
|
+
- Why did the gate fail, and what should be fixed first?
|
|
227
19
|
|
|
228
|
-
|
|
20
|
+
That makes day-to-day work less manual:
|
|
229
21
|
|
|
230
|
-
|
|
22
|
+
- Instead of tracing code -> API -> prompt -> eval by hand, run `terrain report pr` and see what the change affects.
|
|
23
|
+
- Instead of rerunning every eval because no one knows which one matters, run the validations Terrain selects for the diff.
|
|
24
|
+
- Instead of reading a failed eval in isolation, use the cause path to see which code, schema, prompt, or retrieval change led there.
|
|
25
|
+
- Instead of letting weak assertions, skipped tests, and uncovered exports accumulate quietly, surface them as concrete findings before release.
|
|
231
26
|
|
|
232
|
-
|
|
27
|
+
The result is faster review, fewer broad reruns, clearer fixes, and fewer regressions that only make sense after someone reconstructs the system by hand.
|
|
233
28
|
|
|
234
|
-
|
|
29
|
+
Bring your own stack. Terrain reads what teams already use: `pytest`, `jest`, `go test`, Playwright, Promptfoo, DeepEval, Ragas, Great Expectations, Gauntlet-style eval-result JSON, JUnit, LCOV, Istanbul, and repository metadata. It does not replace those tools; it unifies their evidence into one local, deterministic CI gate.
|
|
235
30
|
|
|
236
|
-
-
|
|
237
|
-
- **Redundancy reduction** — `terrain insights` surfaces duplicate test clusters. Removing or consolidating them directly reduces CI time without reducing coverage.
|
|
238
|
-
- **Fanout control** — High-fanout fixtures that trigger thousands of tests on any change are identified and prioritized for splitting.
|
|
239
|
-
- **Confidence-based runs** — Impact analysis assigns confidence scores. CI pipelines can run high-confidence tests immediately and defer low-confidence tests to nightly runs.
|
|
240
|
-
- **What to test next** — `terrain insights` ranks untested source files by dependency count, telling you which test to write first for maximum impact.
|
|
31
|
+
Project status: Terrain is pre-1.0 and actively developed. In 0.3.0, the stable path is the CLI, local/CI artifact contract, GitHub Actions flow, AI/eval artifact ingestion, and portfolio aggregation. The VS Code extension is alpha; marketplace listings, full LSP integration, the plugin runtime, and some preview rules are future work.
|
|
241
32
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
## Installation
|
|
245
|
-
|
|
246
|
-
### Homebrew (macOS and Linux)
|
|
33
|
+
## Install
|
|
247
34
|
|
|
248
35
|
```bash
|
|
36
|
+
# macOS / Linux
|
|
249
37
|
brew install pmclSF/terrain/mapterrain
|
|
250
|
-
```
|
|
251
|
-
|
|
252
|
-
After the first install, you can also tap once and use the short formula name:
|
|
253
|
-
|
|
254
|
-
```bash
|
|
255
|
-
brew tap pmclSF/terrain
|
|
256
|
-
brew install mapterrain
|
|
257
|
-
```
|
|
258
38
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
```bash
|
|
39
|
+
# npm (Node 22+ required; macOS/Linux amd64+arm64, Windows amd64)
|
|
262
40
|
npm install -g mapterrain
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
### Go install
|
|
266
41
|
|
|
267
|
-
|
|
42
|
+
# Go
|
|
268
43
|
go install github.com/pmclSF/terrain/cmd/terrain@latest
|
|
269
44
|
```
|
|
270
45
|
|
|
271
|
-
|
|
46
|
+
Pre-built archives are available for macOS and Linux on amd64/arm64, plus Windows on amd64, from the [releases page](https://github.com/pmclSF/terrain/releases). Each release is signed with Sigstore + cosign; see [SECURITY-DATA-HANDLING.md](SECURITY-DATA-HANDLING.md) for verification details.
|
|
272
47
|
|
|
273
|
-
|
|
48
|
+
Package names vary by distribution, but the installed CLI is always `terrain`. The npm package is `mapterrain`; the Homebrew formula is `pmclSF/terrain/mapterrain`.
|
|
274
49
|
|
|
275
|
-
|
|
50
|
+
## Get started
|
|
276
51
|
|
|
277
52
|
```bash
|
|
278
|
-
|
|
279
|
-
|
|
53
|
+
cd your-repo
|
|
54
|
+
terrain analyze # What's the state of our AI + test system?
|
|
55
|
+
terrain report pr # What does this change put at risk?
|
|
280
56
|
```
|
|
281
57
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
### Build from source
|
|
58
|
+
Source analysis covers Python, TypeScript/JavaScript, Go, and Java in 0.3.0. Ruby source is not analyzed in 0.3.0, but Ruby/RSpec and other ecosystems can still contribute dependency, runtime, coverage, and eval artifacts, so mixed-language repos get useful signal. No config required; optional artifacts sharpen findings when present.
|
|
285
59
|
|
|
286
|
-
|
|
287
|
-
git clone https://github.com/pmclSF/terrain.git
|
|
288
|
-
cd terrain
|
|
289
|
-
go build -o terrain ./cmd/terrain
|
|
290
|
-
```
|
|
60
|
+
## What it catches
|
|
291
61
|
|
|
292
|
-
|
|
62
|
+
Terrain models the AI surface alongside the test surface and looks for drift across them:
|
|
293
63
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
64
|
+
- **Prompt-schema drift** — prompts that reference fields renamed in a schema living in a different language
|
|
65
|
+
- **Hardcoded API keys** — provider-shaped secrets in source (OpenAI, Anthropic, AWS, GCP, etc.)
|
|
66
|
+
- **Eval coverage gaps** — AI surfaces (prompts, agents, RAG pipelines) with no scenario covering them
|
|
67
|
+
- **Model deprecations** — deprecated model IDs lingering in production paths
|
|
68
|
+
- **Cross-language edges** — TS/JS ↔ Python/Go/Java via OpenAPI, tRPC, gRPC, GraphQL, HTTP routes
|
|
69
|
+
- **Framework-migration blockers** — Jest ↔ Vitest, JUnit 4 ↔ 5, Cypress ↔ Playwright
|
|
70
|
+
- **Portfolio drift across repos** — manifest-backed `terrain portfolio --from` rollups show framework-of-record drift across a polyrepo
|
|
71
|
+
- **Untested exports + weak assertions** — public API surfaces with no covering test; assertions that pass on too much
|
|
72
|
+
- **Fixture fanout + duplicate clusters + skip debt** — structural problems that erode CI signal
|
|
297
73
|
|
|
298
|
-
|
|
74
|
+
Each finding carries a stable rule ID, severity, confidence, evidence, and documented remediation. Run `terrain explain <rule-id>` for the long form.
|
|
299
75
|
|
|
300
|
-
|
|
301
|
-
# Detect coverage/runtime data paths (recommended first step)
|
|
302
|
-
terrain init
|
|
76
|
+
## What it looks like
|
|
303
77
|
|
|
304
|
-
|
|
305
|
-
|
|
78
|
+
```
|
|
79
|
+
Terrain · Test Suite Analysis
|
|
80
|
+
────────────────────────────────────────────────────────────
|
|
306
81
|
|
|
307
|
-
|
|
308
|
-
terrain analyze --json
|
|
82
|
+
conftest.py fixture fans out to 3,100 tests — any change retriggers the frame/ suite.
|
|
309
83
|
|
|
310
|
-
|
|
311
|
-
|
|
84
|
+
Key Findings
|
|
85
|
+
────────────────────────────────────────────────────────────
|
|
86
|
+
1. [HIGH] 23 exported code units have no linked tests
|
|
87
|
+
2. [MED] 12 test files have weak assertion density
|
|
88
|
+
3. [LOW] 7 skipped-test patterns need review
|
|
312
89
|
|
|
313
|
-
|
|
314
|
-
|
|
90
|
+
Risk Posture
|
|
91
|
+
────────────────────────────────────────────────────────────
|
|
92
|
+
health: Moderate
|
|
93
|
+
coverage_depth: Elevated
|
|
94
|
+
coverage_diversity: Strong
|
|
95
|
+
structural_risk: Strong
|
|
96
|
+
Signals: 65 (8 high, 34 medium, 23 low)
|
|
315
97
|
```
|
|
316
98
|
|
|
317
|
-
|
|
99
|
+
> Representative output from a large pandas-style repository. Format and labels are stable across runs; the specific numbers vary by repo. Full sample reports for `analyze`, `insights`, `impact`, and `explain` are in [docs/examples/](docs/examples/).
|
|
318
100
|
|
|
319
|
-
|
|
101
|
+
## Workflow
|
|
320
102
|
|
|
321
103
|
| Command | Question |
|
|
322
104
|
|---------|----------|
|
|
323
105
|
| `terrain analyze` | What is the state of our test system? |
|
|
324
|
-
| `terrain
|
|
325
|
-
| `terrain
|
|
326
|
-
| `terrain
|
|
327
|
-
|
|
328
|
-
### Supporting commands
|
|
329
|
-
|
|
330
|
-
| Command | Purpose |
|
|
331
|
-
|---------|---------|
|
|
332
|
-
| `terrain init` | Detect data files and print recommended analyze command |
|
|
333
|
-
| `terrain summary` | Executive summary with risk, trends, benchmark readiness |
|
|
334
|
-
| `terrain focus` | Prioritized next actions |
|
|
335
|
-
| `terrain posture` | Detailed posture breakdown with measurement evidence |
|
|
336
|
-
| `terrain portfolio` | Portfolio intelligence: cost, breadth, leverage, redundancy |
|
|
337
|
-
| `terrain metrics` | Aggregate metrics scorecard |
|
|
338
|
-
| `terrain compare` | Compare two snapshots for trend tracking |
|
|
339
|
-
| `terrain select-tests` | Recommend protective test set for a change |
|
|
340
|
-
| `terrain pr` | PR/change-scoped analysis |
|
|
341
|
-
| `terrain show <type> <id>` | Drill into test, unit, owner, or finding |
|
|
342
|
-
| `terrain migration <sub>` | Migration readiness, blockers, or preview |
|
|
343
|
-
| `terrain policy check` | Evaluate local policy rules |
|
|
344
|
-
| `terrain export benchmark` | Privacy-safe JSON export for benchmarking |
|
|
345
|
-
| `terrain serve` | Local HTTP server with HTML report and JSON API |
|
|
346
|
-
|
|
347
|
-
### AI / eval
|
|
348
|
-
|
|
349
|
-
| Command | Purpose |
|
|
350
|
-
|---------|---------|
|
|
351
|
-
| `terrain ai list` | List detected scenarios, prompts, datasets, eval files |
|
|
352
|
-
| `terrain ai doctor` | Validate AI/eval setup and surface configuration issues |
|
|
353
|
-
| `terrain ai run` | Execute eval scenarios and collect results |
|
|
354
|
-
| `terrain ai replay` | Replay and verify a previous eval run artifact |
|
|
355
|
-
| `terrain ai record` | Record eval results as a baseline snapshot |
|
|
356
|
-
| `terrain ai baseline` | Manage eval baselines (show, compare) |
|
|
357
|
-
|
|
358
|
-
### Conversion / migration
|
|
359
|
-
|
|
360
|
-
| Command | Purpose |
|
|
361
|
-
|---------|---------|
|
|
362
|
-
| `terrain convert <source>` | Go-native test conversion (25 directions) |
|
|
363
|
-
| `terrain convert-config <source>` | Convert framework config files |
|
|
364
|
-
| `terrain migrate <dir>` | Project-wide migration with state tracking |
|
|
365
|
-
| `terrain estimate <dir>` | Estimate migration complexity |
|
|
366
|
-
| `terrain status` | Show migration progress |
|
|
367
|
-
| `terrain checklist` | Generate migration checklist |
|
|
368
|
-
| `terrain doctor [path]` | Run migration diagnostics |
|
|
369
|
-
| `terrain reset` | Clear migration state |
|
|
370
|
-
| `terrain list-conversions` | List supported conversion directions |
|
|
371
|
-
| `terrain shorthands` | List shorthand aliases (e.g., `cy2pw`, `jest2vt`) |
|
|
372
|
-
| `terrain detect <file-or-dir>` | Detect dominant framework |
|
|
373
|
-
|
|
374
|
-
### Advanced / debug
|
|
375
|
-
|
|
376
|
-
| Command | Purpose |
|
|
377
|
-
|---------|---------|
|
|
378
|
-
| `terrain debug graph` | Dependency graph statistics |
|
|
379
|
-
| `terrain debug coverage` | Structural coverage analysis |
|
|
380
|
-
| `terrain debug fanout` | High-fanout node analysis |
|
|
381
|
-
| `terrain debug duplicates` | Duplicate test cluster analysis |
|
|
382
|
-
| `terrain debug depgraph` | Full dependency graph analysis (all engines) |
|
|
383
|
-
|
|
384
|
-
Repository-scoped commands support `--root PATH`, and machine-readable commands support `--json`. Most analysis commands support `--verbose` for additional detail. Run `terrain <command> --help` for full flag documentation.
|
|
385
|
-
|
|
386
|
-
## Architecture Overview
|
|
387
|
-
|
|
388
|
-
Terrain is built around a signal-first architecture:
|
|
106
|
+
| `terrain report pr` | What does this change put at risk? |
|
|
107
|
+
| `terrain report insights` | What should we fix? |
|
|
108
|
+
| `terrain report impact` | What validations matter for this change? |
|
|
109
|
+
| `terrain report explain <target>` | Why did Terrain make this decision? |
|
|
389
110
|
|
|
390
|
-
|
|
391
|
-
Repository scan → Signal detection → Risk modeling → Reporting
|
|
392
|
-
│ │ │ │
|
|
393
|
-
test files framework-specific explainable human-readable
|
|
394
|
-
source files pattern detectors risk scoring + JSON output
|
|
395
|
-
coverage data quality signals with evidence
|
|
396
|
-
runtime artifacts health signals chains
|
|
397
|
-
ownership files migration signals
|
|
398
|
-
policy rules governance signals
|
|
399
|
-
```
|
|
111
|
+
The bare forms (`terrain insights`, `terrain impact`, `terrain explain`) work as aliases. AI / eval verbs, framework conversion, debug drill-downs, and the slash-receiver round out the surface — full reference in [docs/cli-spec.md](docs/cli-spec.md).
|
|
400
112
|
|
|
401
|
-
|
|
402
|
-
- **Snapshots** (`TestSuiteSnapshot`) are the canonical serialized artifact — the complete structural model of a test system at a point in time
|
|
403
|
-
- **Risk surfaces** are derived from signals with explainable scoring across dimensions (quality, reliability, speed, governance)
|
|
404
|
-
- **Dependency graphs** model import relationships, fixture fanout, and structural coverage
|
|
405
|
-
- **Reports** synthesize signals, risk, trends, and benchmark readiness into actionable output
|
|
113
|
+
## CI integration
|
|
406
114
|
|
|
407
|
-
```
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
115
|
+
```yaml
|
|
116
|
+
# .github/workflows/terrain.yml
|
|
117
|
+
name: terrain
|
|
118
|
+
on: pull_request
|
|
119
|
+
|
|
120
|
+
jobs:
|
|
121
|
+
analyze:
|
|
122
|
+
runs-on: ubuntu-latest
|
|
123
|
+
steps:
|
|
124
|
+
- uses: actions/checkout@v6
|
|
125
|
+
with: { fetch-depth: 0 }
|
|
126
|
+
- uses: actions/setup-node@v6
|
|
127
|
+
with: { node-version: '22.x' }
|
|
128
|
+
- run: npm install -g mapterrain
|
|
129
|
+
- run: |
|
|
130
|
+
terrain test \
|
|
131
|
+
--junit terrain-results.xml \
|
|
132
|
+
--summary "$GITHUB_STEP_SUMMARY"
|
|
411
133
|
```
|
|
412
134
|
|
|
413
|
-
|
|
135
|
+
`terrain test` is the CI-mode wrapper. The JUnit XML lets your CI render Terrain findings as test cases; setting `--summary "$GITHUB_STEP_SUMMARY"` makes them appear on the workflow run page automatically.
|
|
414
136
|
|
|
415
|
-
|
|
137
|
+
For a blocking gate, add `--fail-on=high` (or `--fail-on=critical` for the strictest threshold). For onboarding a repo with existing debt, pair with `--new-findings-only --baseline <path>` so only regressions block the build. To restrict the gate to AI-related changes, add a `paths:` filter on prompt / schema / Python / TS file globs (see [docs/examples/gate/](docs/examples/gate/) for the full templates).
|
|
416
138
|
|
|
417
|
-
|
|
139
|
+
## Boundaries
|
|
418
140
|
|
|
419
|
-
|
|
420
|
-
# Save a snapshot
|
|
421
|
-
terrain analyze --write-snapshot
|
|
141
|
+
Terrain is intentionally a CI/local pre-flight layer. It composes with the tools teams already trust instead of replacing them:
|
|
422
142
|
|
|
423
|
-
|
|
424
|
-
|
|
143
|
+
- It reads what `pytest`, `jest`, `go test`, Playwright, Promptfoo, DeepEval, Ragas, Great Expectations, and Gauntlet-style eval-result artifacts produce. The AI namespace can execute supported eval-framework commands for `terrain ai run`, but Terrain does not replace those frameworks.
|
|
144
|
+
- It ingests coverage reports when you have them; it does not instrument code.
|
|
145
|
+
- It complements Semgrep / CodeQL / Sonar rather than replacing application-code bug finding.
|
|
146
|
+
- It routes ownership data to findings; it never produces leaderboards or per-developer scores.
|
|
147
|
+
- It runs locally with zero outbound network calls during analysis — verifiable with `terrain --print-network`. The install paths download signed binaries from GitHub Releases; Terrain itself does not upload analysis results.
|
|
148
|
+
- CI artifacts and MCP responses can include repo paths, line numbers, diagnostic text, and sometimes source excerpts. Treat the CI platform or AI assistant you connect as part of the trust boundary.
|
|
425
149
|
|
|
426
|
-
|
|
427
|
-
terrain compare
|
|
150
|
+
Under the hood, Terrain combines source analysis, dependency-graph construction, artifact ingestion, and deterministic rule evaluation. The product is the CI gate and protection graph; static analysis is one mechanism inside it.
|
|
428
151
|
|
|
429
|
-
|
|
430
|
-
terrain summary
|
|
431
|
-
```
|
|
152
|
+
## AI-aware testing
|
|
432
153
|
|
|
433
|
-
|
|
154
|
+
The same dependency graph that powers test selection for application code also traces AI surface edges. When Terrain can map a prompt-template, schema, or eval-artifact change to declared or inferred scenarios, it selects the impacted evals automatically.
|
|
434
155
|
|
|
435
|
-
|
|
156
|
+
- `terrain ai run --base main` — run only the evals your change affects
|
|
157
|
+
- `terrain report pr` — flag changed AI surfaces with no covering eval
|
|
158
|
+
- `terrain ai findings` — AI eval-gap findings with evidence chains
|
|
436
159
|
|
|
437
|
-
|
|
160
|
+
Two generators help adopters harden a prompt before deploying it:
|
|
438
161
|
|
|
439
|
-
```
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
max_weak_assertions: 10
|
|
443
|
-
max_mock_heavy_tests: 5
|
|
162
|
+
```bash
|
|
163
|
+
terrain inject --prompt prompts/main.md # generate jailbreak-shaped test inputs
|
|
164
|
+
terrain scaffold --schema schemas/input.json # generate boundary-case mutation tests
|
|
444
165
|
```
|
|
445
166
|
|
|
446
|
-
|
|
167
|
+
Both emit runnable pytest / vitest scaffolds you drop into your test tree. Terrain never calls the model; the assertion is yours.
|
|
447
168
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
terrain
|
|
451
|
-
```
|
|
169
|
+
## MCP integration
|
|
170
|
+
|
|
171
|
+
`terrain mcp` exposes the last analyze run to AI coding assistants (Claude Code, Cursor, others) over the [Model Context Protocol](https://modelcontextprotocol.io). The assistant can query findings, drill into surfaces, and read baselines without you copy-pasting JSON — so "why is this PR failing Terrain?" gets a useful answer in the IDE instead of a context-switch to the terminal.
|
|
452
172
|
|
|
453
|
-
|
|
173
|
+
The MCP server is local and read-only. The assistant client decides what context, if any, is sent to its model provider.
|
|
174
|
+
|
|
175
|
+
## Plugins
|
|
176
|
+
|
|
177
|
+
Third-party detectors ship as YAML manifests; `terrain plugins manifest <path>` validates one against the stable schema. The plugin runtime — the loader that executes registered detectors — is reserved for a future release. Adopters can author and publish manifests now and they'll be loadable when the runtime ships. See [`examples/plugins/example-manifest.yaml`](examples/plugins/example-manifest.yaml).
|
|
454
178
|
|
|
455
179
|
## Documentation
|
|
456
180
|
|
|
457
|
-
|
|
458
|
-
- [CLI Specification](docs/cli-spec.md) — full command and flag reference
|
|
459
|
-
- [Example Reports](docs/examples/) — analyze, impact, insights, explain output samples
|
|
460
|
-
- [Canonical User Journeys](docs/product/canonical-user-journeys.md) — primary workflows and expected outcomes
|
|
461
|
-
- [Signal Model](docs/signal-model.md) — the core signal abstraction
|
|
462
|
-
- [Architecture](docs/architecture/) — design documents and technical specifications
|
|
463
|
-
- [Contributing](CONTRIBUTING.md) — how to build, test, and extend Terrain
|
|
181
|
+
**Get started**
|
|
464
182
|
|
|
465
|
-
|
|
183
|
+
- [Quickstart](docs/quickstart.md) — first report in 5 minutes
|
|
184
|
+
- [CLI specification](docs/cli-spec.md) — full command + flag reference
|
|
185
|
+
- [Example reports](docs/examples/) — analyze / impact / insights / explain samples
|
|
466
186
|
|
|
467
|
-
|
|
468
|
-
# Build
|
|
469
|
-
go build -o terrain ./cmd/terrain
|
|
187
|
+
**Reference**
|
|
470
188
|
|
|
471
|
-
|
|
472
|
-
|
|
189
|
+
- [Design](DESIGN.md) — architecture, package map, signal pipeline
|
|
190
|
+
- [Severity rubric](docs/severity-rubric.md) — severity labels and configuration
|
|
191
|
+
- [Compatibility](docs/compatibility.md) — supported OSes, Go versions, frameworks, schemas
|
|
192
|
+
- [Glossary](docs/glossary.md) — Terrain-specific vocabulary
|
|
193
|
+
- [Versioning](docs/versioning.md) — what counts as a breaking change
|
|
473
194
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
195
|
+
**Project**
|
|
196
|
+
|
|
197
|
+
- [CHANGELOG](CHANGELOG.md) — release history
|
|
198
|
+
- [Security](SECURITY.md) — supported versions + vulnerability disclosure
|
|
199
|
+
- [Contributing](CONTRIBUTING.md) — how to build, test, and extend Terrain
|
|
477
200
|
|
|
478
201
|
## License
|
|
479
202
|
|
|
480
|
-
Apache License 2.0 — see [LICENSE](LICENSE)
|
|
203
|
+
Apache License 2.0 — see [LICENSE](LICENSE).
|
package/SECURITY.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Supported Versions
|
|
4
4
|
|
|
5
|
-
Security updates are provided for the latest published major version of Terrain
|
|
5
|
+
Security updates are provided for the latest published major version of Terrain (the `terrain` Go CLI, distributed via Homebrew, npm, and direct binary).
|
|
6
6
|
|
|
7
7
|
## Reporting a Vulnerability
|
|
8
8
|
|
package/bin/postinstall.js
CHANGED
|
@@ -1,12 +1,42 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
ensureTerrainBinary,
|
|
5
|
+
writeInstallFailureMarker,
|
|
6
|
+
clearInstallFailureMarker,
|
|
7
|
+
} from './terrain-installer.js';
|
|
4
8
|
|
|
9
|
+
// We intentionally don't fail npm install when the binary fetch fails —
|
|
10
|
+
// CI pipelines that run `npm install` as part of a larger flow can
|
|
11
|
+
// recover from a transient download issue, and forcing every cosign-
|
|
12
|
+
// missing host to fail the install would be more disruptive than the
|
|
13
|
+
// failure mode itself. But a silent warning is also wrong: a missing
|
|
14
|
+
// binary should not be discovered five minutes later when the user
|
|
15
|
+
// runs `terrain analyze` and gets a confusing retry.
|
|
16
|
+
//
|
|
17
|
+
// Compromise: write a marker file describing the failure. The CLI
|
|
18
|
+
// trampoline reads it on first run and prints a clear, framed error
|
|
19
|
+
// pointing at the remediation (install cosign, or set the documented
|
|
20
|
+
// opt-out env var) instead of attempting a silent retry.
|
|
5
21
|
try {
|
|
6
22
|
await ensureTerrainBinary({ quiet: false });
|
|
23
|
+
// Clean up any stale marker from a previous failed install.
|
|
24
|
+
await clearInstallFailureMarker();
|
|
7
25
|
} catch (error) {
|
|
26
|
+
await writeInstallFailureMarker(error);
|
|
8
27
|
process.stderr.write(
|
|
9
|
-
|
|
10
|
-
'
|
|
28
|
+
'\n' +
|
|
29
|
+
'!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n' +
|
|
30
|
+
'! mapterrain: binary install FAILED !\n' +
|
|
31
|
+
'!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\n' +
|
|
32
|
+
'\n' +
|
|
33
|
+
`${error.message}\n` +
|
|
34
|
+
'\n' +
|
|
35
|
+
'npm install reports success, but the `terrain` binary is NOT\n' +
|
|
36
|
+
'installed. Running `terrain` will fail with the same error\n' +
|
|
37
|
+
'until the underlying issue is resolved.\n' +
|
|
38
|
+
'\n' +
|
|
39
|
+
'Marker written to ~/.terrain/install-failure.log\n' +
|
|
40
|
+
'\n'
|
|
11
41
|
);
|
|
12
42
|
}
|
package/bin/terrain-installer.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { execFileSync, spawn } from 'child_process';
|
|
4
|
+
import { createHash } from 'crypto';
|
|
4
5
|
import { createWriteStream, existsSync } from 'fs';
|
|
5
6
|
import fs from 'fs/promises';
|
|
6
7
|
import https from 'https';
|
|
@@ -17,8 +18,85 @@ const packageJson = JSON.parse(
|
|
|
17
18
|
|
|
18
19
|
const GITHUB_OWNER = 'pmclSF';
|
|
19
20
|
const GITHUB_REPO = 'terrain';
|
|
21
|
+
const DOWNLOAD_TIMEOUT_MS = 120000;
|
|
22
|
+
|
|
23
|
+
// installFailureMarkerPath returns the path where a failed install
|
|
24
|
+
// records its error. The CLI trampoline checks this before retrying
|
|
25
|
+
// so users see a clear remediation message instead of a confusing
|
|
26
|
+
// retry of the same failure. ~/.terrain is also where local snapshots
|
|
27
|
+
// live, so the location is already a Terrain working directory.
|
|
28
|
+
function installFailureMarkerPath() {
|
|
29
|
+
return path.join(os.homedir(), '.terrain', 'install-failure.log');
|
|
30
|
+
}
|
|
20
31
|
|
|
21
|
-
|
|
32
|
+
// writeInstallFailureMarker is called from postinstall.js when
|
|
33
|
+
// `npm install` fails to fetch / verify the binary. It captures the
|
|
34
|
+
// error so the next `terrain` invocation can print it verbatim
|
|
35
|
+
// without attempting another silent retry.
|
|
36
|
+
export async function writeInstallFailureMarker(error) {
|
|
37
|
+
try {
|
|
38
|
+
const markerPath = installFailureMarkerPath();
|
|
39
|
+
await fs.mkdir(path.dirname(markerPath), { recursive: true });
|
|
40
|
+
const body = JSON.stringify(
|
|
41
|
+
{
|
|
42
|
+
timestamp: new Date().toISOString(),
|
|
43
|
+
message: error?.message ?? String(error),
|
|
44
|
+
stack: error?.stack ?? null,
|
|
45
|
+
platform: `${process.platform}/${process.arch}`,
|
|
46
|
+
version: packageJson.version,
|
|
47
|
+
},
|
|
48
|
+
null,
|
|
49
|
+
2
|
|
50
|
+
);
|
|
51
|
+
await fs.writeFile(markerPath, body, 'utf8');
|
|
52
|
+
} catch (writeErr) {
|
|
53
|
+
// Failing to write the marker is itself non-fatal; the postinstall
|
|
54
|
+
// warning has already been printed.
|
|
55
|
+
process.stderr.write(
|
|
56
|
+
`[mapterrain] (could not record install-failure marker: ${writeErr.message})\n`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// clearInstallFailureMarker removes the marker on a successful
|
|
62
|
+
// install or successful first run. Idempotent.
|
|
63
|
+
export async function clearInstallFailureMarker() {
|
|
64
|
+
try {
|
|
65
|
+
await fs.unlink(installFailureMarkerPath());
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (err.code !== 'ENOENT') {
|
|
68
|
+
// ENOENT is the happy path (no marker existed). Anything else
|
|
69
|
+
// is unexpected; surface it but don't fail.
|
|
70
|
+
process.stderr.write(
|
|
71
|
+
`[mapterrain] (could not clear install-failure marker: ${err.message})\n`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// readInstallFailureMarker returns the recorded error message, or
|
|
78
|
+
// null if no marker exists.
|
|
79
|
+
async function readInstallFailureMarker() {
|
|
80
|
+
try {
|
|
81
|
+
const body = await fs.readFile(installFailureMarkerPath(), 'utf8');
|
|
82
|
+
return JSON.parse(body);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const SUPPORTED_TARGETS = new Set([
|
|
89
|
+
'darwin/amd64',
|
|
90
|
+
'darwin/arm64',
|
|
91
|
+
'linux/amd64',
|
|
92
|
+
'linux/arm64',
|
|
93
|
+
'windows/amd64',
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
export function targetForPlatform(
|
|
97
|
+
platform = process.platform,
|
|
98
|
+
arch = process.arch
|
|
99
|
+
) {
|
|
22
100
|
const goosMap = {
|
|
23
101
|
darwin: 'darwin',
|
|
24
102
|
linux: 'linux',
|
|
@@ -29,12 +107,20 @@ function currentTarget() {
|
|
|
29
107
|
arm64: 'arm64',
|
|
30
108
|
};
|
|
31
109
|
|
|
32
|
-
const goos = goosMap[
|
|
33
|
-
const goarch = goarchMap[
|
|
110
|
+
const goos = goosMap[platform];
|
|
111
|
+
const goarch = goarchMap[arch];
|
|
34
112
|
if (!goos || !goarch) {
|
|
35
113
|
throw new Error(
|
|
36
|
-
`Unsupported platform ${
|
|
37
|
-
'Install Terrain manually from GitHub Releases or
|
|
114
|
+
`Unsupported platform ${platform}/${arch}. ` +
|
|
115
|
+
'Install Terrain manually from GitHub Releases, Homebrew on macOS/Linux, or source.'
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (!SUPPORTED_TARGETS.has(`${goos}/${goarch}`)) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Unsupported prebuilt Terrain target ${goos}/${goarch}. ` +
|
|
121
|
+
'Published npm-install binaries are available for ' +
|
|
122
|
+
`${Array.from(SUPPORTED_TARGETS).sort().join(', ')}. ` +
|
|
123
|
+
'Install Terrain manually from source with `go install github.com/pmclSF/terrain/cmd/terrain@latest`.'
|
|
38
124
|
);
|
|
39
125
|
}
|
|
40
126
|
|
|
@@ -46,6 +132,10 @@ function currentTarget() {
|
|
|
46
132
|
};
|
|
47
133
|
}
|
|
48
134
|
|
|
135
|
+
function currentTarget() {
|
|
136
|
+
return targetForPlatform();
|
|
137
|
+
}
|
|
138
|
+
|
|
49
139
|
function isDevelopmentCheckout(rootDir = packageRoot) {
|
|
50
140
|
return existsSync(path.join(rootDir, 'cmd', 'terrain'));
|
|
51
141
|
}
|
|
@@ -71,11 +161,223 @@ function archiveFileName(version) {
|
|
|
71
161
|
return `terrain_${version}_${target.goos}_${target.goarch}.${target.archiveExt}`;
|
|
72
162
|
}
|
|
73
163
|
|
|
74
|
-
function
|
|
164
|
+
function releaseDownloadBaseUrl(version) {
|
|
75
165
|
const baseUrl =
|
|
76
166
|
process.env.TERRAIN_INSTALLER_BASE_URL ||
|
|
77
167
|
`https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases/download`;
|
|
78
|
-
return `${baseUrl}/v${version}
|
|
168
|
+
return `${baseUrl}/v${version}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function archiveDownloadUrl(version) {
|
|
172
|
+
return `${releaseDownloadBaseUrl(version)}/${archiveFileName(version)}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function signatureDownloadUrl(version) {
|
|
176
|
+
return `${archiveDownloadUrl(version)}.sig`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function certificateDownloadUrl(version) {
|
|
180
|
+
return `${archiveDownloadUrl(version)}.pem`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function checksumDownloadUrl(version) {
|
|
184
|
+
return `${releaseDownloadBaseUrl(version)}/checksums.txt`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function expectedSignerIdentity(version) {
|
|
188
|
+
// The keyless Sigstore signature is anchored to the GitHub Actions workflow
|
|
189
|
+
// that ran goreleaser at release time. The workflow runs on the v<version>
|
|
190
|
+
// tag, so the OIDC subject identity is deterministic.
|
|
191
|
+
return (
|
|
192
|
+
`https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}` +
|
|
193
|
+
`/.github/workflows/release.yml@refs/tags/v${version}`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const SIGSTORE_OIDC_ISSUER = 'https://token.actions.githubusercontent.com';
|
|
198
|
+
|
|
199
|
+
function isCosignAvailable() {
|
|
200
|
+
try {
|
|
201
|
+
execFileSync('cosign', ['version'], { stdio: 'pipe' });
|
|
202
|
+
return true;
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function checksumFromManifest(manifestText, archiveName) {
|
|
209
|
+
for (const rawLine of manifestText.split(/\r?\n/)) {
|
|
210
|
+
const line = rawLine.trim();
|
|
211
|
+
if (!line) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const match = line.match(/^([a-fA-F0-9]{64})\s+(.+)$/);
|
|
215
|
+
if (!match) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const candidate = path.basename(match[2].replace(/^\*/, '').trim());
|
|
219
|
+
if (candidate === archiveName) {
|
|
220
|
+
return match[1].toLowerCase();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function verifyChecksumDigest(manifestText, archiveName, actualDigest) {
|
|
227
|
+
const expected = checksumFromManifest(manifestText, archiveName);
|
|
228
|
+
if (!expected) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`checksums.txt did not contain an entry for ${archiveName}`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
const actual = actualDigest.toLowerCase();
|
|
234
|
+
if (actual !== expected) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`checksum mismatch for ${archiveName}: expected ${expected}, got ${actual}`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
return expected;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function sha256File(filePath) {
|
|
243
|
+
const hash = createHash('sha256');
|
|
244
|
+
hash.update(await fs.readFile(filePath));
|
|
245
|
+
return hash.digest('hex');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function verifyChecksum({ archivePath, version, tempDir, quiet }) {
|
|
249
|
+
const checksumsPath = path.join(tempDir, 'checksums.txt');
|
|
250
|
+
await downloadFile(checksumDownloadUrl(version), checksumsPath);
|
|
251
|
+
|
|
252
|
+
const archiveName = path.basename(archivePath);
|
|
253
|
+
const manifest = await fs.readFile(checksumsPath, 'utf8');
|
|
254
|
+
const actual = await sha256File(archivePath);
|
|
255
|
+
verifyChecksumDigest(manifest, archiveName, actual);
|
|
256
|
+
|
|
257
|
+
log(`Checksum verified for ${archiveName}`, quiet);
|
|
258
|
+
return { verified: true, reason: 'checksum' };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Sigstore signature verification.
|
|
262
|
+
//
|
|
263
|
+
// 0.3.x policy: Sigstore verification is MANDATORY by default. If
|
|
264
|
+
// `cosign` is not available on the host, the install fails with a
|
|
265
|
+
// clear remediation pointer. The escape for trusted/CI/air-gapped
|
|
266
|
+
// environments is the documented opt-out
|
|
267
|
+
// `TERRAIN_INSTALLER_SKIP_VERIFY=1`.
|
|
268
|
+
//
|
|
269
|
+
// Earlier revisions silently degraded to "checksum-only" when cosign
|
|
270
|
+
// was missing, which meant a typical npm-install on a host without
|
|
271
|
+
// cosign (most macOS / Linux dev machines) skipped Sigstore entirely
|
|
272
|
+
// without any signal in the install log beyond a one-line "falling
|
|
273
|
+
// back" message: the strong-integrity guarantee we advertise degraded
|
|
274
|
+
// silently to weak by default. Mandatory verification closes the gap;
|
|
275
|
+
// the env-var escape keeps adoption viable.
|
|
276
|
+
//
|
|
277
|
+
// Escape hatches:
|
|
278
|
+
//
|
|
279
|
+
// - TERRAIN_INSTALLER_SKIP_VERIFY=1 — fully opt out (CI / air-gapped).
|
|
280
|
+
// Prints a WARNING so the bypass is auditable.
|
|
281
|
+
// - TERRAIN_INSTALLER_ALLOW_MISSING_COSIGN=1 — opt-in degrade-to-
|
|
282
|
+
// checksum behavior for hosts that genuinely cannot install
|
|
283
|
+
// cosign.
|
|
284
|
+
//
|
|
285
|
+
// Once cosign is on the host, every verify failure is a hard error.
|
|
286
|
+
async function verifySignatureBestEffort({
|
|
287
|
+
archivePath,
|
|
288
|
+
version,
|
|
289
|
+
tempDir,
|
|
290
|
+
quiet,
|
|
291
|
+
env,
|
|
292
|
+
}) {
|
|
293
|
+
if (env.TERRAIN_INSTALLER_SKIP_VERIFY === '1') {
|
|
294
|
+
log(
|
|
295
|
+
'WARNING: signature verification skipped (TERRAIN_INSTALLER_SKIP_VERIFY=1). ' +
|
|
296
|
+
'Set this only in trusted CI / air-gapped environments where ' +
|
|
297
|
+
'integrity is established by another channel.',
|
|
298
|
+
quiet
|
|
299
|
+
);
|
|
300
|
+
return { verified: false, reason: 'skipped-by-env' };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!isCosignAvailable()) {
|
|
304
|
+
if (env.TERRAIN_INSTALLER_ALLOW_MISSING_COSIGN === '1') {
|
|
305
|
+
log(
|
|
306
|
+
'cosign not found on PATH. Continuing with checksum-only verification ' +
|
|
307
|
+
'because TERRAIN_INSTALLER_ALLOW_MISSING_COSIGN=1 is set. ' +
|
|
308
|
+
'For stronger integrity guarantees install cosign ' +
|
|
309
|
+
'(https://github.com/sigstore/cosign) and reinstall.',
|
|
310
|
+
quiet
|
|
311
|
+
);
|
|
312
|
+
return await verifyChecksum({ archivePath, version, tempDir, quiet });
|
|
313
|
+
}
|
|
314
|
+
throw new Error(
|
|
315
|
+
'cosign is required to verify the Sigstore signature on the Terrain ' +
|
|
316
|
+
'release archive, but was not found on PATH.\n\n' +
|
|
317
|
+
'Resolve by one of:\n' +
|
|
318
|
+
' 1. Install cosign: https://github.com/sigstore/cosign#installation\n' +
|
|
319
|
+
' (Homebrew: `brew install cosign`. Linux: see release notes.)\n' +
|
|
320
|
+
' 2. If this host genuinely cannot install cosign and you trust the ' +
|
|
321
|
+
'GitHub-provided checksum file, set ' +
|
|
322
|
+
'TERRAIN_INSTALLER_ALLOW_MISSING_COSIGN=1 to fall back to ' +
|
|
323
|
+
'checksum-only verification.\n' +
|
|
324
|
+
' 3. To skip integrity verification entirely (NOT recommended), ' +
|
|
325
|
+
'set TERRAIN_INSTALLER_SKIP_VERIFY=1.'
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const sigPath = path.join(tempDir, `${path.basename(archivePath)}.sig`);
|
|
330
|
+
const certPath = path.join(tempDir, `${path.basename(archivePath)}.pem`);
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
await downloadFile(signatureDownloadUrl(version), sigPath);
|
|
334
|
+
await downloadFile(certificateDownloadUrl(version), certPath);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
// Hard error in 0.3: if cosign is present, the signature download
|
|
337
|
+
// is required. The release pipeline produces signatures for every
|
|
338
|
+
// archive; their absence is a real failure mode worth surfacing.
|
|
339
|
+
throw new Error(
|
|
340
|
+
`cosign is installed but the Sigstore signature artifacts for ` +
|
|
341
|
+
`terrain ${version} could not be downloaded: ${error.message}. ` +
|
|
342
|
+
`Set TERRAIN_INSTALLER_SKIP_VERIFY=1 to bypass at your own risk.`
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
execFileSync(
|
|
348
|
+
'cosign',
|
|
349
|
+
[
|
|
350
|
+
'verify-blob',
|
|
351
|
+
'--certificate',
|
|
352
|
+
certPath,
|
|
353
|
+
'--signature',
|
|
354
|
+
sigPath,
|
|
355
|
+
'--certificate-identity',
|
|
356
|
+
expectedSignerIdentity(version),
|
|
357
|
+
'--certificate-oidc-issuer',
|
|
358
|
+
SIGSTORE_OIDC_ISSUER,
|
|
359
|
+
archivePath,
|
|
360
|
+
],
|
|
361
|
+
{ stdio: 'pipe' }
|
|
362
|
+
);
|
|
363
|
+
log(
|
|
364
|
+
`Verified Sigstore signature for ${path.basename(archivePath)}.`,
|
|
365
|
+
quiet
|
|
366
|
+
);
|
|
367
|
+
return { verified: true, reason: 'ok' };
|
|
368
|
+
} catch (error) {
|
|
369
|
+
// Hard error in 0.3: a verify-blob failure means the archive on disk
|
|
370
|
+
// does NOT match the signed certificate. Aborting the install is
|
|
371
|
+
// strictly safer than silently continuing.
|
|
372
|
+
const detail = error.stderr
|
|
373
|
+
? error.stderr.toString().trim()
|
|
374
|
+
: error.message;
|
|
375
|
+
throw new Error(
|
|
376
|
+
`cosign verify-blob FAILED for ${path.basename(archivePath)}: ${detail}. ` +
|
|
377
|
+
`The downloaded archive does not match its Sigstore signature; ` +
|
|
378
|
+
`the binary may have been tampered with. Install aborted.`
|
|
379
|
+
);
|
|
380
|
+
}
|
|
79
381
|
}
|
|
80
382
|
|
|
81
383
|
async function ensureDirectory(dir) {
|
|
@@ -96,7 +398,18 @@ function log(message, quiet = false) {
|
|
|
96
398
|
}
|
|
97
399
|
}
|
|
98
400
|
|
|
99
|
-
|
|
401
|
+
// MAX_REDIRECTS caps redirect chains to defend against misconfigured
|
|
402
|
+
// proxies that loop. 5 covers every normal redirect chain (GitHub
|
|
403
|
+
// release → CDN → storage backend) with margin to spare. Without this
|
|
404
|
+
// cap the recursion is unbounded — a redirect loop hangs the installer
|
|
405
|
+
// until the OS kills it.
|
|
406
|
+
const MAX_REDIRECTS = 5;
|
|
407
|
+
|
|
408
|
+
async function downloadFile(
|
|
409
|
+
url,
|
|
410
|
+
destinationPath,
|
|
411
|
+
redirectsRemaining = MAX_REDIRECTS
|
|
412
|
+
) {
|
|
100
413
|
await new Promise((resolve, reject) => {
|
|
101
414
|
const request = https.get(
|
|
102
415
|
url,
|
|
@@ -113,8 +426,22 @@ async function downloadFile(url, destinationPath) {
|
|
|
113
426
|
response.headers.location
|
|
114
427
|
) {
|
|
115
428
|
response.resume();
|
|
429
|
+
if (redirectsRemaining <= 0) {
|
|
430
|
+
reject(
|
|
431
|
+
new Error(
|
|
432
|
+
`download exceeded ${MAX_REDIRECTS} redirects for ${url}; ` +
|
|
433
|
+
'check for proxy redirect loops or set ' +
|
|
434
|
+
'TERRAIN_INSTALLER_BASE_URL to a direct download host.'
|
|
435
|
+
)
|
|
436
|
+
);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
116
439
|
try {
|
|
117
|
-
await downloadFile(
|
|
440
|
+
await downloadFile(
|
|
441
|
+
response.headers.location,
|
|
442
|
+
destinationPath,
|
|
443
|
+
redirectsRemaining - 1
|
|
444
|
+
);
|
|
118
445
|
resolve();
|
|
119
446
|
} catch (error) {
|
|
120
447
|
reject(error);
|
|
@@ -141,6 +468,13 @@ async function downloadFile(url, destinationPath) {
|
|
|
141
468
|
}
|
|
142
469
|
);
|
|
143
470
|
|
|
471
|
+
request.setTimeout(DOWNLOAD_TIMEOUT_MS, () => {
|
|
472
|
+
request.destroy(
|
|
473
|
+
new Error(
|
|
474
|
+
`download timed out after ${DOWNLOAD_TIMEOUT_MS / 1000}s for ${url}`
|
|
475
|
+
)
|
|
476
|
+
);
|
|
477
|
+
});
|
|
144
478
|
request.on('error', reject);
|
|
145
479
|
});
|
|
146
480
|
}
|
|
@@ -233,6 +567,13 @@ export async function ensureTerrainBinary({
|
|
|
233
567
|
quiet
|
|
234
568
|
);
|
|
235
569
|
await downloadFile(archiveDownloadUrl(version), archivePath);
|
|
570
|
+
await verifySignatureBestEffort({
|
|
571
|
+
archivePath,
|
|
572
|
+
version,
|
|
573
|
+
tempDir,
|
|
574
|
+
quiet,
|
|
575
|
+
env,
|
|
576
|
+
});
|
|
236
577
|
await ensureDirectory(extractDir);
|
|
237
578
|
extractArchive(archivePath, extractDir);
|
|
238
579
|
|
|
@@ -268,6 +609,25 @@ export async function runTerrainCli(argv = process.argv.slice(2)) {
|
|
|
268
609
|
return;
|
|
269
610
|
}
|
|
270
611
|
|
|
612
|
+
// Check for a recorded install failure before attempting a silent
|
|
613
|
+
// retry. If `npm install` failed to fetch/verify the binary, the
|
|
614
|
+
// marker file records the original error; surface it verbatim
|
|
615
|
+
// instead of pretending nothing happened.
|
|
616
|
+
const marker = await readInstallFailureMarker();
|
|
617
|
+
if (marker && !existsSync(installedBinaryPath(rootDir))) {
|
|
618
|
+
throw new Error(
|
|
619
|
+
'Terrain binary is not installed.\n\n' +
|
|
620
|
+
`Recorded install failure (${marker.timestamp}, ${marker.platform}, v${marker.version}):\n` +
|
|
621
|
+
` ${marker.message}\n\n` +
|
|
622
|
+
'Resolve the underlying issue, then either:\n' +
|
|
623
|
+
' - Re-run `npm install -g mapterrain` after installing cosign\n' +
|
|
624
|
+
' - Set TERRAIN_INSTALLER_ALLOW_MISSING_COSIGN=1 to fall back to\n' +
|
|
625
|
+
' checksum-only verification, or\n' +
|
|
626
|
+
' - Set TERRAIN_INSTALLER_SKIP_VERIFY=1 to skip verification entirely.\n\n' +
|
|
627
|
+
'Marker file: ~/.terrain/install-failure.log'
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
271
631
|
let binaryPath;
|
|
272
632
|
try {
|
|
273
633
|
binaryPath = await ensureTerrainBinary({ rootDir });
|
|
@@ -287,6 +647,9 @@ export async function runTerrainCli(argv = process.argv.slice(2)) {
|
|
|
287
647
|
);
|
|
288
648
|
}
|
|
289
649
|
|
|
650
|
+
// First successful run after a failed install: clear the marker.
|
|
651
|
+
await clearInstallFailureMarker();
|
|
652
|
+
|
|
290
653
|
await runBinary(binaryPath, argv);
|
|
291
654
|
}
|
|
292
655
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mapterrain",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Terrain test intelligence CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,10 +23,11 @@
|
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
25
|
"postinstall": "node bin/postinstall.js",
|
|
26
|
-
"test": "node scripts/verify-pack.js",
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"format
|
|
26
|
+
"test": "npm run test:unit && node scripts/verify-pack.js",
|
|
27
|
+
"test:unit": "node --test scripts/test-installer-marker.mjs scripts/test-release-sanity.mjs",
|
|
28
|
+
"lint": "eslint \"bin/*.js\" \"scripts/*.js\" \"scripts/*.mjs\"",
|
|
29
|
+
"format": "prettier --write \"bin/*.js\" \"scripts/*.js\" \"scripts/*.mjs\"",
|
|
30
|
+
"format:check": "prettier --check \"bin/*.js\" \"scripts/*.js\" \"scripts/*.mjs\"",
|
|
30
31
|
"start": "node bin/terrain-cli.js",
|
|
31
32
|
"convert": "node bin/terrain-cli.js convert",
|
|
32
33
|
"lint-staged": "lint-staged",
|
|
@@ -81,4 +82,4 @@
|
|
|
81
82
|
"node": ">=22.0.0"
|
|
82
83
|
},
|
|
83
84
|
"sideEffects": false
|
|
84
|
-
}
|
|
85
|
+
}
|