@arc-e-tect/api-only-publisher 0.18.6 → 0.19.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
@@ -6,7 +6,7 @@
6
6
  :source-highlighter: rouge
7
7
  // The released version of this component: its snippets use it. The release
8
8
  // workflow updates it; do not change it by hand.
9
- :api-only-publisher-version: 0.18.6
9
+ :api-only-publisher-version: 0.19.0
10
10
 
11
11
  [NOTE]
12
12
  .About this documentation
@@ -190,12 +190,12 @@ Without `--target`, it also fails on a fragment no target reaches, as <<unrefere
190
190
  |Show each target's dependency closure: how many files it reaches, and the hash of their content.
191
191
 
192
192
  |`changed --since <ref>`
193
- |Report which targets' closures changed between a git ref and the working tree.
194
- A target whose closure is untouched needs no new version, however much else in the repository moved.
193
+ |Report which targets' documents changed between a git ref and the working tree.
194
+ A target whose documents are the same needs no new version, however much else in the repository moved.
195
195
 
196
196
  |`check [--target <t>]... [--pre-release <ids>] [--since <ref>]`
197
- |Fail on every published target that changed without a new version, from git alone, as <<version-guard,A changed target needs a new version §>> describes.
198
- Made for a pull request's pipeline: no network, no credentials, nothing built.
197
+ |Fail on every published target that changed without a new version, as <<version-guard,A changed target needs a new version §>> describes.
198
+ Made for a pull request's pipeline: no credentials, and no network when the bundler is carried, as in the Publisher's image; it builds each target's documents in a temporary copy, and leaves the library's own build output alone.
199
199
 
200
200
  |`pack [--pre-release <ids>] [--target <t>]... [--out <dir>]`
201
201
  |Archive each built, published target with a `manifest.json`, at the version its version file declares.
@@ -449,23 +449,50 @@ Change the version in the same commit as the fragments instead; `check` and `pub
449
449
 
450
450
  `check` decides, for every published target, whether its version may be published, and fails naming each one that may not.
451
451
  `publish` decides the same before it packs or sends anything, so a target that fails it is never published anywhere.
452
- Both read only git: the target's release tags, its history, and the working tree, changes not yet committed included.
452
+ Both decide from git: the target's release tags, its history, and the working tree, changes not yet committed included.
453
+
454
+ A target's version is the version of its documents, so a target has changed when its documents have: what a consumer receives, not the files it is built from.
455
+ `check` builds the target twice, in a temporary copy of the library: as the baseline below has it, and as the working tree has it.
456
+ It compares the two documents as canonical JSON, without the version stamped on them, so the order of keys does not count either.
457
+
458
+ * An edit that does not reach the documents is no change: a YAML comment, quoting or layout, a part of a shared file the target does not use, or a file that only such a part refers to.
459
+ * An edit that does reach them is a change, wherever it was made: a Markdown snippet a placeholder substitutes, which no `$ref` reaches, counts.
460
+ * Moving a fragment to another directory changes its `x-fragment-path`, so with `fragmentPaths` on it is a change; with it off, and the file name kept, it is not.
461
+ The TranscriberJ names generated classes after that path, so what a consumer gets does differ.
453
462
 
454
463
  [cols="1,3", options="header"]
455
464
  |===
456
465
  |What git tells |What `check` requires
457
466
 
458
467
  |A release tag for this exact version
459
- |The target unchanged since that tag: the version was released from what the tag points at, and publishing it again is safe only with the same specifications.
468
+ |The documents unchanged since that tag: the version was released from what the tag points at, and publishing it again is safe only with the same documents.
460
469
 
461
470
  |Release tags, but none for this version
462
471
  |A version above the latest release; one below it is refused.
463
472
 
464
473
  |No release tags at all
465
- |Compared with the merge-base with the base branch, or with the ref `--since` names: a target changed since then needs a higher version than its version file declared there, and a version lower than it was there is refused.
474
+ |Compared with the merge-base with the base branch, or with the ref `--since` names: a target whose documents changed since then needs a higher version than its version file declared there, and a version lower than it was there is refused.
466
475
  A target whose version file was not there yet is new, and passes.
467
476
  |===
468
477
 
478
+ A version raised over documents that did not change is a warning, not a failure: it announces a change there is not, and consumers will look for one.
479
+ A release can be cut again on purpose, so `check` passes it with `WARNING`, and `publish` logs it and publishes.
480
+
481
+ A refused target comes with a hint at the version it needs:
482
+
483
+ [source,console]
484
+ ----
485
+ orders 2.1.0 VIOLATION orders changed since the merge-base with main (3f2a91c0d4e1), but its version is still 2.1.0: raise it in specs/openapi/bundles/orders.bundle.properties; hint: 2.2.0, a minor bump: something was added at /components/schemas/Refund
486
+ ----
487
+
488
+ The hint sorts each difference between the two documents and takes the largest: documentation only -- a description, summary, title or example, or a fragment moved under the same file name -- is a patch; something added is a minor; something removed or changed, or a constraint such as `required`, `pattern` or `maxLength` added, is a major.
489
+ Below 1.0 each moves one place down: a major becomes a minor, a minor a patch.
490
+ It is a hint, not a compatibility analysis: it may call a compatible change a major, and the version you choose is yours.
491
+
492
+ The baseline is built with its own `apionly.yaml` and its own toolchain pin, so it is the document that was released.
493
+ When that pin cannot be had -- a sealed image carries only today's bundler, and downloading is off -- the baseline is built with today's pin instead, and the reason says so.
494
+ A baseline that cannot be built at all leaves the target `undecided`, saying why.
495
+
469
496
  Release tags are named by `versioning.tagFormat`, `{target}-v{version}` by default: `orders-v2.1.0`.
470
497
  The Publisher never creates them: the pipeline that publishes does, after it has published, because a tool that pushes to git needs write access to the repository it builds from.
471
498
  The reference pipelines show the step, and the `gitlab-package` channel's `release: true` creates the tag with the release.
@@ -585,10 +612,12 @@ Raise the version, commit, and push again.
585
612
 
586
613
  Some things to know about the hook:
587
614
 
588
- * It checks every published target, not only those the pushed commits touch: a target that did not change passes, and the whole check takes seconds.
615
+ * It checks every published target, not only those the pushed commits touch: a target that did not change passes.
616
+ The check builds each target twice, so it takes about as long as two builds.
589
617
  * `check` looks at the working tree, and that includes changes you have not committed yet.
590
618
  Commit or stash them before you push, so it checks what you are actually pushing.
591
- * It needs neither credentials nor a network beyond the `git fetch`, and it builds nothing.
619
+ * It needs no credentials, and no network beyond the `git fetch` once the pinned bundler is on your machine.
620
+ It builds in a temporary copy of the library, and leaves your own build output alone.
592
621
  * `git push --no-verify` skips the hook.
593
622
  The pipeline still runs the same check, so skipping it only means you find out later.
594
623
 
@@ -602,16 +631,16 @@ A change under one target's own fragments affects only that one.
602
631
  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.
603
632
 
604
633
  A target's *closure* is every file reachable from its bundle root by following `$ref`s transitively.
605
- `changed --since <ref>` resolves each closure and reports which targets a change actually touched:
634
+ `changed --since <ref>` reports which targets a change actually reached:
606
635
 
607
636
  [source,console]
608
637
  ----
609
638
  npm run apionly -- changed --since origin/main
610
639
  ----
611
640
 
612
- Two things make this correct rather than approximate.
613
- The closure is computed over the *staged, substituted* tree, so a change to a Markdown snippet, which genuinely changes the published document, counts.
614
- 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.
641
+ It decides as `check` does, by building each target as the ref has it and as the working tree has it, and comparing the documents (<<version-guard,A changed target needs a new version §>>).
642
+ A closure is coarser than that: a file is in it as soon as any part of it is reached, so an edit to a part of a shared file a target does not use touches its closure, but not its documents.
643
+ `changed` therefore names a target only when its documents differ, and still reports the files of its closure that git saw change.
615
644
 
616
645
  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.
617
646
 
@@ -1984,7 +2013,7 @@ Choosing the next version is the API designer's responsibility, and this table i
1984
2013
  The policy is the same for both kinds: an AsyncAPI document gains the key exactly as an OpenAPI one does, and the version follows the table above.
1985
2014
 
1986
2015
  Upgrading to a Publisher that stamps the key changes every OpenAPI document that has components built from fragments, and every AsyncAPI document that has fragments at all.
1987
- `changed` does not see that, because a target's closure hash covers the fragments, not the Publisher.
2016
+ `changed` and `check` do not see that, because they build both sides of the comparison with the Publisher that runs them.
1988
2017
  Bump each such target's MINOR version in the upgrade's commit, or set `fragmentPaths: false` for the kind until you do: publishing a changed document under a version already published is refused by every Subscriber that locked it (see <<versions,Versions §>>).
1989
2018
 
1990
2019
  [#distribution]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arc-e-tect/api-only-publisher",
3
- "version": "0.18.6",
3
+ "version": "0.19.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",
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+
3
+ // Which part of a version a change to a target's documents most likely asks for.
4
+ //
5
+ // A hint, not an analysis: it walks the two documents and sorts each difference into
6
+ // documentation (patch), something added (minor), or something removed or changed
7
+ // (major), and the largest one wins. It knows no compatibility rules beyond that, so a
8
+ // change it calls a major may well be compatible; deciding that properly is #100.
9
+ //
10
+ // Below 1.0 each moves one place down: a breaking change bumps the minor version, an
11
+ // addition the patch version.
12
+
13
+ const { parse } = require("./version-policy");
14
+ const { canonical } = require("./documents");
15
+
16
+ const RANK = { patch: 1, minor: 2, major: 3 };
17
+
18
+ // Keys that only document: changing them changes what a reader is told, not what is accepted.
19
+ const DOCUMENTATION = new Set([
20
+ "description", "summary", "title", "example", "examples", "externalDocs",
21
+ "contact", "license", "termsOfService", "x-fragment-path",
22
+ ]);
23
+
24
+ // Keys that narrow what is accepted: adding one is no addition.
25
+ const CONSTRAINTS = new Set([
26
+ "required", "enum", "const", "type", "format", "pattern", "minimum", "maximum",
27
+ "exclusiveMinimum", "exclusiveMaximum", "multipleOf", "minLength", "maxLength",
28
+ "minItems", "maxItems", "uniqueItems", "minProperties", "maxProperties", "additionalProperties",
29
+ ]);
30
+
31
+ // Maps whose keys are names the specification author chose: a property called
32
+ // `description` is a property, not documentation.
33
+ const NAMES = new Set([
34
+ "properties", "patternProperties", "$defs", "definitions", "paths", "schemas", "responses",
35
+ "parameters", "headers", "requestBodies", "securitySchemes", "links", "callbacks", "pathItems",
36
+ "channels", "messages", "operations", "servers", "variables", "mapping", "content",
37
+ ]);
38
+
39
+ function isObject(value) {
40
+ return value !== null && typeof value === "object" && !Array.isArray(value);
41
+ }
42
+
43
+ function pointer(at) {
44
+ return "/" + at.map((part) => String(part).replace(/~/g, "~0").replace(/\//g, "~1")).join("/");
45
+ }
46
+
47
+ function basename(file) {
48
+ return String(file).split("/").pop();
49
+ }
50
+
51
+ /** Every difference between `before` and `after`, as {bump, at, what}. */
52
+ function differences(before, after, at = [], parentKey = null, found = []) {
53
+ const key = at.length > 0 ? at[at.length - 1] : null;
54
+ const isKeyword = key !== null && typeof key === "string" && !NAMES.has(parentKey);
55
+ const keyword = isKeyword ? key : null;
56
+
57
+ if (canonical(before) === canonical(after)) return found;
58
+ if (keyword === "x-fragment-path") {
59
+ const renamed = before !== undefined && after !== undefined && basename(before) !== basename(after);
60
+ found.push(renamed
61
+ ? { bump: "major", at, what: "a fragment was renamed, which renames its generated class" }
62
+ : { bump: "patch", at, what: "a fragment moved" });
63
+ return found;
64
+ }
65
+ if (DOCUMENTATION.has(keyword)) {
66
+ found.push({ bump: "patch", at, what: "documentation changed" });
67
+ return found;
68
+ }
69
+ if (CONSTRAINTS.has(keyword)) {
70
+ found.push({ bump: "major", at, what: "a constraint was added, removed or changed" });
71
+ return found;
72
+ }
73
+ if (before === undefined) {
74
+ found.push({ bump: "minor", at, what: "something was added" });
75
+ return found;
76
+ }
77
+ if (after === undefined) {
78
+ found.push({ bump: "major", at, what: "something was removed or changed" });
79
+ return found;
80
+ }
81
+ if (isObject(before) && isObject(after)) {
82
+ for (const k of new Set([...Object.keys(before), ...Object.keys(after)])) {
83
+ differences(before[k], after[k], [...at, k], key, found);
84
+ }
85
+ return found;
86
+ }
87
+ if (Array.isArray(before) && Array.isArray(after)) {
88
+ const kept = before.every((item, i) => i < after.length && canonical(item) === canonical(after[i]));
89
+ found.push(kept
90
+ ? { bump: "minor", at: [...at, before.length], what: "something was added" }
91
+ : { bump: "major", at, what: "something was removed or changed" });
92
+ return found;
93
+ }
94
+ found.push({ bump: "major", at, what: "something was removed or changed" });
95
+ return found;
96
+ }
97
+
98
+ /**
99
+ * The bump a change from `before` to `after` most likely asks for, from `version`.
100
+ *
101
+ * @param {Object<string, object>} before a target's documents, by kind, at the baseline
102
+ * @param {Object<string, object>} after the same target's documents now
103
+ * @param {string} version the version to bump from
104
+ * @returns {{bump: "major"|"minor"|"patch", version: string, why: string} | null} null when nothing differs
105
+ */
106
+ function bumpHint(before, after, version) {
107
+ const found = [];
108
+ for (const kind of [...new Set([...Object.keys(before), ...Object.keys(after)])].sort()) {
109
+ differences(before[kind], after[kind], [], null, []).forEach((d) => found.push({ ...d, kind }));
110
+ }
111
+ if (found.length === 0) return null;
112
+ const largest = found.reduce((a, b) => (RANK[b.bump] > RANK[a.bump] ? b : a));
113
+
114
+ const v = parse(version);
115
+ let bump = largest.bump;
116
+ if (v.major === 0) bump = bump === "major" ? "minor" : "patch";
117
+ const next = bump === "major" ? `${v.major + 1}.0.0`
118
+ : bump === "minor" ? `${v.major}.${v.minor + 1}.0`
119
+ : `${v.major}.${v.minor}.${v.patch + 1}`;
120
+ const where = largest.at.length > 0 ? ` at ${pointer(largest.at)}` : ` (the ${largest.kind} document)`;
121
+ return { bump, version: next, why: `${largest.what}${where}` };
122
+ }
123
+
124
+ module.exports = { bumpHint };
package/src/changed.js CHANGED
@@ -6,12 +6,17 @@
6
6
  // everything bumps together. Under per-target versioning the release job has to
7
7
  // answer it, or a change to one service's context releases every other service
8
8
  // too, and the version numbers stop meaning anything.
9
+ //
10
+ // A target changed when its documents did (#121): it is built as `since` has it and
11
+ // as the working tree has it, and the two are compared, as the version guard does.
12
+ // The files git saw change in its closure are reported alongside, for the reader.
9
13
 
10
14
  const fs = require("fs");
11
15
  const path = require("path");
12
16
  const { execFileSync } = require("child_process");
13
17
 
14
18
  const { forTargets } = require("./closure");
19
+ const { targetDocuments, sameDocuments } = require("./documents");
15
20
 
16
21
  class ChangedError extends Error {}
17
22
 
@@ -61,7 +66,8 @@ function toSource(config, stagedFile, kinds) {
61
66
  }
62
67
 
63
68
  /**
64
- * @returns {Array<{target, changed: boolean, files: string[], closureSha256: string}>}
69
+ * @returns {Array<{target, changed: boolean, files: string[], closureSha256: string}>} `changed` when the
70
+ * target's documents differ; `files`, the files in its closure git saw change, whether they did or not
65
71
  */
66
72
  function changedSince(config, since, { kinds = ["openapi", "asyncapi"], log = () => {} } = {}) {
67
73
  const repoRoot = real(git(["rev-parse", "--show-toplevel"], config.root).trim());
@@ -75,6 +81,9 @@ function changedSince(config, since, { kinds = ["openapi", "asyncapi"], log = ()
75
81
  );
76
82
 
77
83
  const closures = forTargets(config, kinds);
84
+ const targets = [...closures.keys()];
85
+ const now = targetDocuments(config, targets, { repoRoot }).documents;
86
+ const then = targetDocuments(config, targets, { ref: since, repoRoot }).documents;
78
87
  const results = [];
79
88
 
80
89
  for (const [target, entry] of closures) {
@@ -82,17 +91,20 @@ function changedSince(config, since, { kinds = ["openapi", "asyncapi"], log = ()
82
91
  .map((f) => toSource(config, f, kinds))
83
92
  .filter(Boolean)
84
93
  .map(real);
85
- // A change to the *shape* of a closure -- a bundle gaining or losing a
86
- // $ref -- always means editing a file that is already in the closure, so
87
- // membership changes are caught without diffing membership itself.
88
94
  const touched = [...new Set(sources.filter((f) => diff.has(f)))].sort();
95
+ const mine = now.get(target);
96
+ if (mine.error) throw new ChangedError(`${target} could not be built from the working tree: ${mine.error.message}`);
97
+ const theirs = then.get(target);
98
+ // A target that does not build as `since` has it cannot be shown unchanged.
99
+ if (theirs.error) log(`${target}: could not be built at ${since}, so counted as changed: ${theirs.error.message}`);
100
+ const changed = Boolean(theirs.error) || !sameDocuments(theirs.documents, mine.documents);
89
101
  results.push({
90
102
  target,
91
- changed: touched.length > 0,
103
+ changed,
92
104
  files: touched.map((f) => path.relative(repoRoot, f)),
93
105
  closureSha256: entry.sha256,
94
106
  });
95
- log(`${target.padEnd(18)} ${touched.length > 0 ? "changed" : "unchanged"} ${entry.sha256.slice(0, 12)}`);
107
+ log(`${target.padEnd(18)} ${changed ? "changed" : "unchanged"} ${entry.sha256.slice(0, 12)}`);
96
108
  }
97
109
  return results;
98
110
  }
package/src/cli.js CHANGED
@@ -552,6 +552,9 @@ async function main(argv, io = {}) {
552
552
  for (const r of checked.filter((c) => c.verdict === "undecided")) {
553
553
  log(`warning: ${r.target} ${r.version}: version not checked: ${r.reason}`);
554
554
  }
555
+ for (const r of checked.filter((c) => c.verdict === "warning")) {
556
+ log(`warning: ${r.target} ${r.version}: ${r.reason}`);
557
+ }
555
558
  } catch (error) {
556
559
  if (!(error instanceof GuardError && error.notARepository)) throw error;
557
560
  log(`warning: versions not checked: ${error.message}`);
@@ -0,0 +1,150 @@
1
+ "use strict";
2
+
3
+ // A target's documents, as a consumer receives them.
4
+ //
5
+ // Whether a target changed is a question about what it publishes, not about which files
6
+ // git saw change (#121). A comment, a reformatted fragment, a part of a shared file the
7
+ // target never reaches, a fragment moved where the bundle does not show it: none of these
8
+ // changes the document, and none should ask for a new version. A placeholder snippet no
9
+ // $ref reaches does change it, and should.
10
+ //
11
+ // So the documents are built -- from the working tree, or from a ref -- in a copy of the
12
+ // library made for the purpose, never in the library's own build directories, and
13
+ // compared as canonical JSON without the version stamped on them.
14
+
15
+ const fs = require("fs");
16
+ const os = require("os");
17
+ const path = require("path");
18
+ const { execFileSync } = require("child_process");
19
+ const YAML = require("yaml");
20
+
21
+ const { load } = require("./config");
22
+ const toolchain = require("./toolchain");
23
+
24
+ /** RFC 8785-style canonical JSON: keys sorted at every level, lists in their order. */
25
+ function canonical(value) {
26
+ if (value === undefined) return "undefined";
27
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
28
+ if (value !== null && typeof value === "object") {
29
+ return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(",")}}`;
30
+ }
31
+ return JSON.stringify(value);
32
+ }
33
+
34
+ /** A built document's content, without the version stamped on it. */
35
+ function documentOf(text) {
36
+ const document = YAML.parse(text);
37
+ if (document && document.info && typeof document.info === "object") delete document.info.version;
38
+ return document;
39
+ }
40
+
41
+ /** Whether two targets' documents, by kind, are the same. */
42
+ function sameDocuments(a, b) {
43
+ return canonical(a) === canonical(b);
44
+ }
45
+
46
+ function git(args, cwd, options = {}) {
47
+ return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"], maxBuffer: 1 << 30, ...options });
48
+ }
49
+
50
+ /** Copy the library as the working tree has it: tracked and untracked files git does not ignore. */
51
+ function exportWorkingTree(repoRoot, libRel, into) {
52
+ const listed = git(["ls-files", "-z", "-c", "-o", "--exclude-standard", "--", libRel || "."], repoRoot)
53
+ .toString("utf8").split("\0").filter(Boolean);
54
+ for (const rel of [...new Set(listed)]) {
55
+ const from = path.join(repoRoot, rel);
56
+ let stat;
57
+ try {
58
+ stat = fs.lstatSync(from);
59
+ } catch {
60
+ continue; // tracked, but deleted in the working tree
61
+ }
62
+ const to = path.join(into, rel);
63
+ fs.mkdirSync(path.dirname(to), { recursive: true });
64
+ if (stat.isSymbolicLink()) fs.symlinkSync(fs.readlinkSync(from), to);
65
+ else if (stat.isFile()) fs.copyFileSync(from, to);
66
+ }
67
+ }
68
+
69
+ /** Copy the library as `ref` has it. */
70
+ function exportRef(repoRoot, ref, libRel, into) {
71
+ const archive = git(["archive", "--format=tar", ref, "--", libRel || "."], repoRoot);
72
+ execFileSync("tar", ["-x", "-f", "-", "-C", into], { input: archive, stdio: ["pipe", "ignore", "pipe"] });
73
+ }
74
+
75
+ /**
76
+ * Pin each tool of the copy's configuration to what this installation can run: its own
77
+ * pin when that can be had, otherwise the one `current` pins. A baseline released with an
78
+ * older bundler than the sealed image carries is still compared, and the reason says how.
79
+ */
80
+ function usableToolchain(copy, current, kinds) {
81
+ const notes = [];
82
+ for (const kind of kinds) {
83
+ if (copy.targetsFor(kind).length === 0) continue;
84
+ const key = toolchain.TOOL_OF_KIND[kind];
85
+ const own = copy.toolchain && copy.toolchain[key];
86
+ try {
87
+ toolchain.resolve(key, copy.tool(key));
88
+ } catch (error) {
89
+ const today = current.tool(key);
90
+ if (!own || own === today) throw error;
91
+ toolchain.resolve(key, today);
92
+ copy.toolchain[key] = today;
93
+ notes.push(`built with ${today}, as ${own} cannot be had`);
94
+ }
95
+ }
96
+ return notes;
97
+ }
98
+
99
+ /**
100
+ * Each target's documents, by kind, built from `ref` -- or from the working tree when
101
+ * `ref` is null -- in a copy of the library.
102
+ *
103
+ * A target that does not build is reported with its error rather than failing the rest;
104
+ * so is every target when the copy's configuration cannot be read.
105
+ *
106
+ * @param {object} config the library's loaded configuration
107
+ * @param {string[]} targets
108
+ * @param {{ref?: string|null, repoRoot: string}} options
109
+ * @returns {{documents: Map<string, {documents?: object, error?: Error}>, notes: string[]}}
110
+ */
111
+ function targetDocuments(config, targets, { ref = null, repoRoot }) {
112
+ // Required here, not at the top: the pipeline requires modules that require this one.
113
+ const { build } = require("./pipeline");
114
+ const libRel = path.relative(repoRoot, fs.realpathSync(config.root)).split(path.sep).join("/");
115
+ const configRel = path.relative(fs.realpathSync(config.root), fs.realpathSync(config.path));
116
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "aop-documents-"));
117
+ const documents = new Map();
118
+ let notes = [];
119
+ try {
120
+ let copy;
121
+ try {
122
+ if (ref === null) exportWorkingTree(repoRoot, libRel, scratch);
123
+ else exportRef(repoRoot, ref, libRel, scratch);
124
+ copy = load(path.join(scratch, libRel, configRel));
125
+ notes = usableToolchain(copy, config, ["openapi", "asyncapi"]);
126
+ } catch (error) {
127
+ for (const target of targets) documents.set(target, { error });
128
+ return { documents, notes };
129
+ }
130
+ for (const target of targets) {
131
+ if (!copy.targets[target]) {
132
+ documents.set(target, { documents: {} });
133
+ continue;
134
+ }
135
+ try {
136
+ const built = build(copy, { targets: [target], verify: false, distribute: false });
137
+ const byKind = {};
138
+ for (const result of built) byKind[result.kind] = documentOf(fs.readFileSync(result.file, "utf8"));
139
+ documents.set(target, { documents: byKind });
140
+ } catch (error) {
141
+ documents.set(target, { error });
142
+ }
143
+ }
144
+ return { documents, notes };
145
+ } finally {
146
+ fs.rmSync(scratch, { recursive: true, force: true });
147
+ }
148
+ }
149
+
150
+ module.exports = { canonical, documentOf, sameDocuments, targetDocuments };
package/src/pipeline.js CHANGED
@@ -301,11 +301,15 @@ function prepare(config, { kinds = ["openapi", "asyncapi"], log = () => {} } = {
301
301
  * Build every requested target.
302
302
  *
303
303
  * `versionOf(target)` names the version to stamp on a target's documents; a target
304
- * it returns nothing for keeps the version its source declares.
304
+ * it returns nothing for keeps the version its source declares. Without `verify`, the
305
+ * documents are not linted and their examples not checked; without `distribute`, they
306
+ * are copied nowhere -- which is how the version guard builds a document only to read it.
305
307
  *
306
308
  * @returns {Array<{target, kind, file, distributed}>}
307
309
  */
308
- function build(config, { targets, versionOf = () => null, kinds = ["openapi", "asyncapi"], log = () => {} } = {}) {
310
+ function build(config, {
311
+ targets, versionOf = () => null, kinds = ["openapi", "asyncapi"], log = () => {}, verify = true, distribute: copy = true,
312
+ } = {}) {
309
313
  // Every tool this build needs is found before anything is staged, so a toolchain
310
314
  // the installation cannot run fails the build before it has done any work.
311
315
  const needed = kinds.filter((kind) => config.targetsFor(kind).some((t) => !targets || targets.includes(t)));
@@ -355,13 +359,15 @@ function build(config, { targets, versionOf = () => null, kinds = ["openapi", "a
355
359
  log(`-- Stamping version '${version}'`);
356
360
  stampFile(outFile, version);
357
361
  }
358
- lint(config, kind, outFile, log);
359
- checkExamples(config, kind, outFile, log);
362
+ if (verify) {
363
+ lint(config, kind, outFile, log);
364
+ checkExamples(config, kind, outFile, log);
365
+ }
360
366
 
361
367
  let distributed = null;
362
- if (config.isPublished(target)) {
368
+ if (copy && config.isPublished(target)) {
363
369
  distributed = distribute(config, target, kind, outFile, log);
364
- } else {
370
+ } else if (copy) {
365
371
  log("-- Not distributed (publish: false)");
366
372
  }
367
373
  results.push({ target, kind, file: outFile, distributed });
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
 
3
- // A changed specification comes with a changed version.
3
+ // A changed specification comes with a changed version, and an unchanged one does not.
4
4
  //
5
5
  // A subscriber locks a version's documents by their hashes, so a version published again
6
6
  // with other documents breaks every build that already locked it. The registries refuse
@@ -8,6 +8,12 @@
8
8
  // already hold it; this guard decides it from git alone, before anything is packed, so a
9
9
  // pull request can fail on it.
10
10
  //
11
+ // "Changed" means the documents a consumer receives changed (#121): the target is built
12
+ // as it was released and as it is now, and the two are compared. An edit that does not
13
+ // reach the documents needs no new version, and a new version over the same documents is
14
+ // a warning, since it announces a change there is not. A refused target carries a hint
15
+ // at the version it needs.
16
+ //
11
17
  // What a version was released from is known from the target's release tags, which the
12
18
  // pipeline creates after it publishes (`versioning.tagFormat`); the Publisher only reads
13
19
  // them. A target with no release tag yet is compared with the merge-base with the main
@@ -17,8 +23,8 @@ const fs = require("fs");
17
23
  const path = require("path");
18
24
  const { execFileSync } = require("child_process");
19
25
 
20
- const { prepare } = require("./pipeline");
21
- const { changedSince } = require("./changed");
26
+ const { targetDocuments, sameDocuments } = require("./documents");
27
+ const { bumpHint } = require("./bump-hint");
22
28
  const { versionOf, parseProperties } = require("./bundle-version");
23
29
  const { compare, isPrerelease, SEMVER } = require("./version-policy");
24
30
 
@@ -81,14 +87,17 @@ function versionAt(ref, rel, repoRoot) {
81
87
 
82
88
  /**
83
89
  * Whether each target's version may be published: a version released before, from other
84
- * specifications, may not; nor one lower than what came before it.
90
+ * documents, may not; nor one lower than what came before it. A version raised over the
91
+ * same documents may, with a warning.
85
92
  *
86
93
  * @param {object} config a loaded configuration
87
94
  * @param {string[]} targets the targets to judge; each needs a version file
88
95
  * @param {{preRelease?: string|null, since?: string|null}} options
89
- * A target the tags do not decide, with no merge-base to compare with, is `undecided`.
96
+ * A target the tags do not decide, with no merge-base to compare with, is `undecided`;
97
+ * so is one whose documents cannot be built as they were at the baseline.
90
98
  *
91
- * @returns {Array<{target: string, version: string, verdict: "ok"|"violation"|"undecided", reason: string}>}
99
+ * @returns {Array<{target: string, version: string, verdict: "ok"|"warning"|"violation"|"undecided", reason: string,
100
+ * hint: {bump: string, version: string, why: string}|null}>}
92
101
  */
93
102
  function guard(config, targets, { preRelease = null, since = null } = {}) {
94
103
  const top = tryGit(["rev-parse", "--show-toplevel"], config.root);
@@ -101,14 +110,28 @@ function guard(config, targets, { preRelease = null, since = null } = {}) {
101
110
  // Real paths on both sides: git answers with the canonical one, and a checkout reached
102
111
  // through a symlink -- /var on macOS -- would otherwise never match.
103
112
  const repoRoot = fs.realpathSync(top);
104
- prepare(config);
105
113
 
106
- const changes = new Map();
107
- const changedSinceRef = (ref, target) => {
108
- if (!changes.has(ref)) {
109
- changes.set(ref, new Map(changedSince(config, ref).map((r) => [r.target, r.changed])));
110
- }
111
- return changes.get(ref).get(target) === true;
114
+ // Each tree is built once, for every target, and only when a verdict needs it.
115
+ let current = null;
116
+ const baselines = new Map();
117
+ const builtAt = (ref) => {
118
+ if (ref === null) return current || (current = targetDocuments(config, targets, { repoRoot }));
119
+ if (!baselines.has(ref)) baselines.set(ref, targetDocuments(config, targets, { ref, repoRoot }));
120
+ return baselines.get(ref);
121
+ };
122
+ /** The target's documents now against those at `ref`: {changed, hint, note} or, when `ref` does not build, {error}. */
123
+ const compareWith = (ref, target, from) => {
124
+ const mine = builtAt(null).documents.get(target);
125
+ if (mine.error) throw new GuardError(`${target} could not be built from the working tree: ${mine.error.message}`);
126
+ const baseline = builtAt(ref);
127
+ const theirs = baseline.documents.get(target);
128
+ if (theirs.error) return { error: theirs.error };
129
+ const changed = !sameDocuments(theirs.documents, mine.documents);
130
+ return {
131
+ changed,
132
+ hint: changed ? bumpHint(theirs.documents, mine.documents, from) : null,
133
+ note: baseline.notes.length > 0 ? ` (${baseline.notes.join("; ")})` : "",
134
+ };
112
135
  };
113
136
  let fallback = null;
114
137
 
@@ -116,16 +139,23 @@ function guard(config, targets, { preRelease = null, since = null } = {}) {
116
139
  const declared = versionOf(config, target);
117
140
  const version = versionOf(config, target, { preRelease });
118
141
  const file = path.relative(repoRoot, fs.realpathSync(config.versionFile(target))).split(path.sep).join("/");
119
- const ok = (reason) => ({ target, version, verdict: "ok", reason });
120
- const violation = (reason) => ({ target, version, verdict: "violation", reason });
142
+ const result = (verdict, reason, hint = null) => ({ target, version, verdict, reason, hint });
143
+ const ok = (reason) => result("ok", reason);
144
+ const warning = (reason) => result("warning", reason);
145
+ const undecided = (reason) => result("undecided", reason);
146
+ const violation = (reason, hint = null) => result("violation",
147
+ hint ? `${reason}; hint: ${hint.version}, a ${hint.bump} bump: ${hint.why}` : reason, hint);
148
+ const unbuildable = (label, error) => undecided(`${target} could not be built at ${label}: ${error.message}`);
121
149
  const tags = releaseTags(config, target, repoRoot);
122
150
 
123
151
  // Released before: exact, whatever else is true.
124
152
  if (tags.has(version)) {
125
153
  const tag = tags.get(version);
126
- return changedSinceRef(tag, target)
127
- ? violation(`${version} was released as ${tag}, and ${target} has changed since: raise the version in ${file}`)
128
- : ok(`unchanged since its release tag ${tag}`);
154
+ const c = compareWith(tag, target, declared);
155
+ if (c.error) return unbuildable(`its release tag ${tag}`, c.error);
156
+ return c.changed
157
+ ? violation(`${version} was released as ${tag}, and ${target} has changed since${c.note}: raise the version in ${file}`, c.hint)
158
+ : ok(`unchanged since its release tag ${tag}${c.note}`);
129
159
  }
130
160
  if (preRelease && tags.has(declared)) {
131
161
  return violation(`${version} is a pre-release of ${declared}, which is already released as ${tags.get(declared)}: ` +
@@ -134,24 +164,38 @@ function guard(config, targets, { preRelease = null, since = null } = {}) {
134
164
  const releases = [...tags.keys()].filter((v) => !isPrerelease(v)).sort(compare);
135
165
  if (releases.length > 0) {
136
166
  const latest = releases[releases.length - 1];
137
- return compare(declared, latest) < 0
138
- ? violation(`${declared} is lower than its latest release, ${latest} (${tags.get(latest)}): raise the version in ${file}`)
139
- : ok(`${declared} is above its latest release, ${latest}`);
167
+ const tag = tags.get(latest);
168
+ if (compare(declared, latest) < 0) {
169
+ return violation(`${declared} is lower than its latest release, ${latest} (${tag}): raise the version in ${file}`);
170
+ }
171
+ const c = compareWith(tag, target, latest);
172
+ if (c.error) return ok(`${declared} is above its latest release, ${latest}; not compared with it: ${c.error.message}`);
173
+ return c.changed
174
+ ? ok(`${declared} is above its latest release, ${latest}${c.note}`)
175
+ : warning(`${declared} is above its latest release, ${latest} (${tag}), but ${target}'s documents are ` +
176
+ `the same as that release's${c.note}: a new version announces a change there is not`);
140
177
  }
141
178
 
142
179
  // Never released, as far as the tags tell: the merge-base, or --since, decides.
143
180
  fallback = fallback || fallbackBaseline(config, repoRoot, since);
144
- if (fallback.missing) return { target, version, verdict: "undecided", reason: fallback.missing };
181
+ if (fallback.missing) return undecided(fallback.missing);
145
182
  const before = versionAt(fallback.ref, file, repoRoot);
146
183
  if (before === null) return ok(`new: no version file at ${fallback.label}`);
147
184
  if (compare(declared, before) < 0) {
148
185
  return violation(`${declared} is lower than ${before} at ${fallback.label}: raise the version in ${file}`);
149
186
  }
150
- if (!changedSinceRef(fallback.ref, target)) return ok(`unchanged since ${fallback.label}`);
151
- if (compare(declared, before) > 0) return ok(`${before} -> ${declared} since ${fallback.label}`);
187
+ const c = compareWith(fallback.ref, target, declared);
188
+ if (c.error) return unbuildable(fallback.label, c.error);
189
+ if (!c.changed) {
190
+ return compare(declared, before) > 0
191
+ ? warning(`${declared} is above ${before} at ${fallback.label}, but ${target}'s documents are unchanged ` +
192
+ `since${c.note}: a new version announces a change there is not`)
193
+ : ok(`unchanged since ${fallback.label}${c.note}`);
194
+ }
195
+ if (compare(declared, before) > 0) return ok(`${before} -> ${declared} since ${fallback.label}${c.note}`);
152
196
  if (preRelease) return ok(`pre-release ${version}: its identifiers make it a new version`);
153
- return violation(`${target} changed since ${fallback.label}, but its version is still ${declared}: ` +
154
- `raise it in ${file}`);
197
+ return violation(`${target} changed since ${fallback.label}${c.note}, but its version is still ${declared}: ` +
198
+ `raise it in ${file}`, c.hint);
155
199
  });
156
200
  }
157
201