trooth 0.4.4 → 0.5.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 +105 -39
- package/bin/lib/declarations.mjs +306 -0
- package/bin/lib/hcl.mjs +294 -0
- package/bin/trooth.mjs +393 -348
- package/npm-shrinkwrap.json +37 -0
- package/package.json +9 -5
package/README.md
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
The Trooth Network from your terminal. Published on npm as **`trooth`**.
|
|
4
4
|
|
|
5
|
-
Trooth operates the Trooth Network: one public,
|
|
5
|
+
Trooth operates the Trooth Network: one public, machine-readable record per company, carrying its identity, products and demos, commercial terms, domain and marketing links, people, documents, security and privacy posture, AI practices, procurement terms and relationships. It is Trooth's only product and it is free. Trooth signs one object in that record, the witness statement for a reading Trooth took of the company's public surface. The rest of the profile, including what the company declares about itself, is not signed.
|
|
6
6
|
|
|
7
7
|
DNS says where a company is. A TLS certificate says the connection is authentic. The Trooth Network says who the company is and what it does with your data.
|
|
8
8
|
|
|
9
9
|
This CLI is the terminal interface to that record. It does two things:
|
|
10
10
|
|
|
11
|
-
- `trooth check <domain>` reads a company's published record from the public Network. No key
|
|
11
|
+
- `trooth check <domain>` reads a company's published record from the public Network. No key and no account. It sends one request to `api.trooth.co` with **the domain you ask about in the request URL**, plus what every web request carries: your IP address and a `trooth-cli/<version>` user agent. Trooth's servers can therefore see which domain you looked up; [trooth.co/privacy](https://trooth.co/privacy) and the [retention schedule](https://trooth.co/retention) say what is kept and for how long. Nothing else about you or your machine is sent.
|
|
12
12
|
- `trooth lint [path]` reads what your own infrastructure declares and prints those declarations as facts. Entirely local: it opens files and opens no sockets.
|
|
13
13
|
|
|
14
14
|
**Trooth witnesses and dates facts. It does not grade, rate or rank anyone.** The CLI prints counts, reported apart, and never adds them into one number.
|
|
@@ -24,20 +24,20 @@ npm install -g trooth
|
|
|
24
24
|
trooth --version
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
Node 18 or newer, because the binary uses the built-in `fetch`.
|
|
27
|
+
Node 18 or newer, because the binary uses the built-in `fetch`. One dependency, pinned to an exact version and locked in `npm-shrinkwrap.json`: [`yaml`](https://www.npmjs.com/package/yaml), the maintained YAML parser, which itself has none. It is there so Kubernetes files are parsed rather than pattern-matched. Install with `--ignore-scripts` if you like; neither package has an install script.
|
|
28
28
|
|
|
29
29
|
## Commands
|
|
30
30
|
|
|
31
31
|
| Command | What it does |
|
|
32
32
|
|---|---|
|
|
33
|
-
| `trooth check <domain>` | Reads a company's record from the live Network and prints
|
|
34
|
-
| `trooth lint [path]` | Reads the infrastructure the given directory declares and prints those declarations
|
|
33
|
+
| `trooth check <domain>` | Reads a company's record from the live Network and prints its listing and evidence state, the date of the witnessed reading and of first publication, the live-probe and self-attestation counts, the badge id, the id of the signing key, and the three newest events in its ledger, newest first. `--json` adds the signature itself and every event the feed returns. It does not check the signature. |
|
|
34
|
+
| `trooth lint [path]` | Reads the infrastructure the given directory declares and prints those declarations, a coverage report and an aggregate digest of the counts. Local and offline. `path` defaults to `.`. |
|
|
35
35
|
| `trooth --help` | Help. Also `-h` and `help`. |
|
|
36
36
|
| `trooth --version` | Version. Also `-v` and `version`. |
|
|
37
37
|
|
|
38
38
|
## Flags
|
|
39
39
|
|
|
40
|
-
`--json` is the
|
|
40
|
+
`--json` is the flag both commands take; `lint` also takes `--allow-incomplete`. With `--json`, stdout carries exactly one JSON document and nothing else, and every diagnostic goes to stderr. On an error the document is `{"ok": false, "error": "...", "exit": N}`; a non-2xx response adds `http_status`, and `lint` with nothing to read adds `files_opened`. `--help` and `--version` print plain text whether or not `--json` is given.
|
|
41
41
|
|
|
42
42
|
Any other flag is a usage error. The message names the flag, and for a `--` flag given to `check` or `lint` it also lists the ones that exist.
|
|
43
43
|
|
|
@@ -45,12 +45,14 @@ Any other flag is a usage error. The message names the flag, and for a `--` flag
|
|
|
45
45
|
|
|
46
46
|
| Code | Meaning |
|
|
47
47
|
|---|---|
|
|
48
|
-
| 0 |
|
|
49
|
-
| 1 |
|
|
50
|
-
| 2 | Usage error: a missing argument, an unknown flag or command, or a path that does not exist. |
|
|
51
|
-
| 3 | Trooth
|
|
48
|
+
| 0 | `check`: listed, and the record carries a dated reading Trooth witnessed. `lint`: a complete read of at least one declaration. Help and version also exit 0. |
|
|
49
|
+
| 1 | `check`: not listed, or revoked. `lint`: nothing to read. |
|
|
50
|
+
| 2 | Usage error: a missing argument, an unknown flag or command, input that is not one domain, or a path that does not exist. |
|
|
51
|
+
| 3 | Service or contract error: Trooth unreachable or slower than 15 seconds, a status other than 2xx or the documented not-listed 404, a body over 1 MiB, a body that is not JSON, or a record for a different domain. Never an answer about a company. An unexpected failure inside the CLI also exits 3. |
|
|
52
|
+
| 4 | `lint`: the read was incomplete. A selected file was over the size limit, did not parse or could not be read, or the walk stopped at its file limit. `--allow-incomplete` reports the same and exits 0 (or 1 when nothing was read). New in 0.5.0. |
|
|
53
|
+
| 5 | `check`: the company is listed, but its record carries no reading this CLI can confirm Trooth witnessed. New in 0.5.0. |
|
|
52
54
|
|
|
53
|
-
A company with no record and a Network that could not be read are different answers, so they exit differently. A pipeline can tell them apart without parsing prose, and a Trooth outage never reads as a company with no record.
|
|
55
|
+
A company with no record, a listed company without a witnessed reading, and a Network that could not be read are different answers, so they exit differently. A pipeline can tell them apart without parsing prose, and a Trooth outage never reads as a company with no record.
|
|
54
56
|
|
|
55
57
|
## `trooth check`
|
|
56
58
|
|
|
@@ -61,30 +63,47 @@ trooth check trooth.co
|
|
|
61
63
|
Example output. The values are illustrative; the shape is what the binary prints.
|
|
62
64
|
|
|
63
65
|
```
|
|
64
|
-
Trooth Network // public
|
|
66
|
+
Trooth Network // public record · read-only //
|
|
65
67
|
Trooth, LLC trooth.co
|
|
66
|
-
Listing state: listed
|
|
68
|
+
Listing state: listed; Trooth witnessed a reading reading dated 2026-09-26 first published 2026-08-01
|
|
67
69
|
|
|
68
|
-
Live probes: 65 read;
|
|
70
|
+
Live probes: 65 read; 63 as expected
|
|
69
71
|
Self-attestations: 35 asked; 27 attested
|
|
70
72
|
Live probes are readings Trooth took itself, from the company's public surface.
|
|
71
73
|
Self-attestations are what the company attested about itself; Trooth records them
|
|
72
74
|
and did not witness them. The two are reported apart and never added into one number.
|
|
73
|
-
Badge rw_... Key
|
|
75
|
+
Badge rw_... Key trooth-master-2026-09
|
|
74
76
|
|
|
75
77
|
Latest ledger events, newest first
|
|
76
|
-
• 2026-
|
|
77
|
-
• 2026-
|
|
78
|
+
• 2026-09-26 live probes re-read
|
|
79
|
+
• 2026-09-26 reading completed
|
|
78
80
|
• 2026-08-01 record published to the Trooth Network
|
|
79
81
|
--json carries the whole ledger, with the feed's own wording for each event.
|
|
80
82
|
|
|
83
|
+
This command did not check the record's signature. The signature covers the
|
|
84
|
+
reading, not every fact on the company's profile. To check it yourself: https://trooth.co/docs/verifiable-evidence
|
|
81
85
|
A dated, point-in-time record. Trooth issues no verdict and no single number.
|
|
82
86
|
Full record: https://trooth.co/network/trooth.co · Signing keys: https://api.trooth.co/public/keys
|
|
83
87
|
```
|
|
84
88
|
|
|
85
|
-
The line that begins `Listing state:` gives the listing
|
|
89
|
+
The line that begins `Listing state:` gives the listing and evidence state, decided from the record's own fields and never from a matching name:
|
|
86
90
|
|
|
87
|
-
|
|
91
|
+
| `state` in `--json` | Printed | Exit | Meaning |
|
|
92
|
+
|---|---|---|---|
|
|
93
|
+
| `listed_witnessed` | listed; Trooth witnessed a reading | 0 | The record carries a dated reading with at least one live probe read. |
|
|
94
|
+
| `listed_not_witnessed` | listed; no reading witnessed | 5 | The record says it was not witnessed, or its reading read no probe. |
|
|
95
|
+
| `listed_evidence_unknown` | listed; the reading could not be read from this record | 5 | Listed, with no reading this CLI can interpret. |
|
|
96
|
+
| `revoked` | revoked | 1 | The record says it was revoked or withdrawn. |
|
|
97
|
+
| `not_listed` | not listed in the Trooth Network's public feed | 1 | The documented not-listed answer: a JSON 404 whose body says `listed: false`. |
|
|
98
|
+
| `service_error` | (an error on stderr) | 3 | Anything else. Never read as an answer about the company. |
|
|
99
|
+
|
|
100
|
+
The two counts use the same form as the record page on trooth.co: live probes (how many were read at the last reading, and how many of those returned the expected result) and self-attestations (how many the company was asked for, and how many it attested). Live probes are readings Trooth took itself; self-attestations are the company's statements about itself, which Trooth records and does not witness. The counts come from the directory record; they are the counts of the signed reading, and not any other total a page may show.
|
|
101
|
+
|
|
102
|
+
The events section prints the three newest entries in the record's ledger, newest first, by timestamp; entries with the same timestamp keep the feed's order, the later entry first. Known event types get a plain label: `scan_completed` prints as "reading completed", `standing_published` as "record published to the Trooth Network" and `rewitnessed` as "live probes re-read". A type the CLI does not know prints with its underscores turned into spaces. `--json` carries every event the feed returns, with its type and detail exactly as the feed has them.
|
|
103
|
+
|
|
104
|
+
**What `check` accepts.** A bare domain or a URL. It is parsed with the standard URL parser, so case, a trailing dot, the default port (80 or 443), a path and an internationalized name (converted to its ASCII form) normalize to one domain, and a leading `www.` is dropped: `https://Trooth.co:443/path` reads `trooth.co`. A URL with a user name or password, a non-default port, an IP address, a scheme other than http or https, or a name with no dot is a usage error (exit 2) rather than a guess.
|
|
105
|
+
|
|
106
|
+
**How `check` asks.** One `GET /directory/api/vendors/<domain>`, with a 15-second deadline (`TROOTH_TIMEOUT_MS` changes it), a 1 MiB limit on the body, redirects refused, a JSON content type required, and at most one retry, only after a connection failure or a 502, 503 or 504. Up to 0.4.4, a plain-text 404 made the CLI download the whole directory list and search it; the single-record route has been served since 2026-09-26, and 0.5.0 has no fallback, so an unexpected answer is a service error, never "not listed".
|
|
88
107
|
|
|
89
108
|
For scripting:
|
|
90
109
|
|
|
@@ -96,6 +115,7 @@ trooth check trooth.co --json
|
|
|
96
115
|
{
|
|
97
116
|
"domain": "trooth.co",
|
|
98
117
|
"listed": true,
|
|
118
|
+
"state": "listed_witnessed",
|
|
99
119
|
"company_name": "Trooth, LLC",
|
|
100
120
|
"witnessed_at": "2026-08-30T00:00:00Z",
|
|
101
121
|
"first_published_at": "2026-08-01T00:00:00Z",
|
|
@@ -109,19 +129,21 @@ trooth check trooth.co --json
|
|
|
109
129
|
{ "type": "standing_published", "at": "2026-08-30T00:00:00Z", "detail": "point-in-time · published to the Trooth Network" }
|
|
110
130
|
],
|
|
111
131
|
"receipt_signature": "...",
|
|
112
|
-
"authority_key_id": "
|
|
132
|
+
"authority_key_id": "trooth-master-2026-09",
|
|
133
|
+
"signature_checked": false,
|
|
113
134
|
"verify_keys": "https://api.trooth.co/public/keys",
|
|
135
|
+
"verify_how": "https://trooth.co/docs/verifiable-evidence",
|
|
114
136
|
"record_url": "https://trooth.co/network/trooth.co"
|
|
115
137
|
}
|
|
116
138
|
```
|
|
117
139
|
|
|
118
|
-
In `probes`, `total` is how many live probes were read at the last reading and `passed` (the API's field name) is how many returned the expected result. In `attested`, `total` is how many declarations were asked for and `passed` is how many the company attested. `witnessed_at` is the date of the
|
|
140
|
+
In `probes`, `total` is how many live probes were read at the last reading and `passed` (the API's field name) is how many returned the expected result. In `attested`, `total` is how many declarations were asked for and `passed` is how many the company attested. `witnessed_at` is the date of the witnessed reading, and is `null` unless `state` is `listed_witnessed`; it is never a profile edit time. `events` is the whole ledger, oldest first, in the feed's order. Each event's `detail` is written when the event is stored and is not rewritten afterward, so older and newer events of the same type can be worded differently.
|
|
119
141
|
|
|
120
142
|
`category` and `description` are added when the record carries them. Those are the only fields `check --json` emits: anything else the feed happens to carry is dropped on the way out, so a script written against this shape keeps working. A field whose name contains `score`, `tier`, `grade`, `rank`, `rating`, `level` or `percent`, or is `rate`, is dropped no matter what the feed sends.
|
|
121
143
|
|
|
122
|
-
A company with no record exits 1 and emits `{"domain": "...", "listed": false, "record_url": "..."}`. That is not a judgment. It means the Network's public feed carries no record for that domain. A company gets a record at [trooth.co/get-started](https://trooth.co/get-started), free.
|
|
144
|
+
A company with no record exits 1 and emits `{"domain": "...", "listed": false, "state": "not_listed", "record_url": "..."}`. That is not a judgment. It means the Network's public feed carries no record for that domain. A company gets a record at [trooth.co/get-started](https://trooth.co/get-started), free.
|
|
123
145
|
|
|
124
|
-
`receipt_signature` is Trooth's Ed25519 signature and `authority_key_id` names the key that made it; the public keys are listed at [trooth.co/verify/keys](https://trooth.co/verify/keys).
|
|
146
|
+
`receipt_signature` is Trooth's Ed25519 signature over the directory receipt and `authority_key_id` names the key that made it; the public keys are listed at [trooth.co/verify/keys](https://trooth.co/verify/keys). `signature_checked` is always `false`: this CLI does not check any signature, and nothing it prints should be read as a checked signature. The object whose exact signed bytes Trooth publishes is the reading's witness statement, which is not part of this feed; [trooth.co/docs/verifiable-evidence](https://trooth.co/docs/verifiable-evidence) shows how to fetch and check it offline. That signature covers the reading. It does not cover the company's own declarations, which are not signed by anyone.
|
|
125
147
|
|
|
126
148
|
## `trooth lint`
|
|
127
149
|
|
|
@@ -131,7 +153,7 @@ A company with no record exits 1 and emits `{"domain": "...", "listed": false, "
|
|
|
131
153
|
trooth lint ./infra
|
|
132
154
|
```
|
|
133
155
|
|
|
134
|
-
Output for the small fixture in this repository (`trooth lint tests/fixtures/infra`, trooth 0.
|
|
156
|
+
Output for the small fixture in this repository (`trooth lint tests/fixtures/infra`, trooth 0.5.0):
|
|
135
157
|
|
|
136
158
|
```
|
|
137
159
|
trooth lint // local · offline · declarations only //
|
|
@@ -141,7 +163,10 @@ terraform 1 · kubernetes 1
|
|
|
141
163
|
Declared
|
|
142
164
|
Regions and zones us-east-1
|
|
143
165
|
Storage declarations 1
|
|
144
|
-
|
|
166
|
+
declaring encryption 1
|
|
167
|
+
declaring encryption off 0
|
|
168
|
+
declaring nothing about encryption 0
|
|
169
|
+
set by an unresolved expression 0
|
|
145
170
|
Logging declarations 0
|
|
146
171
|
Identity declarations 0
|
|
147
172
|
Open to any address (0.0.0.0/0, ::/0) 1
|
|
@@ -152,17 +177,20 @@ Most declared resource types
|
|
|
152
177
|
1 aws_s3_bucket
|
|
153
178
|
1 aws_s3_bucket_server_side_encryption_configuration
|
|
154
179
|
1 aws_security_group_rule
|
|
180
|
+
1 Deployment
|
|
155
181
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
182
|
+
Coverage complete
|
|
183
|
+
2 selected: 2 read, 0 not declarations, 0 excluded, 0 skipped, 0 invalid, 0 unreadable.
|
|
184
|
+
|
|
185
|
+
Facts digest sha256:2ba1da28be9e97e1e8be1f7e41641288bc632192c062ba1fbd563350448fd5a8
|
|
186
|
+
A SHA-256 over the counts above, in canonical form. It is an aggregate: two different
|
|
187
|
+
trees with the same counts share it. It does not identify your files, your repository
|
|
188
|
+
or a deployment.
|
|
160
189
|
|
|
161
190
|
How this was read
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
Dockerfiles for regions, open addresses, public markers and credential literals only.
|
|
191
|
+
Every file is parsed; a file that does not parse is reported as invalid, not read.
|
|
192
|
+
Comments count for nothing. Nothing is evaluated: a setting that depends on a variable,
|
|
193
|
+
a local, a module or a function is reported as unresolved. Dockerfiles: ENV and ARG only.
|
|
166
194
|
|
|
167
195
|
Counts of what the files declare. Not a judgment: a public load balancer is
|
|
168
196
|
supposed to be public. Trooth issues no verdict here and checks nothing against
|
|
@@ -172,11 +200,35 @@ Publish what you choose on your record at https://trooth.co/dashboard.
|
|
|
172
200
|
|
|
173
201
|
The bucket's encryption configuration is a setting on the bucket, not a second store, so the fixture has one storage declaration, and that one declares encryption.
|
|
174
202
|
|
|
175
|
-
|
|
203
|
+
**How each source is read.** Every file is parsed, and a file that does not parse is reported as invalid, never as read.
|
|
204
|
+
|
|
205
|
+
| Source | Parsed with | Unit |
|
|
206
|
+
|---|---|---|
|
|
207
|
+
| `.tf` | The HCL reader in `bin/lib/hcl.mjs` (comments dropped, heredocs and templates understood) | One `resource` block, at any indentation |
|
|
208
|
+
| `.tf.json` | `JSON.parse` | One resource |
|
|
209
|
+
| `terraform show -json` plan | `JSON.parse` | One planned managed resource; data sources are skipped |
|
|
210
|
+
| Kubernetes YAML (`apiVersion` and `kind`) | The `yaml` package, strict mode | One document, classified by `kind` |
|
|
211
|
+
| Dockerfile | `ENV` and `ARG` instructions only | The file |
|
|
212
|
+
|
|
213
|
+
Nothing is evaluated. A setting that depends on a variable, a local, a module output or a function is reported as unresolved and is never counted as declared. Storage, logging and identity are counted by a resource's type or a document's kind, never by the words around it. Counts are of parsed values, not lines: two credential literals on one minified line are two.
|
|
214
|
+
|
|
215
|
+
**Encryption, per store.** Each storage declaration is counted in exactly one of five states:
|
|
176
216
|
|
|
177
|
-
|
|
217
|
+
| Field | State |
|
|
218
|
+
|---|---|
|
|
219
|
+
| `storage_declaring_encryption` | An explicit `true`, a named key (a literal or a reference to a key resource such as `aws_kms_key.main.arn`), or an encryption block, in the store or in a setting resource that refers to it |
|
|
220
|
+
| `storage_declaring_encryption_off` | An explicit `false`. It wins over any other signal |
|
|
221
|
+
| `storage_encryption_not_declared` | Nothing about encryption. A commented-out setting is nothing |
|
|
222
|
+
| `storage_encryption_unresolved` | Decided by a variable, a local or another expression lint does not evaluate |
|
|
223
|
+
| `storage_encryption_unsupported` | A value lint does not interpret, such as a number where a switch belongs |
|
|
224
|
+
|
|
225
|
+
A declaration is what a file says, not what a cloud account does: a provider default, an account-wide setting or a module can encrypt a store whose file declares nothing, and lint cannot see any of those.
|
|
226
|
+
|
|
227
|
+
**Coverage.** Every file the walk selects (`.tf`, `.tf.json`, `.json`, `.yaml`, `.yml`, Dockerfiles) ends in exactly one bucket: read; not applicable (a JSON or YAML file that is not a plan or a manifest); excluded by a stated rule (a templated manifest containing `{{ }}`, which has to be rendered first); skipped (over 4 MiB); invalid (did not parse); or unreadable (a permission or I/O error). The walk visits directories depth first with entries sorted by name, skips `node_modules`, `.git`, `.terraform` and the other build and dependency directories listed in the source, and stops at 5,000 selected files. A read with anything skipped, invalid or unreadable, or a truncated walk, is **incomplete**: the output says so, `--json` carries a `coverage` object with each count and a `coverage_details` object listing up to 50 paths per bucket with the reason (never file contents), and the exit code is 4 unless you pass `--allow-incomplete`.
|
|
228
|
+
|
|
229
|
+
Nothing leaves the machine. No line, no code and no value is printed or transmitted, only the path you gave it, counts, resource type names, region strings and, for files that could not be read, their paths and the reason. The credential count is a count: the literal it found is never shown.
|
|
178
230
|
|
|
179
|
-
The digest is a SHA-256 over the `facts` object in canonical form.
|
|
231
|
+
**The facts digest** (`facts_digest`, also emitted as `digest` until 0.6) is a SHA-256 over the `facts` object in canonical form. It is an aggregate: two different trees with the same counts produce the same digest, and a release that changes how something is counted changes the digest of the same tree. It does not identify file contents, a repository, a commit or a deployment, and it is not an attestation of any of them. It is useful for noticing that the counts changed between two runs of the same version.
|
|
180
232
|
|
|
181
233
|
```bash
|
|
182
234
|
# Keep the fact document as a build artifact.
|
|
@@ -188,11 +240,13 @@ trooth lint --json > trooth-lint.json
|
|
|
188
240
|
The same `lint`, as a step, is [`troothllc/trooth-action`](https://github.com/troothllc/trooth-action). It is advisory by default: it writes what your infrastructure declares to the job summary and does not fail your workflow over anything it read unless you opt in to one of two gates. It does fail the step when it could not read at all: a path that does not exist, or a CLI it could not install or start.
|
|
189
241
|
|
|
190
242
|
```yaml
|
|
191
|
-
- uses: troothllc/trooth-action
|
|
243
|
+
- uses: troothllc/trooth-action@<full commit SHA> # v1
|
|
192
244
|
with:
|
|
193
245
|
path: ./infra
|
|
194
246
|
```
|
|
195
247
|
|
|
248
|
+
Pin the action by its full commit SHA. A tag can be moved; a commit cannot. The action installs one exact `trooth` version with install scripts disabled and refuses a version input that is a range, a tag or a URL.
|
|
249
|
+
|
|
196
250
|
That repository's README documents the inputs and outputs.
|
|
197
251
|
|
|
198
252
|
## Environment
|
|
@@ -200,6 +254,8 @@ That repository's README documents the inputs and outputs.
|
|
|
200
254
|
| Variable | Effect |
|
|
201
255
|
|---|---|
|
|
202
256
|
| `TROOTH_API` | Base URL for `check`. Defaults to `https://api.trooth.co`. `lint` ignores it, because `lint` makes no requests. |
|
|
257
|
+
| `TROOTH_TIMEOUT_MS` | The deadline for each `check` request. Defaults to 15000; the minimum is 1000. |
|
|
258
|
+
| `TROOTH_LINT_MAX_FILES` | The number of selected files after which `lint` stops walking and reports the read as truncated. Defaults to 5000. |
|
|
203
259
|
| `NO_COLOR` | Disables ANSI color. Color is already off when stdout is not a TTY. |
|
|
204
260
|
|
|
205
261
|
There is no API key. The binary asks for no credential of any kind and has no write path.
|
|
@@ -214,9 +270,19 @@ https://api.trooth.co/public/mcp
|
|
|
214
270
|
|
|
215
271
|
Four read-only tools, public data, no key. The pattern is written up at [trooth.co/docs/agents](https://trooth.co/docs/agents).
|
|
216
272
|
|
|
273
|
+
## Changed in 0.5.0
|
|
274
|
+
|
|
275
|
+
Breaking where the old behavior overstated what was read.
|
|
276
|
+
|
|
277
|
+
- `lint` parses every format instead of matching patterns. `encrypted = false` on one line, `false` followed by a comment, a commented-out setting and a setting that depends on a variable no longer count as declaring encryption; each store is counted in one of five encryption states. Comments count for nothing, indentation no longer changes a count, and a minified JSON file counts the same as a pretty one.
|
|
278
|
+
- `lint` reports coverage and exits 4 on an incomplete read: a malformed file, a file over the size limit, an unreadable file or a truncated walk. 0.4.4 read a malformed file as a success and an oversized one as "nothing to read".
|
|
279
|
+
- The digest is renamed `facts_digest` and described as what it is, an aggregate of the counts. `digest` carries the same value until 0.6.
|
|
280
|
+
- `check` decides the evidence state from the record's fields: a listed record is no longer printed as witnessed unless it carries a witnessed reading, and exit 5 means listed but not witnessed. The full-list fallback is gone, so an unexpected answer is a service error, never "not listed". Requests have a deadline, a body limit, a content-type check and one bounded retry. Input is normalized with the URL parser.
|
|
281
|
+
- `check` says that it did not check the signature, and the help and this README say that `check` sends the domain you ask about.
|
|
282
|
+
|
|
217
283
|
## Changed in 0.4.4
|
|
218
284
|
|
|
219
|
-
- `check`
|
|
285
|
+
- `check` asked the Trooth Network for the one record it needs (`/directory/api/vendors/<domain>`) instead of downloading the whole directory list, and fell back to the list when the API did not serve that path. 0.5.0 removes the fallback.
|
|
220
286
|
|
|
221
287
|
## Changed in 0.4.3
|
|
222
288
|
|
|
@@ -238,7 +304,7 @@ The Trooth Network is Trooth's only product. This CLI, the public API and the MC
|
|
|
238
304
|
|
|
239
305
|
## Security
|
|
240
306
|
|
|
241
|
-
Report a vulnerability through the [Vulnerability Disclosure Policy](https://trooth.co/security/vulnerability-disclosure-policy). Nothing in this CLI takes a credential, so there is no key to leak from it.
|
|
307
|
+
Report a vulnerability through the [Vulnerability Disclosure Policy](https://trooth.co/security/vulnerability-disclosure-policy). Nothing in this CLI takes a credential, so there is no key to leak from it. Reproduce the published tarball with `npm pack` at the tagged commit and compare its SHA-512 with the `integrity` value `npm view trooth@<version> dist.integrity` prints.
|
|
242
308
|
|
|
243
309
|
## Links
|
|
244
310
|
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// What a declaration file says, read from its parsed tree.
|
|
2
|
+
// Copyright 2025-2026 Trooth, LLC. Apache-2.0.
|
|
3
|
+
//
|
|
4
|
+
// Every supported format is parsed first (HCL by ./hcl.mjs, JSON by
|
|
5
|
+
// JSON.parse, YAML by the maintained `yaml` package) and then read here from
|
|
6
|
+
// the tree, so a comment can never count, indentation cannot change a count,
|
|
7
|
+
// a minified file counts the same as a pretty one, and a file that does not
|
|
8
|
+
// parse is reported as invalid instead of being read.
|
|
9
|
+
//
|
|
10
|
+
// An unresolved expression ({ $expr: "..." }) is never promoted to a value.
|
|
11
|
+
// Where it decides a fact, the fact is reported as UNRESOLVED.
|
|
12
|
+
|
|
13
|
+
import { parseHcl, bodyToTree, HclParseError } from './hcl.mjs';
|
|
14
|
+
import { parseAllDocuments } from 'yaml';
|
|
15
|
+
|
|
16
|
+
export { HclParseError };
|
|
17
|
+
|
|
18
|
+
export const isExpr = (v) => !!v && typeof v === 'object' && !Array.isArray(v) && typeof v.$expr === 'string';
|
|
19
|
+
|
|
20
|
+
/* ------------------------------------------------------------ parsing ---- */
|
|
21
|
+
|
|
22
|
+
export class InvalidDeclaration extends Error {
|
|
23
|
+
constructor(message) { super(message); this.name = 'InvalidDeclaration'; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A string in Terraform's JSON syntax that holds a template is an expression. */
|
|
27
|
+
function tfJsonValue(v) {
|
|
28
|
+
if (typeof v === 'string') {
|
|
29
|
+
if (/[$%]\{/.test(v)) return { $expr: v };
|
|
30
|
+
return v;
|
|
31
|
+
}
|
|
32
|
+
if (Array.isArray(v)) return v.map(tfJsonValue);
|
|
33
|
+
if (v && typeof v === 'object') {
|
|
34
|
+
const o = {};
|
|
35
|
+
for (const [k, x] of Object.entries(v)) o[k] = tfJsonValue(x);
|
|
36
|
+
return o;
|
|
37
|
+
}
|
|
38
|
+
return v;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const each = (v, fn) => { if (Array.isArray(v)) v.forEach((x) => each(x, fn)); else if (v && typeof v === 'object') fn(v); };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One file's units. Each unit: { type, name, address, tree, typed }.
|
|
45
|
+
* `type` is a resource type or Kubernetes kind (null for everything else in the
|
|
46
|
+
* file), `tree` is the parsed value, `address` is how other resources refer to
|
|
47
|
+
* it. Throws InvalidDeclaration when the file does not parse.
|
|
48
|
+
*/
|
|
49
|
+
export function unitsOf(kind, text) {
|
|
50
|
+
if (kind === 'terraform') {
|
|
51
|
+
let items;
|
|
52
|
+
try { items = parseHcl(text); } catch (e) { throw new InvalidDeclaration(e.message); }
|
|
53
|
+
const units = [];
|
|
54
|
+
const rest = [];
|
|
55
|
+
for (const it of items) {
|
|
56
|
+
if (it.kind === 'block' && it.type === 'resource') {
|
|
57
|
+
if (it.labels.length !== 2) throw new InvalidDeclaration(`line ${it.line}: a resource block needs a type and a name`);
|
|
58
|
+
const [type, name] = it.labels;
|
|
59
|
+
units.push({ type, name, address: `${type}.${name}`, tree: bodyToTree(it.body), typed: true });
|
|
60
|
+
} else rest.push(it);
|
|
61
|
+
}
|
|
62
|
+
if (rest.length) units.push({ type: null, name: null, address: null, tree: bodyToTree(rest), typed: false });
|
|
63
|
+
return units;
|
|
64
|
+
}
|
|
65
|
+
if (kind === 'terraform-json') {
|
|
66
|
+
let doc;
|
|
67
|
+
try { doc = JSON.parse(text); } catch (e) { throw new InvalidDeclaration(`not valid JSON: ${e.message}`); }
|
|
68
|
+
if (!doc || typeof doc !== 'object') throw new InvalidDeclaration('a .tf.json file must hold a JSON object');
|
|
69
|
+
const units = [];
|
|
70
|
+
each(doc, (top) => each(top.resource, (byType) => {
|
|
71
|
+
for (const [type, byName] of Object.entries(byType)) {
|
|
72
|
+
if (!/^[a-z0-9_]+$/.test(type)) continue;
|
|
73
|
+
each(byName, (names) => {
|
|
74
|
+
for (const [name, body] of Object.entries(names)) {
|
|
75
|
+
each(body, (b) => units.push({ type, name, address: `${type}.${name}`, tree: tfJsonValue(b), typed: true }));
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}));
|
|
80
|
+
if (!Array.isArray(doc)) {
|
|
81
|
+
const rest = { ...doc }; delete rest.resource;
|
|
82
|
+
if (Object.keys(rest).length) units.push({ type: null, name: null, address: null, tree: tfJsonValue(rest), typed: false });
|
|
83
|
+
}
|
|
84
|
+
return units;
|
|
85
|
+
}
|
|
86
|
+
if (kind === 'terraform-plan') {
|
|
87
|
+
let doc;
|
|
88
|
+
try { doc = JSON.parse(text); } catch (e) { throw new InvalidDeclaration(`not valid JSON: ${e.message}`); }
|
|
89
|
+
const units = [];
|
|
90
|
+
const walkModule = (m) => {
|
|
91
|
+
if (!m || typeof m !== 'object') return;
|
|
92
|
+
for (const r of Array.isArray(m.resources) ? m.resources : []) {
|
|
93
|
+
if (r && r.mode !== 'data' && typeof r.type === 'string') {
|
|
94
|
+
units.push({ type: r.type, name: String(r.name ?? ''), address: r.address || `${r.type}.${r.name}`, tree: r.values ?? {}, typed: true, plan: true });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const c of Array.isArray(m.child_modules) ? m.child_modules : []) walkModule(c);
|
|
98
|
+
};
|
|
99
|
+
if (doc && doc.planned_values && doc.planned_values.root_module) walkModule(doc.planned_values.root_module);
|
|
100
|
+
else {
|
|
101
|
+
for (const rc of Array.isArray(doc && doc.resource_changes) ? doc.resource_changes : []) {
|
|
102
|
+
const after = rc && rc.change ? rc.change.after : null;
|
|
103
|
+
if (rc && rc.mode !== 'data' && typeof rc.type === 'string' && after) {
|
|
104
|
+
units.push({ type: rc.type, name: String(rc.name ?? ''), address: rc.address || `${rc.type}.${rc.name}`, tree: after, typed: true, plan: true });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return units;
|
|
109
|
+
}
|
|
110
|
+
if (kind === 'kubernetes') {
|
|
111
|
+
const docs = parseAllDocuments(text, { strict: true, uniqueKeys: true, prettyErrors: false });
|
|
112
|
+
const list = Array.isArray(docs) ? docs : [docs];
|
|
113
|
+
const units = [];
|
|
114
|
+
for (const d of list) {
|
|
115
|
+
if (d.errors && d.errors.length) throw new InvalidDeclaration(`not valid YAML: ${d.errors[0].message.split('\n')[0]}`);
|
|
116
|
+
const v = d.toJS({ maxAliasCount: 100 });
|
|
117
|
+
if (v === null || v === undefined) continue;
|
|
118
|
+
if (typeof v !== 'object' || Array.isArray(v)) throw new InvalidDeclaration('a Kubernetes document must be a mapping');
|
|
119
|
+
const k = typeof v.kind === 'string' && /^[A-Za-z][A-Za-z0-9]*$/.test(v.kind) ? v.kind : null;
|
|
120
|
+
if (!k || typeof v.apiVersion !== 'string') throw new InvalidDeclaration('a document in a Kubernetes file has no apiVersion and kind');
|
|
121
|
+
units.push({ type: k, name: v.metadata && v.metadata.name ? String(v.metadata.name) : null, address: null, tree: v, typed: true });
|
|
122
|
+
}
|
|
123
|
+
return units;
|
|
124
|
+
}
|
|
125
|
+
if (kind === 'container') {
|
|
126
|
+
return [{ type: null, name: null, address: null, tree: dockerfileTree(text), typed: false }];
|
|
127
|
+
}
|
|
128
|
+
throw new Error(`unknown kind ${kind}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A Dockerfile's ENV and ARG settings as a tree. Comment lines and line
|
|
132
|
+
* continuations are handled; every other instruction declares nothing lint
|
|
133
|
+
* counts. A value that uses $ is unresolved. */
|
|
134
|
+
function dockerfileTree(text) {
|
|
135
|
+
const tree = {};
|
|
136
|
+
const logical = [];
|
|
137
|
+
let cur = '';
|
|
138
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
139
|
+
if (/^\s*#/.test(raw) && !cur) continue;
|
|
140
|
+
if (/\\\s*$/.test(raw)) { cur += raw.replace(/\\\s*$/, ' '); continue; }
|
|
141
|
+
logical.push(cur + raw); cur = '';
|
|
142
|
+
}
|
|
143
|
+
if (cur) logical.push(cur);
|
|
144
|
+
const val = (s) => {
|
|
145
|
+
let v = s;
|
|
146
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
|
147
|
+
return /\$/.test(v) ? { $expr: v } : v;
|
|
148
|
+
};
|
|
149
|
+
for (const l of logical) {
|
|
150
|
+
const m = /^\s*(ENV|ARG)\s+(.*)$/i.exec(l);
|
|
151
|
+
if (!m) continue;
|
|
152
|
+
const rest = m[2].trim();
|
|
153
|
+
const pairs = rest.match(/[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)/g);
|
|
154
|
+
if (pairs && pairs.join(' ').length >= rest.replace(/\s+/g, ' ').length - pairs.length) {
|
|
155
|
+
for (const p of pairs) { const i = p.indexOf('='); tree[p.slice(0, i)] = val(p.slice(i + 1)); }
|
|
156
|
+
} else {
|
|
157
|
+
const sp = /^([A-Za-z_][A-Za-z0-9_]*)(?:\s+(.*))?$/.exec(rest);
|
|
158
|
+
if (sp) tree[sp[1]] = sp[2] !== undefined ? val(sp[2].trim()) : { $expr: `ARG ${sp[1]}` };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return tree;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* ----------------------------------------------------------- reading ---- */
|
|
165
|
+
|
|
166
|
+
/** Every (key, value) pair in a tree, depth first. */
|
|
167
|
+
function* pairs(tree, parent = null) {
|
|
168
|
+
if (Array.isArray(tree)) { for (const x of tree) yield* pairs(x, parent); return; }
|
|
169
|
+
if (!tree || typeof tree !== 'object' || isExpr(tree)) return;
|
|
170
|
+
for (const [k, v] of Object.entries(tree)) {
|
|
171
|
+
yield [k, v, parent];
|
|
172
|
+
yield* pairs(v, k);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Every string (literal or expression source) in a tree. */
|
|
177
|
+
function* strings(tree) {
|
|
178
|
+
if (typeof tree === 'string') { yield tree; return; }
|
|
179
|
+
if (isExpr(tree)) { yield tree.$expr; return; }
|
|
180
|
+
if (Array.isArray(tree)) { for (const x of tree) yield* strings(x); return; }
|
|
181
|
+
if (tree && typeof tree === 'object') for (const v of Object.values(tree)) yield* strings(v);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const REGION_KEY = /^(region|location|availability_zone|aws_region|aws_default_region|zone)$/i;
|
|
185
|
+
const REGION_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{2,40}$/;
|
|
186
|
+
const SECRET_KEY = /(password|passwd|secret|api[_-]?key|access[_-]?key|token|private[_-]?key)/i;
|
|
187
|
+
const OPEN_CIDRS = new Set(['0.0.0.0/0', '::/0']);
|
|
188
|
+
|
|
189
|
+
export function regionsIn(tree) {
|
|
190
|
+
const found = [];
|
|
191
|
+
for (const [k, v] of pairs(tree)) if (REGION_KEY.test(k) && typeof v === 'string' && REGION_VALUE.test(v)) found.push(v);
|
|
192
|
+
return found;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** A credential literal: a secret-named key holding a literal string of at
|
|
196
|
+
* least eight characters. One count per key, wherever it sits in the file. */
|
|
197
|
+
export function credentialLiterals(tree) {
|
|
198
|
+
let n = 0;
|
|
199
|
+
for (const [k, v] of pairs(tree)) {
|
|
200
|
+
if (!SECRET_KEY.test(k)) continue;
|
|
201
|
+
if (typeof v === 'string' && v.length >= 8 && !/[$%]\{/.test(v)) n++;
|
|
202
|
+
}
|
|
203
|
+
return n;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function opensToAnyAddress(tree) {
|
|
207
|
+
for (const s of strings(tree)) if (OPEN_CIDRS.has(s.trim())) return true;
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function markedPublic(tree) {
|
|
212
|
+
for (const [k, v] of pairs(tree)) {
|
|
213
|
+
if ((k === 'publicly_accessible' || k === 'public_network_access_enabled' || k === 'associate_public_ip_address') && (v === true || v === 'true')) return true;
|
|
214
|
+
if (k === 'acl' && typeof v === 'string' && /^public-read/.test(v)) return true;
|
|
215
|
+
if (k === 'type' && (v === 'LoadBalancer' || v === 'NodePort')) return true;
|
|
216
|
+
}
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* -------------------------------------------------------- encryption ---- */
|
|
221
|
+
|
|
222
|
+
// Keys whose value is the encryption switch itself.
|
|
223
|
+
const BOOL_KEY = /^(encrypted|storage_encrypted|encrypt_at_rest|encryption_enabled|enable_encryption|encrypted_at_rest|at_rest_encryption_enabled|encryption_at_rest_enabled)$/i;
|
|
224
|
+
// Keys that name the key or algorithm a store is encrypted with.
|
|
225
|
+
const KEY_KEY = /^(kms_key_id|kms_key_arn|kms_key_name|kms_key|kms_master_key_id|kms_key_self_link|encryption_key|encryption_key_name|disk_encryption_set_id|key_vault_key_id|sse_algorithm|default_kms_key_name)$/i;
|
|
226
|
+
// Blocks whose presence configures encryption, unless they switch it off.
|
|
227
|
+
const BLOCK_KEY = /^(server_side_encryption_configuration|server_side_encryption|encryption_configuration|encryption_config|encryption|encryption_at_rest|apply_server_side_encryption_by_default|disk_encryption|customer_managed_key)$/i;
|
|
228
|
+
// An expression that refers to a managed resource (aws_kms_key.main.arn) or a
|
|
229
|
+
// data source names a key that exists in the configuration; a variable, a
|
|
230
|
+
// local or anything else is unresolved.
|
|
231
|
+
const RESOURCE_REF = /^\$?\{?\s*(?:data\.)?[a-z][a-z0-9]*_[a-z0-9_]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_]+\s*\}?$/;
|
|
232
|
+
|
|
233
|
+
export const ENCRYPTION = Object.freeze({
|
|
234
|
+
TRUE: 'declared',
|
|
235
|
+
FALSE: 'declared_off',
|
|
236
|
+
ABSENT: 'not_declared',
|
|
237
|
+
UNRESOLVED: 'unresolved',
|
|
238
|
+
UNSUPPORTED: 'unsupported',
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
function boolState(v) {
|
|
242
|
+
if (v === true || v === 'true') return ENCRYPTION.TRUE;
|
|
243
|
+
if (v === false || v === 'false') return ENCRYPTION.FALSE;
|
|
244
|
+
if (isExpr(v)) return ENCRYPTION.UNRESOLVED;
|
|
245
|
+
if (v === null) return ENCRYPTION.ABSENT;
|
|
246
|
+
return ENCRYPTION.UNSUPPORTED;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function keyState(v) {
|
|
250
|
+
if (v === null || v === '' || v === false) return ENCRYPTION.ABSENT;
|
|
251
|
+
if (typeof v === 'string') return ENCRYPTION.TRUE;
|
|
252
|
+
if (isExpr(v)) {
|
|
253
|
+
const s = v.$expr.replace(/^"\$\{|\}"$/g, '').trim();
|
|
254
|
+
return RESOURCE_REF.test(s) ? ENCRYPTION.TRUE : ENCRYPTION.UNRESOLVED;
|
|
255
|
+
}
|
|
256
|
+
return ENCRYPTION.UNSUPPORTED;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function blockState(v) {
|
|
260
|
+
const blocks = Array.isArray(v) ? v : [v];
|
|
261
|
+
let state = ENCRYPTION.ABSENT;
|
|
262
|
+
for (const b of blocks) {
|
|
263
|
+
if (!b || typeof b !== 'object' || isExpr(b)) { if (isExpr(b)) state = state === ENCRYPTION.TRUE ? state : ENCRYPTION.UNRESOLVED; continue; }
|
|
264
|
+
if ('enabled' in b) {
|
|
265
|
+
const s = boolState(b.enabled);
|
|
266
|
+
if (s === ENCRYPTION.FALSE) return ENCRYPTION.FALSE;
|
|
267
|
+
if (s === ENCRYPTION.UNRESOLVED) { state = ENCRYPTION.UNRESOLVED; continue; }
|
|
268
|
+
}
|
|
269
|
+
if (state !== ENCRYPTION.UNRESOLVED) state = ENCRYPTION.TRUE;
|
|
270
|
+
}
|
|
271
|
+
return state;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* What one store's tree declares about encryption at rest:
|
|
276
|
+
* declared an explicit true, a key, or an encryption block
|
|
277
|
+
* declared_off an explicit false (it wins over any other signal)
|
|
278
|
+
* not_declared nothing about encryption (a comment is nothing)
|
|
279
|
+
* unresolved decided by a variable, local or other expression
|
|
280
|
+
* unsupported a value lint does not interpret (a number for a switch)
|
|
281
|
+
* An explicit switch decides; keys and blocks decide only when no switch is set.
|
|
282
|
+
*/
|
|
283
|
+
export function encryptionState(tree) {
|
|
284
|
+
const sw = [], other = [];
|
|
285
|
+
for (const [k, v] of pairs(tree)) {
|
|
286
|
+
if (BOOL_KEY.test(k)) sw.push(boolState(v));
|
|
287
|
+
else if (KEY_KEY.test(k)) other.push(keyState(v));
|
|
288
|
+
else if (BLOCK_KEY.test(k) && v && typeof v === 'object') other.push(blockState(v));
|
|
289
|
+
}
|
|
290
|
+
const decide = (list) => {
|
|
291
|
+
if (list.includes(ENCRYPTION.FALSE)) return ENCRYPTION.FALSE;
|
|
292
|
+
if (list.includes(ENCRYPTION.UNRESOLVED)) return ENCRYPTION.UNRESOLVED;
|
|
293
|
+
if (list.includes(ENCRYPTION.UNSUPPORTED)) return ENCRYPTION.UNSUPPORTED;
|
|
294
|
+
if (list.includes(ENCRYPTION.TRUE)) return ENCRYPTION.TRUE;
|
|
295
|
+
return ENCRYPTION.ABSENT;
|
|
296
|
+
};
|
|
297
|
+
const s = decide(sw);
|
|
298
|
+
if (s !== ENCRYPTION.ABSENT) return s;
|
|
299
|
+
return decide(other);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Every string and expression source in a tree, joined, for finding a
|
|
303
|
+
* reference to another resource's address. */
|
|
304
|
+
export function referenceText(tree) {
|
|
305
|
+
return [...strings(tree)].join('\n');
|
|
306
|
+
}
|