@arc-e-tect/api-only-publisher 0.1.0 → 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.adoc CHANGED
@@ -11,64 +11,63 @@ image:https://img.shields.io/github/v/release/Arc-E-Tect/SoftwareEngineeringDone
11
11
  image:https://img.shields.io/badge/Node-22%2B-339933?logo=node.js[Node 22+,link=https://nodejs.org]
12
12
  image:https://img.shields.io/badge/License-MIT-blue[MIT,link=LICENSE]
13
13
 
14
- Links say where they go: § jumps to a section of this page, → opens another file in this repository, and ↗ leaves it.
14
+ [NOTE]
15
+ .Where the links in this document go
16
+ ====
17
+ Every link ends with a marker saying where it leads, so you can tell before you follow it:
18
+
19
+ [horizontal]
20
+ `↗`:: An *external* site, outside this repository.
21
+ `→`:: *Another file* in this repository.
22
+ `§`:: *A section of this same file.*
23
+ ====
15
24
 
16
25
  // Remove this notice when 1.0.0 is released.
17
26
  [IMPORTANT]
18
27
  ====
19
28
  Until 1.0.0 is released, any release may change the command line, `apionly.yaml` or the published artifacts incompatibly, without a major version bump.
20
- There are no users to break yet, and the interface is still being settled; 1.0.0 is the first release whose interface is kept stable.
29
+ The interface is still being settled; 1.0.0 is the first release whose interface is kept stable.
21
30
  Pin an exact version, and read the changelog before upgrading.
22
31
  ====
23
32
 
33
+ [#what-it-is]
24
34
  == What it is
25
35
 
26
- `api-only-publisher` turns a library of small, reusable API description
27
- fragments into one complete, self-contained document per target, and distributes
28
- each to whoever needs it.
29
-
30
- It is the producer half of a pair. The consumer half, `API-Only Subscriber`,
31
- fetches a published document into an implementation project and verifies that it
32
- has not drifted. The two are coupled only by the published artifact and its
33
- manifest -- neither knows anything else about the other.
34
-
35
- A *target* is whatever gets its own contract document: a microservice, a modular
36
- monolith, a backend-for-frontend, or a documentation aggregate spanning all of
37
- them. It need not be a deployable, which is why this is not called `services`.
38
-
39
- == Why it is not a shell script
36
+ `api-only-publisher` turns a library of small, reusable API description fragments into one complete, self-contained document per target, and distributes each to whoever needs it.
40
37
 
41
- It was one, and the shell showed through in three places.
38
+ It is the producer half of a pair.
39
+ The consumer half, the link:../api-only-subscriber/README.adoc[API-Only Subscriber →], fetches a published document into an implementation project and verifies that it has not drifted.
40
+ The two are coupled only by the published artifact and its manifest; neither knows anything else about the other.
42
41
 
43
- The target list was a plain Bash array with a comment explaining that it avoided
44
- associative arrays for the Bash 3.2 that macOS still ships. Both scripts carried
45
- a write-to-a-temp-file-and-move dance to work around the difference between BSD
46
- and GNU `sed -i`. And neither could parse YAML, which is why setting a version
47
- was a regular expression that silently did nothing when it failed to match.
42
+ A *target* is whatever gets its own contract document: a microservice, a modular monolith, a backend-for-frontend, or a documentation aggregate spanning all of them.
43
+ It need not be a deployable, which is why this is not called `services`.
48
44
 
49
- Node was never an optional dependency either: `@redocly/cli` and `@asyncapi/cli`
50
- are Node tools, so a Node runtime was already required to run the pipeline at all.
45
+ This document is the reference for the Publisher alone.
46
+ To set it up for your situation, start at link:../docs/README.adoc[the documentation →].
51
47
 
48
+ [#requirements]
52
49
  == Requirements
53
50
 
54
51
  [cols="1,1",options="header"]
55
52
  |===
56
53
  | Requirement | Version
57
- | Node | 22 +
58
- | npm | 10 +
54
+ | Node.js | `^22.14.0`, `^24.10.0` or `>=26.0.0`
55
+ | npm | The one that comes with Node.js
59
56
  |===
60
57
 
61
- Nothing else. The bundlers are fetched by `npx` at the versions `apionly.yaml`
62
- pins, so they need no installation, and the only runtime dependency is `yaml`.
58
+ Nothing else.
59
+ The bundlers, https://redocly.com/docs/cli/[Redocly CLI ↗] and https://www.asyncapi.com/tools/cli[AsyncAPI CLI ↗], are fetched by `npx` at the versions `apionly.yaml` pins, so they need no installation, and the only runtime dependency is `yaml`.
63
60
 
61
+ [#installation]
64
62
  == Installation
65
63
 
66
64
  The Publisher behaves the same however it is started.
67
65
  What differs is where its version is pinned, and what checks that pin.
68
66
 
67
+ [#as-a-development-dependency]
69
68
  === As a development dependency
70
69
 
71
- The better fit for a specification library, and for any build that runs the Publisher more than once.
70
+ The fit for a specification library, and for any build that runs the Publisher more than once.
72
71
 
73
72
  [source,console]
74
73
  ----
@@ -93,7 +92,7 @@ npm install --save-dev --save-exact @arc-e-tect/api-only-publisher
93
92
  ----
94
93
  npm ci
95
94
  npm run apionly -- build
96
- npm run apionly -- publish --target user-account
95
+ npm run apionly -- publish --target orders
97
96
  ----
98
97
 
99
98
  * The version is pinned in one place, `package.json`, and every command and every CI job runs that one.
@@ -102,9 +101,13 @@ npm run apionly -- publish --target user-account
102
101
  * Renovate and Dependabot see the dependency, so an upgrade arrives as a pull request that moves the pin.
103
102
  * The cost is a `package.json` and a `package-lock.json` in the library, and an `npm ci` before the first command.
104
103
 
104
+ Run it through the script, as `npm run apionly -- <command>`.
105
+ `npx api-only-publisher`, without the scope, is not this package: npx would look that name up on the public registry.
106
+
107
+ [#with-npx]
105
108
  === With `npx`, installing nothing
106
109
 
107
- The better fit for trying the tool, for scaffolding a library with `init`, and for a one-off command.
110
+ The fit for trying the tool, for scaffolding a library with `init`, and for a one-off command.
108
111
 
109
112
  [source,console]
110
113
  ----
@@ -118,98 +121,90 @@ npx @arc-e-tect/api-only-publisher@<version> build -C my-api-library
118
121
  * Nothing records an integrity hash, and nothing notices when a newer version is released.
119
122
 
120
123
  Either way, pin an exact version rather than a range.
121
- This tool's output is expected to be reproducible a specification library's golden fixtures assert it byte for byte — and a floating version quietly breaks that guarantee.
124
+ A published contract should change only when its fragments or its version change, and a floating tool version lets it change without either.
122
125
 
123
126
  The bundlers are a separate matter.
124
127
  However the Publisher itself is started, it fetches them with `npx`, at the versions `toolchain` in `apionly.yaml` pins.
125
128
 
129
+ [#commands]
126
130
  == Commands
127
131
 
128
132
  [cols="1,3", options="header"]
129
133
  |===
130
134
  |Command |Purpose
131
135
 
132
- |`init [dir]`
133
- |Scaffold `apionly.yaml` and a reference directory layout that builds as it
134
- stands. This is how the *conventions* become reusable, as opposed to the code.
136
+ |`init [dir] [--force]`
137
+ |Scaffold `apionly.yaml` and a reference directory layout that builds as it stands.
135
138
 
136
- |`build`
137
- |Stage, substitute placeholders, bundle, stamp each published target's version, lint, and distribute.
139
+ |`build [--target <t>]... [--pre-release <ids>] [--openapi\|--asyncapi]`
140
+ |Stage, substitute placeholders, bundle, stamp each published target's version, and lint.
138
141
 
139
- |`lint`
140
- |Lint what is already built, without rebuilding, for fast local feedback.
142
+ |`lint [--target <t>]...`
143
+ |Lint what is already built, without rebuilding, and write a report per document.
144
+ Without `--target`, it also fails on a fragment no target reaches, as <<unreferenced,A fragment no target reaches fails the lint §>> describes.
141
145
 
142
146
  |`targets`
143
- |List the declared targets, what each one builds, and whether it is distributed.
147
+ |List the declared targets, what each one builds, and whether it is published.
144
148
 
145
- |`closure`
146
- |Show each target's dependency closure: how many files it reaches, and the hash
147
- of their content.
149
+ |`closure [--target <t>]...`
150
+ |Show each target's dependency closure: how many files it reaches, and the hash of their content.
148
151
 
149
152
  |`changed --since <ref>`
150
- |Report which targets' closures actually changed since a git ref. This is what
151
- drives a release: a target whose closure is untouched is not released, however
152
- much else in the repository moved.
153
+ |Report which targets' closures changed between a git ref and the working tree.
154
+ A target whose closure is untouched needs no new version, however much else in the repository moved.
153
155
 
154
- |`pack`
156
+ |`pack [--pre-release <ids>] [--target <t>]... [--out <dir>]`
155
157
  |Archive each built, published target with a `manifest.json`, at the version its version file declares.
156
158
 
157
- |`publish`
158
- |Pack once, then ship those same bytes to every configured channel.
159
+ |`publish [--pre-release <ids>] [--target <t>]... [--channel <c>]... [--out <dir>]`
160
+ |Pack once, then ship those same bytes to every configured channel, or to each `--channel`.
159
161
 
160
- |`split --out <dir>`
162
+ |`split --out <dir> [--by kind\|target]`
161
163
  |Make the library safe to break into separate repositories.
162
164
  |===
163
165
 
166
+ `--target` and `--channel` may be repeated.
167
+ `-C <dir>` runs as if started in `<dir>`; without it the configuration is found by walking up from the current directory.
168
+ `-q` or `--quiet` reports only errors, which is what a script that reads the output wants.
169
+ `--pre-release <ids>` appends pre-release identifiers to each target's version, as <<versions,Versions §>> describes.
170
+
164
171
  [source,console]
165
172
  ----
166
- api-only-publisher init my-api-library
167
- api-only-publisher build -C my-api-library
168
- api-only-publisher build --target user-account --openapi
169
- api-only-publisher publish --target user-account --pre-release rc.1
170
- api-only-publisher targets
173
+ npm run apionly -- build --target orders --openapi
174
+ npm run apionly -- publish --target orders --pre-release rc.1
175
+ npm run apionly -- changed --since origin/main --quiet
171
176
  ----
172
177
 
173
- `--target` may be repeated. `-C <dir>` runs as if started in `<dir>`; without it
174
- the configuration is found by walking up from the current directory.
175
- `--pre-release <ids>` appends pre-release identifiers to each target's version, as <<versions,Versions §>> describes.
176
-
177
- == What `build` actually does
178
+ [#what-build-does]
179
+ == What `build` does
178
180
 
179
- . **Stage.** The whole source root is copied to `build/staging/<type>/`. Nothing
180
- after this point touches the hand-authored tree.
181
+ . **Stage.** The whole source root is copied to `<build.staging>/<kind>/`.
182
+ Nothing after this point touches the hand-authored tree.
181
183
  +
182
- The *whole* root, not just one specification type's subtree, because the trees
183
- `$ref` each other -- event schemas reuse the OpenAPI common schemas, so a
184
- username means the same thing over Kafka as over HTTP. A partial copy breaks
185
- those references. Each type gets *its own* staging root, so building one never
186
- invalidates the other's staged tree.
184
+ The *whole* root, not just one specification kind's subtree, because the trees `$ref` each other: an event schema can reuse an OpenAPI component schema, so a field means the same thing in an event as in an HTTP response.
185
+ A partial copy breaks those references.
186
+ Each kind gets *its own* staging root, so building one never invalidates the other's staged tree.
187
187
 
188
- . **Substitute placeholders**, in place, across the staged copy. A `{{token}}`
189
- is replaced with the contents of `<token>.md`, found by searching the staged
190
- root. Indentation is preserved, so multi-line Markdown stays valid inside an
191
- indented YAML scalar.
188
+ . **Substitute placeholders**, in place, across the staged copy.
189
+ A `{{token}}` is replaced with the contents of `<token>.md`, found by searching the staged source root.
190
+ Indentation is preserved, so multi-line Markdown stays valid inside an indented YAML scalar.
192
191
 
193
- . **Bundle.** `@redocly/cli bundle` or `@asyncapi/cli bundle` resolves every
194
- `$ref` into one flat, self-contained document under `dist/<target>/`.
192
+ . **Bundle.** `@redocly/cli bundle` or `@asyncapi/cli bundle` resolves every `$ref` into one flat, self-contained document under `<build.dist>/<target>/`.
195
193
 
196
194
  . **Stamp the version** of each published target on its *finished* document: the version its version file declares, with any `--pre-release` identifiers appended.
197
195
  A `publish: false` target keeps the version its source declares.
198
196
 
199
- . **Lint** the finished document, so a broken fragment surfaces against the
200
- target it actually affects rather than at deploy time.
197
+ . **Lint** the finished document, so a broken fragment surfaces against the target it actually affects.
201
198
 
202
- . **Distribute**, unless the target is `publish: false`.
203
-
204
- [[versions]]
199
+ [#versions]
205
200
  == Versions
206
201
 
207
202
  Every published target has a version of its own, and it is kept in the library, beside the fragments it describes.
208
203
 
209
- .specs/openapi/bundles/user-account.bundle.properties
204
+ .specs/openapi/bundles/orders.bundle.properties
210
205
  [source,properties]
211
206
  ----
212
- # The version of the user-account contract, for every document it builds.
207
+ # The version of the orders contract, for every document it builds.
213
208
  # Semantic: major for a breaking change, minor for an additive one, patch for
214
209
  # anything else. Change it in the same commit as the fragments it describes.
215
210
  version=2.1.0
@@ -235,79 +230,61 @@ A version names one set of documents.
235
230
  Publishing different documents under a version that was published before, after editing a fragment without changing the version file, is refused by the API-Only Subscriber in every project that locked the earlier ones.
236
231
  Change the version in the same commit as the fragments instead.
237
232
 
233
+ [#closure]
238
234
  == Independent versioning, and the dependency closure
239
235
 
240
- Giving each target its own version has one non-obvious consequence, and the
241
- `closure` and `changed` commands are what pay for it.
236
+ Giving each target its own version has one non-obvious consequence, and the `closure` and `changed` commands are what pay for it.
242
237
 
243
- A change under `components/common/` affects every target that reaches it. A
244
- change under one service's own context affects only that one. Under
245
- repository-wide versioning the distinction is invisible, because everything bumps
246
- together; under per-target versioning the release job has to know the difference,
247
- or one service's edit releases every other service and the version numbers stop
248
- meaning anything.
238
+ A change under `components/common/` affects every target that reaches it.
239
+ A change under one target's own fragments affects only that one.
240
+ Under repository-wide versioning the distinction is invisible, because everything bumps together; under per-target versioning a release has to know the difference, or one target's edit releases every other target and the version numbers stop meaning anything.
249
241
 
250
- A target's *closure* is every file reachable from its bundle root by following
251
- `$ref`s transitively. `changed --since <ref>` resolves each closure and reports
252
- which targets a commit actually touched:
242
+ A target's *closure* is every file reachable from its bundle root by following `$ref`s transitively.
243
+ `changed --since <ref>` resolves each closure and reports which targets a change actually touched:
253
244
 
254
245
  [source,console]
255
246
  ----
256
- api-only-publisher changed --since origin/main
247
+ npm run apionly -- changed --since origin/main
257
248
  ----
258
249
 
259
- Two things make this correct rather than approximate. The closure is computed
260
- over the *staged, substituted* tree, so a change to a Markdown snippet -- which
261
- genuinely changes the published document -- counts. And a change to the *shape*
262
- of a closure always means editing a file already inside it, so a bundle gaining
263
- or losing a `$ref` is caught without diffing membership separately.
250
+ Two things make this correct rather than approximate.
251
+ The closure is computed over the *staged, substituted* tree, so a change to a Markdown snippet, which genuinely changes the published document, counts.
252
+ And a change to the *shape* of a closure always means editing a file already inside it, so a bundle gaining or losing a `$ref` is caught without diffing membership separately.
264
253
 
265
- The closure hash is recorded in each release's manifest as `closureSha256`, which
266
- is what lets a later commit compare against what was actually published.
254
+ The closure hash is recorded in each release's manifest as `closureSha256`, which is what lets a later commit compare against what was actually published.
267
255
 
256
+ [#splitting]
268
257
  == Splitting the library
269
258
 
270
- A fragment library is one `$ref` graph, and that graph does not respect the
271
- directory boundaries a repository split would follow. Here, the AsyncAPI event
272
- schemas reuse `openapi/components/common/schemas/UsernameV1.yaml` -- a username
273
- means the same thing over Kafka as over HTTP. Move the two trees into separate
274
- repositories as they stand and every one of those references dangles.
259
+ A fragment library is one `$ref` graph, and that graph does not respect the directory boundaries a repository split would follow.
260
+ When the AsyncAPI documents reuse `openapi/components/common/schemas/`, moving the two trees into separate repositories as they stand leaves every one of those references dangling.
275
261
 
276
262
  [source,console]
277
263
  ----
278
- api-only-publisher split --out build/split --by kind
264
+ npm run apionly -- split --out build/split --by kind
279
265
  ----
280
266
 
281
- Each part is written with its own subtree *plus* a copy of every foreign file it
282
- reaches, so that afterwards each side is self-contained and builds on its own.
283
- The copies keep their original path relative to the shared source root, which is
284
- what makes this safe: every relative `$ref` keeps resolving exactly as it did, so
285
- nothing is rewritten and no reference can break because a rewrite got the depth
286
- wrong.
267
+ Each part is written with its own subtree *plus* a copy of every foreign file it reaches, so that afterwards each side is self-contained and builds on its own.
268
+ The copies keep their original path relative to the shared source root, which is what makes this safe: every relative `$ref` keeps resolving exactly as it did, so nothing is rewritten and no reference can break because a rewrite got the depth wrong.
287
269
 
288
- `--by kind` splits along the specification types. `--by target` gives every
289
- target its own self-contained tree, which is what you want before splitting into
290
- per-service repositories.
270
+ `--by kind` splits along the specification kinds.
271
+ `--by target` gives every target its own self-contained tree, which is what you want before splitting into per-target repositories.
291
272
 
292
- Any part that received copies gets an `IMPORTED.adoc` listing them and saying
293
- plainly what it costs: those copies can now drift, and nothing will notice. A
294
- shared schema that two repositories both define is two schemas that happen to
295
- agree today. Where that matters, promote the fragment to a contract of its own,
296
- published and consumed like any other, rather than copied.
273
+ Any part that received copies gets an `IMPORTED.adoc` listing them and saying plainly what it costs: those copies can now drift, and nothing will notice.
274
+ A shared schema that two repositories both define is two schemas that happen to agree today.
275
+ Where that matters, promote the fragment to a contract of its own, published and consumed like any other, rather than copied.
297
276
 
277
+ [#aggregates]
298
278
  == Aggregates
299
279
 
300
- A whole-landscape OpenAPI view is just another bundle root: every path body
301
- already lives in a `$ref`'d fragment, so a portfolio costs one hand-written
302
- table of contents and duplicates no contract text.
280
+ A whole-landscape OpenAPI view is just another bundle root: every path body already lives in a `$ref`'d fragment, so a portfolio costs one hand-written table of contents and duplicates no contract text.
303
281
 
304
- AsyncAPI cannot be written that way. Its operations use document-root pointers
305
- (`channel: {$ref: '#/channels/auditV1'}`), and `#` resolves against whichever file
306
- contains it, so moving an operation into a fragment breaks it. A hand-written
307
- async portfolio would have to copy every operation verbatim, and that copy would
308
- start rotting the moment a member changed.
282
+ AsyncAPI cannot be written that way.
283
+ Its operations use document-root pointers (`channel: {$ref: '#/channels/orderPlacedV1'}`), and `#` resolves against whichever file contains it, so moving an operation into a fragment breaks it.
284
+ A hand-written async portfolio would have to copy every operation verbatim, and that copy would start rotting the moment a member changed.
309
285
 
310
- So it is generated instead. Declare the members and the aggregate's own identity:
286
+ So it is generated instead.
287
+ Declare the members and the aggregate's own identity:
311
288
 
312
289
  [source,yaml]
313
290
  ----
@@ -316,17 +293,17 @@ portfolio:
316
293
  asyncapi:
317
294
  bundle: portfolio_asyncapi_structure.yaml
318
295
  aggregate:
319
- - user-account
296
+ - orders
297
+ - payments
320
298
  info:
321
299
  title: Everything, together
322
300
  version: 0.0.0
323
301
  ----
324
302
 
325
- The bundle root is synthesised into the staging tree immediately before bundling,
326
- so nothing is duplicated in the source and the view cannot fall behind its
327
- members. Two members contributing the same channel or operation key is an error
328
- rather than a silent overwrite.
303
+ The bundle root is synthesised into the staging tree immediately before bundling, so nothing is duplicated in the source and the view cannot fall behind its members.
304
+ Two members contributing the same channel or operation key is an error rather than a silent overwrite.
329
305
 
306
+ [#channels]
330
307
  == Channels
331
308
 
332
309
  [cols="1,3", options="header"]
@@ -335,74 +312,63 @@ rather than a silent overwrite.
335
312
 
336
313
  |`file`
337
314
  |A local directory, laid out as `<directory>/<target>/<version>/`.
338
- Not a distribution mechanism between repositories: it exists so the pipeline is testable end to end with no infrastructure, so a library can share one build with the projects that implement it, and as a local-iteration escape hatch.
315
+ Not a distribution mechanism between repositories: it makes the pipeline testable end to end with no infrastructure, lets a library share one build with the projects that implement it, and is a local-iteration escape hatch.
339
316
  Publishing a version again replaces that version and keeps the others; with `clean: true` only the version being published is kept, for a directory that is a build output rather than a record of releases.
340
317
 
341
318
  |`maven`
342
- |A path publishes into a repository layout on disk; an `http(s)` URL deploys to a
343
- real remote. Native for Gradle consumers: version resolution, caching and
344
- conflict handling come free, which is what lets the Subscriber carry no HTTP
345
- client of its own.
319
+ |A path publishes into a repository layout on disk; an `http(s)` URL deploys to a remote repository with an HTTP `PUT` per file and a bearer token.
320
+ Native for Gradle consumers: version resolution, caching and conflict handling come free, which is what lets the Subscriber carry no HTTP client of its own.
346
321
 
347
322
  |`npm`
348
- |Real semver, integrity hashes for free, private scopes available, and the
349
- toolchain here is already Node.
323
+ |Real semver, integrity hashes, private scopes, and a toolchain that is already Node.js.
350
324
 
351
325
  |`github-release`
352
- |The language-neutral floor: immutable per tag, works for private repositories
353
- with a token, readable by a consumer with no JVM and no Node. No
354
- dependency-resolution semantics and no update notification, which is why it is
355
- the fallback rather than the default.
326
+ |The language-neutral floor: immutable per tag, works for private repositories with a token, readable by a consumer with no JVM and no Node.js.
327
+ No dependency-resolution semantics and no update notification, which is why it is the fallback rather than the default.
356
328
  |===
357
329
 
358
330
  Credentials are read from the environment, never from configuration.
359
- `channels.maven.tokenEnv` names the variable; a configuration file gets
360
- committed, and a credential in a committed file is a credential that has leaked.
331
+ `channels.maven.tokenEnv` names the variable, `MAVEN_TOKEN` by default; a configuration file gets committed, and a credential in a committed file is a credential that has leaked.
332
+
333
+ link:../docs/reference/how-publisher-and-subscriber-work-together.adoc#channels[How the Publisher and the Subscriber work together →] lists each channel's layout and options.
361
334
 
335
+ [#why-registries]
362
336
  === Why npm and Maven rather than release assets alone
363
337
 
364
- Publishing is passive. The producer releases `2.1.0` and nothing happens in an
365
- implementation repository until somebody looks.
338
+ Publishing is passive.
339
+ The producer releases `2.1.0` and nothing happens in an implementation repository until somebody looks.
366
340
 
367
- Renovate and Dependabot understand npm and Maven natively, so a contract bump
368
- arrives downstream as a pull request without any bespoke machinery. That is the
369
- practical argument for preferring them, and the reason to design the coordinates
370
- so that standard update bots can read them.
341
+ Renovate and Dependabot understand npm and Maven natively, so a contract bump arrives downstream as a pull request without any bespoke machinery.
342
+ That is the practical argument for preferring them, and the reason to design the coordinates so that standard update bots can read them.
371
343
 
344
+ [#pre-releases]
372
345
  == Pre-releases
373
346
 
374
- API-Only design means implementation starts against a contract that is not
375
- finished. If the only way to obtain a bundle were a final release, teams would
376
- work around the tool by cloning the specification repository -- which is exactly
377
- the broad read access that publishing artifacts exists to avoid, with none of the
378
- guarantees.
347
+ API-first design means implementation starts against a contract that is not finished.
348
+ If the only way to obtain a bundle were a final release, teams would work around the tool by cloning the specification repository, which is exactly the broad read access that publishing artifacts exists to avoid, with none of the guarantees.
379
349
 
380
- So a pre-release version is first-class: `2.1.0-rc.1`, or Maven's `-SNAPSHOT`
381
- spelling, which is not semver-legal but is what Maven consumers expect.
350
+ So a pre-release version is first-class: `2.1.0-rc.1`, or Maven's `-SNAPSHOT` spelling, which is not semver-legal but is what Maven consumers expect.
382
351
  It is cut from the version in the target's version file: `publish --pre-release rc.1` publishes `2.1.0-rc.1` when the file declares `2.1.0`.
383
352
 
384
- The hard rule is the other half of it. A pre-release must never quietly satisfy a
385
- production build:
353
+ The hard rule is the other half of it.
354
+ A pre-release must never quietly satisfy a production build:
386
355
 
387
- * npm publishes it under the `next` dist-tag, never `latest`, so `npm install`
388
- cannot pick one up by accident.
389
- * A GitHub release is marked as a pre-release, so "latest release" never resolves
390
- to an unfinished contract.
391
- * The API-Only Subscriber refuses a pre-release version outright unless the
392
- subscription sets `allowPrerelease = true` -- a visible, reviewable line in a
393
- build file rather than a default.
356
+ * npm publishes it under the `next` dist-tag, never `latest`, so `npm install` cannot pick one up by accident.
357
+ * A GitHub release is marked as a pre-release, so "latest release" never resolves to an unfinished contract.
358
+ * The API-Only Subscriber refuses a pre-release version outright unless the subscription sets `allowPrerelease = true`: a visible, reviewable line in a build file rather than a default.
394
359
 
360
+ [#configuration]
395
361
  == Configuration
396
362
 
397
- Everything project-specific lives in `apionly.yaml`, not in the code. Adapting
398
- the tool to another project means editing configuration.
363
+ Everything project-specific lives in `apionly.yaml`, not in the code.
364
+ Adapting the tool to another project means editing configuration.
399
365
 
400
366
  [source,yaml]
401
367
  ----
402
368
  schemaVersion: 1
403
369
 
404
370
  sources:
405
- root: specs # staged wholesale; the types share fragments
371
+ root: specs # staged wholesale; the kinds share fragments
406
372
  openapi: openapi
407
373
  asyncapi: asyncapi
408
374
 
@@ -417,9 +383,11 @@ defaults:
417
383
 
418
384
  build:
419
385
  staging: build/staging
420
- dist: dist
386
+ dist: build/dist
421
387
  reports:
422
388
  lint: build/reports/lint
389
+ lint:
390
+ unreferenced: error # a fragment no target reaches: error, warn or off
423
391
 
424
392
  toolchain:
425
393
  redocly: "@redocly/cli@2.52.0"
@@ -431,68 +399,82 @@ channels:
431
399
  clean: true # keep only the version being published
432
400
 
433
401
  targets:
434
- user-account:
435
- # versionFile: versions/user-account.properties
436
- # default: user-account.bundle.properties beside the first bundle root
402
+ orders:
403
+ # versionFile: versions/orders.properties
404
+ # default: orders.bundle.properties beside the first bundle root
437
405
  openapi:
438
- bundle: bundles/user-account_openapi_structure.yaml
406
+ bundle: bundles/orders.yaml
439
407
  asyncapi:
440
- bundle: user-account_asyncapi_structure.yaml
408
+ bundle: orders_asyncapi.yaml
441
409
  portfolio:
442
- publish: false # built and linted, never distributed
410
+ publish: false # built and linted, never published
443
411
  openapi:
444
- bundle: bundles/portfolio_openapi_structure.yaml
412
+ bundle: bundles/portfolio.yaml
445
413
  ----
446
414
 
447
- `targets` is the single source of truth for what gets built. It replaced the
448
- per-script target arrays and the `apis:` map in `redocly.yaml`, which said
449
- overlapping things in two places under two different names.
415
+ `targets` is the single source of truth for what gets built.
450
416
 
417
+ [#pinning-the-bundlers]
451
418
  === Pinning the bundlers
452
419
 
453
- `toolchain` pins the bundler versions. Unpinned, the documents this tool produces
454
- could change because an upstream release happened, with nothing in the library
455
- having changed -- which makes any claim that a refactoring preserved output
456
- unfalsifiable. An upgrade should be a deliberate commit that moves the pin and
457
- the expected output together.
420
+ `toolchain` pins the bundler versions.
421
+ Unpinned, the documents this tool produces could change because an upstream release happened, with nothing in the library having changed.
422
+ An upgrade should be a deliberate commit that moves the pin, reviewed with whatever it changes in the published documents.
458
423
 
424
+ [#behaviour]
459
425
  == Behaviour worth knowing about
460
426
 
427
+ [#unresolved-placeholders]
461
428
  === Unresolved placeholders fail the build
462
429
 
463
- The tool this absorbed emitted the literal string `*MISSING CONTENT*` into the
464
- document and carried on with a warning, so a broken contract could ship from a
465
- green build. An unresolved `{{token}}` is now an error. Set
466
- `defaults.placeholders.strict: false` to get the old leniency, in which case the
467
- token is left visible rather than replaced with a marker.
430
+ An unresolved `{{token}}` is an error, so a broken contract cannot ship from a green build.
431
+ Set `defaults.placeholders.strict: false` to leave the token visible in the document instead.
468
432
 
433
+ [#placeholder-search]
469
434
  === Placeholder snippets are found from the source root
470
435
 
471
- The absorbed tool searched downward from the *input file's own directory*, never
472
- from the directory it was told to use, so a snippet one level up was invisible
473
- and the workaround was to move the Markdown files. The search now starts at the
474
- staged source root.
436
+ `<token>.md` is searched for from the staged source root downwards, so a snippet can live anywhere under it.
437
+
438
+ [#unreferenced]
439
+ === A fragment no target reaches fails the lint
440
+
441
+ A linter checks documents, and a fragment reaches a document only through a `$ref`.
442
+ A fragment that no target references is therefore never linted, however wrong it is, until the day a target starts to use it.
443
+
444
+ So `lint`, run without `--target`, also lists every YAML file under the source root that no target's closure reaches, in `<reports.lint>/unreferenced.txt`, and fails when there is one.
445
+ Every target counts, `publish: false` ones included, because they are linted too.
446
+ That reports a fragment that does not comply before anything uses it, and finds a definition left behind by a change that stopped using it.
447
+
448
+ Configuration is not a fragment, so it may sit anywhere, `sources.root` included.
449
+ The check skips the well-known configuration files of lint tools, wherever they are: `redocly.yaml`, `.redocly.yaml`, `.redocly.lint-ignore.yaml`, `.spectral.yaml`, `.spectral.yml`, and `apionly.yaml`.
450
+ It also skips the lint configurations `defaults.<kind>.lint` names, whatever they are called.
451
+ Any other YAML file under the source root counts.
475
452
 
453
+ `lint.unreferenced` in `apionly.yaml` sets what happens: `error` by default, `warn` to report without failing, or `off` not to look.
454
+ `lint --target` does not look, because an unreferenced fragment belongs to no target.
455
+
456
+ `lint` lints every document even when one of them fails, and names every failure at the end, so a single run reports all of them.
457
+
458
+ [#version-stamping]
476
459
  === Version stamping edits one scalar
477
460
 
478
- `info.version` is located structurally and that one scalar is spliced. It is
479
- deliberately not a re-serialisation: re-emitting a document reformats everything
480
- around the edit, because the AsyncAPI CLI wraps long descriptions at a width no
481
- YAML emitter reproduces. Splicing keeps every other byte exactly as the bundler
482
- wrote it. A document with no `info` block, or no version inside it, is an error.
461
+ `info.version` is located structurally and that one scalar is spliced.
462
+ It is deliberately not a re-serialisation: re-emitting a document reformats everything around the edit, because the AsyncAPI CLI wraps long descriptions at a width no YAML emitter reproduces.
463
+ Splicing keeps every other byte exactly as the bundler wrote it.
464
+ A document with no `info` block, or no version inside it, is an error.
483
465
 
466
+ [#distribution]
484
467
  === `distribution` is transitional
485
468
 
486
- A producer has no business knowing the directory layout of the projects that
487
- consume it. The `distribution` block exists so that a specification library can
488
- adopt this tool without every consumer changing at the same time. The API-Only
489
- Subscriber replaces it: each consuming project declares where its own copy lands,
490
- and the block goes away.
469
+ A producer has no business knowing the directory layout of the projects that consume it.
470
+ The `distribution` block copies built documents into such a layout, so that a library can adopt this tool before its consumers adopt the API-Only Subscriber.
471
+ Once they subscribe, each consuming project declares where its own copy lands, and the block goes away.
491
472
 
473
+ [#adapting]
492
474
  == Adapting it to another project
493
475
 
494
- The tool has no dependency on any particular product, framework or directory
495
- layout. In order of what usually needs changing:
476
+ The tool has no dependency on any particular product, framework or directory layout.
477
+ In order of what usually needs changing:
496
478
 
497
479
  [cols="2,3", options="header"]
498
480
  |===
@@ -505,7 +487,7 @@ layout. In order of what usually needs changing:
505
487
  |`sources`
506
488
 
507
489
  |What each document is called
508
- |`defaults.<type>.outputName`
490
+ |`defaults.<kind>.outputName`
509
491
 
510
492
  |The lint rules
511
493
  |`defaults.openapi.lint`
@@ -514,7 +496,7 @@ layout. In order of what usually needs changing:
514
496
  |`reports.lint`, defaulting to `build/reports/lint/<target>/<kind>.txt`
515
497
 
516
498
  |Where built documents go
517
- |`build.dist`, and `distribution` while it still exists
499
+ |`build.dist`
518
500
 
519
501
  |Where artifacts are published
520
502
  |`channels`
@@ -523,37 +505,30 @@ layout. In order of what usually needs changing:
523
505
  |Its version file, or `targets.<target>.versionFile`
524
506
  |===
525
507
 
526
- Beyond that: the `common/` versus `<product>/<target>/` split is one convention
527
- for organising a multi-target fragment library, not a requirement. A single-target
528
- project can keep every fragment in one flat directory, or skip fragments entirely
529
- and hand-author one complete document. The tool only needs one bundle root file
530
- per target; what that file `$ref`s, or whether it `$ref`s anything at all, is
531
- entirely up to the library.
508
+ Beyond that, a `components/common/` directory beside per-target directories is one convention for organising a multi-target fragment library, not a requirement.
509
+ A single-target project can keep every fragment in one flat directory, or skip fragments entirely and hand-author one complete document.
510
+ The tool only needs one bundle root file per target; what that file `$ref`s, or whether it `$ref`s anything at all, is up to the library.
532
511
 
533
- Placeholders are optional too. A library with no `{{token}}` anywhere simply has
534
- nothing substituted.
512
+ Placeholders are optional too.
513
+ A library with no `{{token}}` anywhere simply has nothing substituted.
535
514
 
515
+ [#breaking-changes]
536
516
  == Breaking changes
537
517
 
538
- The tool does not police them. `changed` tells a release job which targets a
539
- commit altered, and every manifest records the closure hash of what was
540
- published, so the hooks a diff gate would need already exist -- but the gate
541
- itself is deliberately not built.
518
+ The tool does not police them.
519
+ `changed` tells a release which targets a commit altered, and every manifest records the closure hash of what was published, so the hooks a diff gate would need already exist, but the gate itself is deliberately not built.
542
520
 
543
521
  A breaking change is declared where it is made: the major version in the target's version file changes in the same commit as the fragments it breaks.
522
+ A specification library should write down its own policy for what counts as breaking.
544
523
 
545
- A specification library should write down its own policy. The one this repository
546
- uses is at `usable-suspects/docs/breaking-changes.adoc`, and is a reasonable
547
- starting point to copy.
548
-
524
+ [#as-a-library]
549
525
  == Using it as a library
550
526
 
551
- Everything the CLI does is available programmatically, so a release job that
552
- wants to decide something for itself does not have to parse console output:
527
+ Everything the CLI does is available programmatically, so a release job that wants to decide something for itself does not have to parse console output:
553
528
 
554
529
  [source,javascript]
555
530
  ----
556
- const { loadFrom, prepare, forTargets, changedSince } =
531
+ const { loadFrom, prepare, changedSince } =
557
532
  require('@arc-e-tect/api-only-publisher');
558
533
 
559
534
  const config = loadFrom('.');
@@ -566,19 +541,20 @@ for (const result of changedSince(config, 'origin/main')) {
566
541
  }
567
542
  ----
568
543
 
544
+ [#exit-codes]
569
545
  == Exit codes
570
546
 
571
547
  [cols="1,3",options="header"]
572
548
  |===
573
549
  | Code | Meaning
574
550
  | `0` | Success.
575
- | `1` | A handled failure: bad configuration, an unresolved placeholder, a
576
- document that cannot be stamped, a dangling `$ref`, a channel that
577
- refused the artifact. The message says which.
551
+ | `1` | A handled failure: bad configuration, an unresolved placeholder, a document that cannot be stamped, a dangling `$ref`, a lint failure, a channel that refused the artifact.
552
+ The message says which.
578
553
  |===
579
554
 
580
555
  Anything else is a bug; please report it.
581
556
 
557
+ [#tests]
582
558
  == Tests
583
559
 
584
560
  [source,console]
@@ -586,32 +562,22 @@ Anything else is a bug; please report it.
586
562
  npm test
587
563
  ----
588
564
 
589
- Covers placeholder substitution (including each of the three defects fixed on
590
- the way in), version stamping, version files, configuration parsing, every command, and the scaffold.
591
-
592
- The end-to-end guarantee lives elsewhere, in the specification library's own
593
- golden fixtures: they assert that the documents this tool produces are unchanged,
594
- byte for byte, from the ones the pipeline produced before it existed.
565
+ Covers every module and every command: placeholder substitution, version stamping, version files, configuration parsing, closures, packing, channels, splitting, and the scaffold.
566
+ `build.e2e.test.js` runs the real bundlers.
567
+ The run fails below 90% of lines and functions, or 80% of branches.
595
568
 
569
+ [#relationship]
596
570
  == Relationship to the API-Only Subscriber
597
571
 
598
- This half publishes; link:../api-only-subscriber/README.adoc[the Subscriber]
599
- fetches and verifies. They share one seam an archive plus a `manifest.json`
600
- and nothing else, which is why they can be versioned independently while living
601
- in one repository.
602
-
603
- This document is a reference for this half alone. For how the two are put
604
- together in a real project, see link:../docs/README.adoc[the manuals], which are
605
- organised by where the API descriptions live relative to the code that implements
606
- them: link:../docs/in-the-implementation-project.adoc[in the implementation
607
- project], link:../docs/elsewhere-in-the-repository.adoc[elsewhere in the same
608
- repository], link:../docs/in-its-own-repository.adoc[in a repository of their
609
- own]. The first uses only `build` and reads the result directly; the second
610
- publishes to the `file` channel, and the third to a registry. If you are
611
- replacing a script that copies a document into `src/main/resources`, start at
612
- link:../docs/migrating-a-checked-in-document.adoc[Migrating a checked-in
613
- document].
572
+ This half publishes; link:../api-only-subscriber/README.adoc[the Subscriber] fetches and verifies.
573
+ They share one seam, an archive plus a `manifest.json`, and nothing else, which is why they can be versioned independently while living in one repository.
574
+
575
+ * link:../docs/README.adoc[The documentation →] helps you pick the use case that fits, with a tutorial and AI prompts for each.
576
+ * link:../docs/reference/how-publisher-and-subscriber-work-together.adoc[How the Publisher and the Subscriber work together →] maps every setting, command and check across the two tools, and says where a change to either belongs.
577
+ * link:../docs/reference/run-book.adoc[The run-book →] covers operating both, and every failure message.
614
578
 
579
+ [#license]
615
580
  == License
616
581
 
617
- MIT. See link:LICENSE[LICENSE].
582
+ MIT.
583
+ See link:LICENSE[LICENSE →].
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arc-e-tect/api-only-publisher",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Builds, packs and publishes API description documents from a library of reusable fragments.",
5
5
  "license": "MIT",
6
6
  "author": "Arc-E-Tect",
package/src/cli.js CHANGED
@@ -15,6 +15,7 @@ const { split, SplitError } = require("./split");
15
15
  const { publish, ChannelError } = require("./channels");
16
16
  const { VersionError: PolicyError, describe } = require("./version-policy");
17
17
  const { versionOf, BundleVersionError } = require("./bundle-version");
18
+ const { unreferenced } = require("./unreferenced");
18
19
 
19
20
  const USAGE = `api-only-publisher -- build and distribute API description documents
20
21
 
@@ -162,26 +163,61 @@ async function main(argv) {
162
163
  }
163
164
  case "lint": {
164
165
  // Lint without rebuilding, for fast local feedback on what is already
165
- // in dist/.
166
+ // in dist/. Every selected document is linted even when one fails, so
167
+ // one run reports every failure rather than only the first.
166
168
  const fs = require("fs");
167
169
  const { lint } = require("./pipeline");
170
+ const failures = [];
168
171
  let linted = 0;
169
172
  for (const kind of ["openapi", "asyncapi"]) {
170
173
  for (const target of config.targetsFor(kind)) {
171
174
  if (targets && !targets.includes(target)) continue;
172
175
  const file = path.join(config.distDir(target), config.outputName(kind));
173
176
  if (!fs.existsSync(file)) {
174
- throw new BuildError(`${file} does not exist; run 'build' first`);
177
+ failures.push(`${target} (${kind}): ${file} does not exist; run 'build' first`);
178
+ continue;
175
179
  }
176
180
  log(`=== ${target} (${kind}) ===`);
177
- lint(config, kind, file, log, {
178
- report: true,
179
- reportFile: config.lintReport(target, kind),
180
- });
181
+ try {
182
+ lint(config, kind, file, log, {
183
+ report: true,
184
+ reportFile: config.lintReport(target, kind),
185
+ });
186
+ } catch (error) {
187
+ if (!(error instanceof BuildError)) throw error;
188
+ console.error(error.message);
189
+ failures.push(`${target} (${kind})`);
190
+ }
181
191
  linted += 1;
182
192
  }
183
193
  }
184
194
  log(`\nLinted ${linted} document(s).`);
195
+
196
+ // A fragment no target reaches is never linted, so it is looked for
197
+ // here -- only when every target is linted, since it belongs to none.
198
+ const mode = config.lintUnreferenced();
199
+ let orphaned = null;
200
+ if (!targets && mode !== "off") {
201
+ prepare(config);
202
+ const orphans = unreferenced(config);
203
+ const reportFile = config.unreferencedReport();
204
+ fs.mkdirSync(path.dirname(reportFile), { recursive: true });
205
+ fs.writeFileSync(reportFile, orphans.map((file) => `${file}\n`).join(""));
206
+ if (orphans.length > 0) {
207
+ orphaned =
208
+ `${orphans.length} fragment(s) not reachable from any target, so nothing lints them:\n` +
209
+ orphans.map((file) => ` ${file}`).join("\n") +
210
+ "\nReference each one from a target's bundle, or delete it.";
211
+ if (mode === "warn") log(orphaned);
212
+ }
213
+ }
214
+
215
+ const problems = [];
216
+ if (failures.length > 0) {
217
+ problems.push(`lint failed for ${failures.length} document(s): ${failures.join("; ")}`);
218
+ }
219
+ if (orphaned && mode === "error") problems.push(orphaned);
220
+ if (problems.length > 0) throw new BuildError(problems.join("\n\n"));
185
221
  return 0;
186
222
  }
187
223
  case "closure": {
package/src/config.js CHANGED
@@ -2,9 +2,8 @@
2
2
 
3
3
  // Reading and validating apionly.yaml.
4
4
  //
5
- // The configuration is the single source of truth for what this library builds:
6
- // it replaced the per-script SERVICES arrays, and the `apis:` map that said an
7
- // overlapping thing under a different name.
5
+ // The configuration is the single source of truth for what a library builds:
6
+ // which targets exist, where their fragments live, and where each document goes.
8
7
 
9
8
  const fs = require("fs");
10
9
  const path = require("path");
@@ -93,6 +92,7 @@ function load(configPath) {
93
92
  toolchain: parsed.toolchain || {},
94
93
  build: parsed.build || {},
95
94
  reports: parsed.reports || {},
95
+ lint: parsed.lint || {},
96
96
  distribution: parsed.distribution || null,
97
97
  channels: parsed.channels || {},
98
98
  targets,
@@ -126,6 +126,21 @@ function load(configPath) {
126
126
  const reports = this.reports.lint || "build/reports/lint";
127
127
  return path.resolve(this.root, reports, target, `${kind}.txt`);
128
128
  },
129
+ // Where lint lists the fragments no target reaches.
130
+ unreferencedReport() {
131
+ const reports = this.reports.lint || "build/reports/lint";
132
+ return path.resolve(this.root, reports, "unreferenced.txt");
133
+ },
134
+ // What lint does about a fragment no target reaches: `error`, the default,
135
+ // fails the lint; `warn` reports it; `off` does not look.
136
+ lintUnreferenced() {
137
+ const configured = this.lint.unreferenced;
138
+ const mode = configured === undefined ? "error" : configured === false ? "off" : configured;
139
+ if (!["error", "warn", "off"].includes(mode)) {
140
+ throw new ConfigError(`lint.unreferenced must be error, warn or off, not ${JSON.stringify(configured)}`);
141
+ }
142
+ return mode;
143
+ },
129
144
  tool(name) {
130
145
  return requireString(this.toolchain[name], `toolchain.${name}`);
131
146
  },
package/src/index.js CHANGED
@@ -14,6 +14,7 @@ module.exports = {
14
14
  ...require("./version-policy"),
15
15
  ...require("./bundle-version"),
16
16
  ...require("./closure"),
17
+ ...require("./unreferenced"),
17
18
  ...require("./aggregate"),
18
19
  ...require("./pipeline"),
19
20
  ...require("./pack"),
package/src/pipeline.js CHANGED
@@ -55,12 +55,8 @@ function stage(config, kind, log) {
55
55
  /**
56
56
  * Substitute placeholders across the staged tree, in place.
57
57
  *
58
- * Every YAML file is visited rather than only the bundle roots and info.yaml.
59
- * The tool this replaces read only the one file it was handed, which is why the
60
- * shared info block had to be preprocessed as a separate up-front step and why
61
- * bundles had to $ref a generated merged_info.yaml instead of the file they
62
- * meant. Visiting the staged tree removes that special case: a placeholder works
63
- * wherever it is written.
58
+ * Every YAML file is visited, not only the bundle roots, so a placeholder works
59
+ * wherever it is written, and no file has to be preprocessed separately first.
64
60
  */
65
61
  function substituteTree(config, kind, log) {
66
62
  const stagingRoot = config.stagingRoot(kind);
@@ -2,21 +2,17 @@
2
2
 
3
3
  // Placeholder substitution.
4
4
  //
5
- // Absorbed from sedr_utils/openapi/prep_openapi's preprocess_openapi.js, with
6
- // three defects fixed rather than inherited:
5
+ // A {{token}} in a staged YAML file is replaced by the contents of <token>.md.
6
+ // Three choices here are deliberate:
7
7
  //
8
- // 1. Search scope. The original searched for <placeholder>.md recursively
9
- // downward from the *input file's own directory*, never from the -d
10
- // argument the caller passed. The workaround was to move the Markdown
11
- // files next to whatever referenced them, and the gotcha had to be
12
- // documented. The search root is now a parameter, defaulting to the source
13
- // root the caller actually named.
14
- // 2. Failure mode. A missing Markdown file produced a warning and the literal
15
- // string *MISSING CONTENT* in the output, so a broken document shipped from
16
- // a green build. Unresolved placeholders are now an error by default.
17
- // 3. Token grammar. The pattern \{\{(\w+)\}\} silently excluded '-' and '.'
18
- // from placeholder names, so {{status-codes}} was left in the output rather
19
- // than reported. The grammar now admits them.
8
+ // 1. Search scope. <token>.md is searched for from the root the caller names,
9
+ // the staged source root by default, not from the directory of the file
10
+ // holding the token, so a snippet can live anywhere under that root.
11
+ // 2. Failure mode. An unresolved placeholder is an error by default. Leaving a
12
+ // marker in the document and carrying on would let a broken contract ship
13
+ // from a green build.
14
+ // 3. Token grammar. Names may contain '-' and '.', so {{status-codes}} is a
15
+ // placeholder like any other, never text silently left in the output.
20
16
 
21
17
  const fs = require("fs");
22
18
  const path = require("path");
@@ -103,9 +99,8 @@ function substitute(text, { searchRoot, strict = true, describeAs = "input" } =
103
99
  }
104
100
 
105
101
  /**
106
- * Substitute a file in place. Unlike the tool this replaces, nothing named
107
- * merged_* is left behind: the file is rewritten where it stands, which is safe
108
- * because it is always a staged copy.
102
+ * Substitute a file in place. The file is rewritten where it stands and nothing
103
+ * is written beside it, which is safe because it is always a staged copy.
109
104
  */
110
105
  function substituteFile(file, options) {
111
106
  const before = fs.readFileSync(file, "utf8");
package/src/split.js CHANGED
@@ -4,10 +4,10 @@
4
4
  //
5
5
  // The problem it solves is real and easy to miss. A fragment library is one $ref
6
6
  // graph, and that graph does not respect the directory boundaries a repository
7
- // split would follow. In this library the AsyncAPI event schemas reuse
8
- // `openapi/components/common/schemas/UsernameV1.yaml`, so a username means the
9
- // same thing over Kafka as over HTTP. Move the two trees into separate
10
- // repositories as they stand and every one of those references dangles.
7
+ // split would follow. An AsyncAPI event schema that reuses an OpenAPI common
8
+ // schema -- a username, say -- means the same thing over a message broker as over
9
+ // HTTP, and that reuse is a reference across the two trees. Move the trees into
10
+ // separate repositories as they stand and every such reference dangles.
11
11
  //
12
12
  // `split` resolves each part's dependency closure and materialises it whole:
13
13
  // each part gets its own subtree *plus* a copy of every foreign file it reaches,
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+
3
+ // Fragments no target reaches.
4
+ //
5
+ // A linter checks documents, and a fragment reaches a document only through a
6
+ // $ref. A fragment that no target references is therefore never linted, however
7
+ // wrong it is, until the day a target starts to use it. Looking for unreferenced
8
+ // fragments is how that is caught before then -- and how a definition left behind
9
+ // by a change that stopped using it is found at all.
10
+
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+
14
+ const { forTargets } = require("./closure");
15
+
16
+ function yamlFiles(dir) {
17
+ const files = [];
18
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
19
+ const full = path.join(dir, entry.name);
20
+ if (entry.isDirectory()) files.push(...yamlFiles(full));
21
+ else if (entry.isFile() && /\.ya?ml$/.test(entry.name)) files.push(full);
22
+ }
23
+ return files;
24
+ }
25
+
26
+ const portable = (file) => file.split(path.sep).join("/");
27
+
28
+ // Configuration files of the tools an API library is linted with, and this tool's
29
+ // own. They are YAML, but they describe how to check an API, not the API, so they
30
+ // are never fragments, wherever they sit and whether or not apionly.yaml names them.
31
+ const TOOL_CONFIGS = new Set([
32
+ "apionly.yaml",
33
+ "redocly.yaml",
34
+ ".redocly.yaml",
35
+ ".redocly.lint-ignore.yaml",
36
+ ".spectral.yaml",
37
+ ".spectral.yml",
38
+ ]);
39
+
40
+ /**
41
+ * Every YAML file under the source root that no target's closure reaches,
42
+ * relative to the source root and sorted.
43
+ *
44
+ * Every target counts, `publish: false` ones included: a documentation view is
45
+ * linted like any other target, so whatever it reaches is linted too. The staged
46
+ * tree must already exist, as for forTargets.
47
+ *
48
+ * Configuration is not a fragment: neither the well-known configuration files of
49
+ * lint tools, nor the lint configuration apionly.yaml names, whatever it is called.
50
+ *
51
+ * @returns {string[]}
52
+ */
53
+ function unreferenced(config) {
54
+ const reached = new Set();
55
+ for (const [, entry] of forTargets(config)) {
56
+ for (const [kind, files] of Object.entries(entry.byKind)) {
57
+ const stagingRoot = config.stagingRoot(kind);
58
+ for (const file of files) reached.add(portable(path.relative(stagingRoot, file)));
59
+ }
60
+ }
61
+ const sourceRoot = config.sourceRoot();
62
+ const own = new Set(["openapi", "asyncapi"]
63
+ .map((kind) => config.lintConfig(kind))
64
+ .filter(Boolean)
65
+ .map((file) => portable(path.relative(sourceRoot, file))));
66
+ return yamlFiles(sourceRoot)
67
+ .map((file) => portable(path.relative(sourceRoot, file)))
68
+ .filter((file) => !reached.has(file) && !own.has(file) && !TOOL_CONFIGS.has(path.posix.basename(file)))
69
+ .sort();
70
+ }
71
+
72
+ module.exports = { unreferenced };
package/src/version.js CHANGED
@@ -2,12 +2,10 @@
2
2
 
3
3
  // Setting info.version on a bundled document.
4
4
  //
5
- // This replaces two different sed expressions, which between them differed per
6
- // specification type (OpenAPI quoted the version, AsyncAPI did not), did nothing
7
- // at all when the pattern missed, and could match a `version:` field elsewhere in
8
- // the document.
9
- //
10
- // The field is located structurally and only that one scalar is spliced. It is
5
+ // A pattern-based substitution would have to differ per specification type,
6
+ // would silently do nothing when its pattern missed, and could match a
7
+ // `version:` field elsewhere in the document. So the field is located
8
+ // structurally, and only that one scalar is spliced. It is
11
9
  // deliberately not a re-serialisation: re-emitting the document reformats
12
10
  // everything around the edit, because the AsyncAPI CLI wraps long descriptions at
13
11
  // a width no YAML emitter reproduces. Splicing keeps every other byte exactly as