@shortlink-org/portolan 0.2.4 → 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.
Files changed (151) hide show
  1. package/README.md +23 -0
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1066 -0
  5. package/catalog/roundtrip_test.go +203 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +8 -0
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +14 -0
  11. package/go.sum +20 -0
  12. package/internal/gocall/README.md +19 -0
  13. package/internal/gocall/analyze.go +189 -0
  14. package/internal/gocall/analyze_test.go +107 -0
  15. package/internal/gohttp/analyze.go +2562 -0
  16. package/internal/gohttp/destination.go +373 -0
  17. package/internal/gohttp/endpoints.go +1067 -0
  18. package/internal/gohttp/roots.go +320 -0
  19. package/internal/gohttp/typed.go +96 -0
  20. package/internal/goscan/constants.go +85 -0
  21. package/internal/goscan/goscan_test.go +227 -0
  22. package/internal/goscan/index.go +629 -0
  23. package/internal/goscan/index_test.go +66 -0
  24. package/internal/goscan/names.go +52 -0
  25. package/internal/goscan/parse_test.go +11 -0
  26. package/internal/goscan/source.go +37 -0
  27. package/internal/goscan/tree.go +284 -0
  28. package/internal/goscan/types.go +99 -0
  29. package/internal/wsdl/ids.go +127 -0
  30. package/internal/wsdl/ids_test.go +21 -0
  31. package/internal/wsdl/model.go +70 -0
  32. package/internal/wsdl/parse.go +949 -0
  33. package/internal/wsdl/parse_test.go +170 -0
  34. package/package.json +22 -10
  35. package/plugin/describe.go +118 -0
  36. package/plugin/describe_test.go +114 -0
  37. package/plugin/protocol.go +141 -0
  38. package/plugin/schematest/schematest.go +126 -0
  39. package/plugins/README.md +109 -46
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-celery/extract.py +0 -2
  42. package/plugins/extract-celery/extract_test.py +1 -1
  43. package/plugins/extract-django/README.md +39 -17
  44. package/plugins/extract-django/domain.py +28 -17
  45. package/plugins/extract-django/extract.py +21 -7
  46. package/plugins/extract-django/extract_test.py +55 -2
  47. package/plugins/extract-django/lifecycle.py +2 -0
  48. package/plugins/extract-django/operations.py +1 -1
  49. package/plugins/extract-django/routing_test.py +109 -1
  50. package/plugins/extract-django/store.py +1 -1
  51. package/plugins/extract-django/transport.py +101 -55
  52. package/plugins/extract-django/verbs.py +241 -0
  53. package/plugins/extract-go/README.md +47 -0
  54. package/plugins/extract-http-clients/describe.go +19 -0
  55. package/plugins/extract-http-clients/describe_test.go +11 -0
  56. package/plugins/extract-http-clients/extract.go +740 -0
  57. package/plugins/extract-http-clients/extract_test.go +1561 -0
  58. package/plugins/extract-http-clients/main.go +41 -0
  59. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  60. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  61. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  62. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  63. package/plugins/extract-python-kafka/extract.py +0 -2
  64. package/plugins/extract-python-kafka/extract_test.py +1 -1
  65. package/plugins/extract-ts/extract.test.ts +2 -2
  66. package/plugins/extract-ts/extract.ts +4 -5
  67. package/plugins/extract-ts/graphql.test.ts +1 -1
  68. package/plugins/openapi/ids.go +261 -0
  69. package/plugins/openapi/ids_test.go +98 -0
  70. package/plugins/portolan-go.wasm +0 -0
  71. package/plugins/pyplugin/protocol.py +1 -5
  72. package/portolan.json +3 -2
  73. package/schema/portolan.schema.json +34 -0
  74. package/scripts/README.md +18 -12
  75. package/scripts/catalog-sources.mjs +6 -0
  76. package/scripts/delivery-presets.mjs +21 -11
  77. package/scripts/diff.mjs +5 -1
  78. package/scripts/django-aggregates.test.mjs +58 -0
  79. package/scripts/gen-likec4.mjs +1 -1
  80. package/scripts/gen.mjs +118 -115
  81. package/scripts/go-discovery.test.mjs +30 -0
  82. package/scripts/history.mjs +186 -3
  83. package/scripts/history.test.mjs +1 -1
  84. package/scripts/host-plugins/fetch-git.mjs +77 -21
  85. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  86. package/scripts/local-api.mjs +71 -4
  87. package/scripts/local-api.test.mjs +63 -4
  88. package/scripts/local-discovery.mjs +82 -9
  89. package/scripts/manifest.mjs +5 -3
  90. package/scripts/manifest.test.mjs +24 -0
  91. package/scripts/output-diff.mjs +94 -0
  92. package/scripts/output-diff.test.mjs +36 -0
  93. package/scripts/package-smoke.mjs +62 -4
  94. package/scripts/plugin-host.mjs +22 -2
  95. package/scripts/plugin-host.test.mjs +9 -0
  96. package/scripts/plugin-wasm-worker.mjs +4 -1
  97. package/scripts/provenance.mjs +72 -0
  98. package/scripts/provenance.test.mjs +149 -0
  99. package/scripts/run-builtin.mjs +39 -5
  100. package/scripts/schema.mjs +29 -0
  101. package/scripts/warning-policy.mjs +161 -0
  102. package/scripts/warning-policy.test.mjs +56 -0
  103. package/src/app/Sidebar.tsx +3 -3
  104. package/src/catalog-docs.test.ts +64 -0
  105. package/src/catalog-docs.ts +35 -0
  106. package/src/catalog-error.test.ts +15 -0
  107. package/src/catalog-model.ts +48 -5
  108. package/src/catalog-validation.ts +9 -0
  109. package/src/chat/Starter.tsx +5 -11
  110. package/src/chat/tools.test.ts +27 -0
  111. package/src/chat/tools.ts +5 -9
  112. package/src/components/CatalogStamp.tsx +10 -8
  113. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  114. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  115. package/src/components/Integrations.tsx +1 -1
  116. package/src/components/MachineDocs.tsx +6 -5
  117. package/src/components/MethodRows.tsx +9 -2
  118. package/src/components/RelationEvidence.test.tsx +14 -0
  119. package/src/components/RelationEvidence.tsx +53 -0
  120. package/src/data.ts +25 -7
  121. package/src/enrich.test.ts +332 -1
  122. package/src/enrich.ts +206 -3
  123. package/src/flow/StepDetail.tsx +6 -0
  124. package/src/flow/evidence.test.ts +16 -0
  125. package/src/flow/evidence.ts +34 -0
  126. package/src/index.css +44 -0
  127. package/src/landing/DraggableReveal.tsx +3 -2
  128. package/src/landing/EvidencePipeline.tsx +105 -0
  129. package/src/landing/LandingPage.tsx +2 -59
  130. package/src/lib/catalog-diff.ts +1 -1
  131. package/src/lib/django-aggregates.d.mts +9 -0
  132. package/src/lib/django-aggregates.mjs +36 -0
  133. package/src/lib/django-aggregates.test.ts +29 -0
  134. package/src/lib/django-aggregates.ts +5 -0
  135. package/src/lib/local-api.ts +20 -2
  136. package/src/lib/setup-info.test.ts +17 -0
  137. package/src/lib/setup-info.ts +58 -0
  138. package/src/lib/warnings.test.ts +54 -0
  139. package/src/lib/warnings.ts +260 -0
  140. package/src/map/ContextMapGraph.tsx +76 -32
  141. package/src/merge.ts +16 -9
  142. package/src/pages/AggregatePage.tsx +8 -7
  143. package/src/pages/ContextPage.tsx +6 -5
  144. package/src/pages/ServicePage.tsx +4 -3
  145. package/src/pages/Settings.tsx +189 -41
  146. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  147. package/src/selection/DetailPanel.tsx +15 -0
  148. package/src/virtual-provenance.d.ts +11 -0
  149. package/vite.config.ts +5 -0
  150. package/scripts/vendor-lock.mjs +0 -58
  151. package/scripts/vendor-lock.test.mjs +0 -69
@@ -1,5 +1,6 @@
1
1
  // When each file was first committed and last changed, read by the host for a
2
- // plugin that asks (portolan.0007).
2
+ // plugin that asks (portolan.0007), and for the provenance of every catalog
3
+ // source (portolan.0010).
3
4
  //
4
5
  // One `git log` over the whole checkout, walked oldest first with renames
5
6
  // followed, gives every path its first commit and its last. A plugin used to
@@ -8,8 +9,8 @@
8
9
  // reads it once and hands each step the part under its root.
9
10
 
10
11
  import { execFileSync } from "node:child_process";
11
- import { realpathSync } from "node:fs";
12
- import { relative, resolve, sep } from "node:path";
12
+ import { realpathSync, statSync } from "node:fs";
13
+ import { dirname, relative, resolve, sep } from "node:path";
13
14
 
14
15
  const RECORD = "\x1e";
15
16
  const FIELD = "\x1f";
@@ -17,6 +18,188 @@ const FIELD = "\x1f";
17
18
  /** Per repository, per process: the history does not change during a run. */
18
19
  const cache = new Map();
19
20
 
21
+ /** Repositories already told they are shallow; it is said once. */
22
+ const warnedShallow = new Set();
23
+
24
+ /**
25
+ * When each of `paths` last changed, as the history says: the commit that
26
+ * last touched the file, and that commit's date.
27
+ *
28
+ * This is where a source's provenance comes from (portolan.0010). It is read
29
+ * off the checkout that holds the file and never written into the file, so a
30
+ * fragment is content and nothing else, and a regeneration that changes no
31
+ * fact changes no byte. The stamp names the commit the change landed in,
32
+ * which a stamp written into the file never could: that one was the parent
33
+ * at best, and it took a second commit to catch up.
34
+ *
35
+ * A file with uncommitted changes, or one git does not track, is
36
+ * `uncommitted` and dated by its mtime - an observation of the working tree,
37
+ * shown and never committed. A path outside any repository is the same. A
38
+ * shallow clone makes every path look as though it changed in the one commit
39
+ * that was fetched; that is said once, and the stamps are what the truncated
40
+ * history says, which is at least the checkout.
41
+ *
42
+ * @param {string} workspace
43
+ * @param {string[]} paths relative to `workspace`, forward slashes
44
+ * @returns {Map<string, {commit: string, generatedAt: string}>}
45
+ */
46
+ export function stampsFor(workspace, paths) {
47
+ const workspaceReal = realpathSync(resolve(workspace));
48
+ const stamps = new Map();
49
+ const repoOfDir = new Map();
50
+ const byRepo = new Map();
51
+
52
+ for (const path of paths) {
53
+ const absolute = resolve(workspaceReal, path);
54
+ const dir = dirname(absolute);
55
+ if (!repoOfDir.has(dir)) repoOfDir.set(dir, repositoryOf(dir));
56
+ const repo = repoOfDir.get(dir);
57
+ if (!repo) {
58
+ stamps.set(path, uncommitted(absolute));
59
+ continue;
60
+ }
61
+ if (!byRepo.has(repo)) byRepo.set(repo, []);
62
+ byRepo.get(repo).push({ path, absolute, key: toPosix(relative(repo, absolute)) });
63
+ }
64
+
65
+ for (const [repo, files] of byRepo) {
66
+ warnIfShallow(repo);
67
+ const history = fileHistory(repo);
68
+ const dirty = dirtyIn(repo);
69
+ for (const file of files) {
70
+ const entry = history.get(file.key);
71
+ const last = entry?.revised ?? entry?.created;
72
+ stamps.set(
73
+ file.path,
74
+ !last || dirty.has(file.key)
75
+ ? uncommitted(file.absolute)
76
+ : { commit: last.commit.slice(0, 7), generatedAt: last.date },
77
+ );
78
+ }
79
+ }
80
+
81
+ return stamps;
82
+ }
83
+
84
+ /**
85
+ * The commit that last touched any of `paths`, and its date, or null when
86
+ * none of them has been committed. For a step's output this is when it was
87
+ * last generated: the history remembers it, so nothing has to write it down
88
+ * anywhere else (portolan.0010).
89
+ *
90
+ * @param {string} workspace
91
+ * @param {string[]} paths relative to `workspace`
92
+ * @returns {{commit: string, date: string} | null}
93
+ */
94
+ export function lastCommitTouching(workspace, paths) {
95
+ const { repo, keys } = inRepo(workspace, paths);
96
+ if (!repo || keys.length === 0) return null;
97
+ const answer = git(repo, ["log", "-1", "--format=%H %cI", "--", ...keys]);
98
+ if (!answer) return null;
99
+ const [sha, date] = answer.split(" ");
100
+ // Seven characters, the width every stamp has, so the two read alike.
101
+ return sha && date ? { commit: sha.slice(0, 7), date } : null;
102
+ }
103
+
104
+ /**
105
+ * Which files under `paths` differ from what `commit` held, working tree
106
+ * included: modified, added, deleted and untracked, less anything under
107
+ * `excludes`. Paths in and out are relative to `workspace`.
108
+ *
109
+ * @param {string} workspace
110
+ * @param {string} commit
111
+ * @param {string[]} paths
112
+ * @param {string[]} [excludes]
113
+ * @returns {string[]}
114
+ */
115
+ export function changedSince(workspace, commit, paths, excludes = []) {
116
+ const { repo, keys, toWorkspace } = inRepo(workspace, paths);
117
+ if (!repo || keys.length === 0) return [];
118
+ const spec = [...keys, ...inRepo(workspace, excludes).keys.map((key) => `:(exclude)${key}`)];
119
+ const tracked = git(repo, ["diff", "--name-only", commit, "--", ...spec]);
120
+ const untracked = git(repo, ["ls-files", "--others", "--exclude-standard", "--", ...spec]);
121
+ const changed = new Set();
122
+ for (const line of `${tracked}\n${untracked}`.split("\n")) {
123
+ if (line) changed.add(toWorkspace(line));
124
+ }
125
+ return [...changed].sort();
126
+ }
127
+
128
+ /**
129
+ * The text of `path` as `commit` held it, or "" when the commit did not hold
130
+ * it or there is no history to ask.
131
+ *
132
+ * @param {string} workspace
133
+ * @param {string} commit
134
+ * @param {string} path relative to `workspace`
135
+ * @returns {string}
136
+ */
137
+ export function fileAt(workspace, commit, path) {
138
+ const { repo, keys } = inRepo(workspace, [path]);
139
+ if (!repo || keys.length === 0) return "";
140
+ return git(repo, ["show", `${commit}:${keys[0]}`]);
141
+ }
142
+
143
+ /** The paths as the workspace's repository spells them, and the way back. */
144
+ function inRepo(workspace, paths) {
145
+ const workspaceReal = realpathSync(resolve(workspace));
146
+ const repo = repositoryOf(workspaceReal);
147
+ if (!repo) return { repo: "", keys: [], toWorkspace: (line) => line };
148
+ const keys = [];
149
+ for (const path of paths) {
150
+ const key = toPosix(relative(repo, resolve(workspaceReal, path)));
151
+ if (key.startsWith("..")) continue;
152
+ keys.push(key || ".");
153
+ }
154
+ return {
155
+ repo,
156
+ keys,
157
+ toWorkspace: (line) => toPosix(relative(workspaceReal, resolve(repo, line))),
158
+ };
159
+ }
160
+
161
+ function uncommitted(absolute) {
162
+ let generatedAt = "";
163
+ try {
164
+ generatedAt = statSync(absolute).mtime.toISOString();
165
+ } catch {
166
+ // Gone, or never there: undated rather than dated by a guess.
167
+ }
168
+ return { commit: "uncommitted", generatedAt };
169
+ }
170
+
171
+ /** Every path with a change the history does not hold yet, relative to the repository. */
172
+ function dirtyIn(repo) {
173
+ const dirty = new Set();
174
+ // Read raw: an entry begins with two status letters, and a space is one of
175
+ // them, which the trimming reader would eat off the first line.
176
+ let status = "";
177
+ try {
178
+ status = execFileSync("git", ["-C", repo, "status", "--porcelain", "-z", "--no-renames", "--untracked-files=all"], {
179
+ encoding: "utf8",
180
+ stdio: ["ignore", "pipe", "ignore"],
181
+ maxBuffer: 64 * 1024 * 1024,
182
+ });
183
+ } catch {
184
+ return dirty;
185
+ }
186
+ for (const entry of status.split("\0")) {
187
+ if (entry.length > 3) dirty.add(entry.slice(3));
188
+ }
189
+ return dirty;
190
+ }
191
+
192
+ function warnIfShallow(repo) {
193
+ if (warnedShallow.has(repo)) return;
194
+ warnedShallow.add(repo);
195
+ if (git(repo, ["rev-parse", "--is-shallow-repository"]) !== "true") return;
196
+ console.warn(
197
+ `${repo} is a shallow clone, where every source looks as though it last changed in ` +
198
+ "the one commit that was fetched. Fetch the full history (git fetch --unshallow, " +
199
+ "or actions/checkout with fetch-depth: 0) for the provenance to say anything.",
200
+ );
201
+ }
202
+
20
203
  /**
21
204
  * History for every file under `root`, keyed by the path a plugin would name
22
205
  * it - relative to `workspace`, forward slashes. `undefined` when the root is
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { afterEach, describe, expect, it } from "vitest";
6
6
 
7
- import { fileHistory, forgetHistory, historyFor, parseLog } from "./history.mjs";
7
+ import { changedSince, fileHistory, forgetHistory, historyFor, lastCommitTouching, parseLog, stampsFor } from "./history.mjs";
8
8
 
9
9
  const created = [];
10
10
  afterEach(() => {
@@ -44,6 +44,18 @@ export const LOCK_NAME = "git.lock.json";
44
44
  export const PIN_NAME = "git.repo.json";
45
45
  export const OFFLINE_ENV = "PORTOLAN_OFFLINE";
46
46
 
47
+ const SNIFF_BYTES = 8 * 1024;
48
+ const BINARY_SUFFIXES = [
49
+ ".tar.gz", ".tar.bz2", ".tar.xz",
50
+ ".7z", ".a", ".apk", ".avif", ".bin", ".bmp", ".bz2", ".class",
51
+ ".deb", ".dll", ".dmg", ".doc", ".docx", ".eot", ".exe", ".gif",
52
+ ".gz", ".ico", ".iso", ".jar", ".jpeg", ".jpg", ".lib", ".mmdb",
53
+ ".mov", ".mp3", ".mp4", ".o", ".otf", ".pdf", ".png", ".pyc",
54
+ ".rar", ".so", ".tar", ".tgz", ".tif", ".tiff", ".ttf", ".war",
55
+ ".wasm", ".webm", ".webp", ".woff", ".woff2", ".xls", ".xlsx",
56
+ ".xz", ".zip",
57
+ ];
58
+
47
59
  export function describe() {
48
60
  return {
49
61
  name: "fetch-git",
@@ -105,7 +117,7 @@ export function run(request, { env = process.env } = {}) {
105
117
  }
106
118
  continue;
107
119
  }
108
- emitFetched(out, dir, want, fetched.commit, fetched.files, generatedAt);
120
+ emitFetched(out, dir, want, fetched.commit, fetched.files, fetched.skipped, generatedAt);
109
121
  }
110
122
 
111
123
  return out.response();
@@ -172,9 +184,11 @@ export function pin(repo, commit, generatedAt = "") {
172
184
  /** The lock, written the way every generated file here is written. */
173
185
  export function encodeLock(entry) {
174
186
  const files = [...entry.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
187
+ const skipped = [...(entry.skipped ?? [])].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
175
188
  const record = { repo: entry.repo, commit: entry.commit };
176
189
  if (entry.paths?.length) record.paths = [...entry.paths].sort();
177
190
  record.files = files.map((file) => ({ path: file.path, sha256: file.sha256, size: file.size }));
191
+ if (skipped.length) record.skipped = skipped.map((file) => ({ path: file.path, size: file.size, reason: file.reason }));
178
192
  return `${JSON.stringify({ repos: [record] }, null, 2)}\n`;
179
193
  }
180
194
 
@@ -191,13 +205,15 @@ function live(url, want, out, env) {
191
205
  commit = resolve(url, want.ref, env);
192
206
  out.warn(want.repo, `is not pinned; "${want.ref || "HEAD"}" resolved to ${commit}. Pin it in portolan.json or every run is a lottery.`);
193
207
  }
194
- const files = download(url, commit, want.paths ?? [], env);
195
- if (files.size === 0) throw new Error(`${want.repo} at ${commit} holds no files under ${(want.paths ?? []).join(", ")}`);
196
- return { commit, files };
208
+ const fetched = download(url, commit, want.paths ?? [], env);
209
+ if (fetched.files.size === 0 && fetched.skipped.length === 0) {
210
+ throw new Error(`${want.repo} at ${commit} holds no files under ${(want.paths ?? []).join(", ")}`);
211
+ }
212
+ return { commit, ...fetched };
197
213
  }
198
214
 
199
- function emitFetched(out, dir, want, commit, files, generatedAt) {
200
- const entry = { repo: want.repo, commit, paths: want.paths ?? [], files: [] };
215
+ function emitFetched(out, dir, want, commit, files, skipped, generatedAt) {
216
+ const entry = { repo: want.repo, commit, paths: want.paths ?? [], files: [], skipped };
201
217
  for (const path of [...files.keys()].sort()) {
202
218
  const contents = files.get(path);
203
219
  out.file(posix.join(dir, path), contents);
@@ -205,6 +221,7 @@ function emitFetched(out, dir, want, commit, files, generatedAt) {
205
221
  }
206
222
  out.file(posix.join(dir, LOCK_NAME), encodeLock(entry));
207
223
  out.file(posix.join(dir, PIN_NAME), pin(want.repo, commit, generatedAt));
224
+ warnSkipped(out, want.repo, skipped);
208
225
  }
209
226
 
210
227
  /**
@@ -225,6 +242,13 @@ function emitCached(out, dir, at, want, why, generatedAt = "") {
225
242
  // what is actually on disk rather than what was asked for.
226
243
  out.file(posix.join(dir, PIN_NAME), pin(want.repo, held.lock.commit, generatedAt));
227
244
  out.warn(want.repo, `not fetched (${why}); the copy committed in this repository is used unchanged`);
245
+ warnSkipped(out, want.repo, held.lock.skipped);
246
+ }
247
+
248
+ function warnSkipped(out, repo, skipped = []) {
249
+ if (skipped.length === 0) return;
250
+ const bytes = skipped.reduce((total, file) => total + file.size, 0);
251
+ out.warn(repo, `skipped ${skipped.length} binary ${skipped.length === 1 ? "file" : "files"} (${bytes} bytes); metadata is recorded in ${LOCK_NAME}`);
228
252
  }
229
253
 
230
254
  /**
@@ -252,7 +276,14 @@ function replay(dir) {
252
276
  if (repos.length !== 1) throw new Error(`${lockPath} names ${repos.length} repositories; expected exactly one`);
253
277
  const entry = repos[0];
254
278
  const files = new Map();
279
+ const kept = [];
280
+ const skipped = [...(Array.isArray(entry.skipped) ? entry.skipped : [])];
255
281
  for (const want of entry.files ?? []) {
282
+ const known = knownBinaryReason(want.path);
283
+ if (known) {
284
+ skipped.push({ path: want.path, size: want.size, reason: known });
285
+ continue;
286
+ }
256
287
  let contents;
257
288
  try {
258
289
  contents = readFileSync(join(dir, ...String(want.path).split("/")));
@@ -261,9 +292,15 @@ function replay(dir) {
261
292
  throw cause;
262
293
  }
263
294
  if (digestOf(contents) !== want.sha256) throw new Error(`${want.path} does not match its digest; the vendored copy was edited by hand`);
295
+ const sniffed = sniffedBinaryReason(contents);
296
+ if (sniffed) {
297
+ skipped.push({ path: want.path, size: contents.length, reason: sniffed });
298
+ continue;
299
+ }
264
300
  files.set(want.path, contents);
301
+ kept.push(want);
265
302
  }
266
- return { lock: { repo: entry.repo, commit: entry.commit, paths: entry.paths ?? [], files: entry.files ?? [] }, files };
303
+ return { lock: { repo: entry.repo, commit: entry.commit, paths: entry.paths ?? [], files: kept, skipped }, files };
267
304
  }
268
305
 
269
306
  // --- git --------------------------------------------------------------------
@@ -305,7 +342,7 @@ function resolve(url, ref, env) {
305
342
  * its branches where it does not, which is the case for a plain file://
306
343
  * remote; either way what is read is verified to be that commit.
307
344
  *
308
- * @returns {Map<string, Buffer>} path contents
345
+ * @returns {{files: Map<string, Buffer>, skipped: {path: string, size: number, reason: string}[]}}
309
346
  */
310
347
  function download(url, commit, paths, env) {
311
348
  const tmp = mkdtempSync(join(tmpdir(), "portolan-fetch-git-"));
@@ -326,19 +363,24 @@ function download(url, commit, paths, env) {
326
363
  throw new Error(`${commit} is not a commit ${url} has, or not one reachable from a branch`);
327
364
  }
328
365
 
329
- const listing = git(tmp, ["ls-tree", "-r", "-z", "--format=%(objectname) %(objecttype) %(path)", commit, "--", ...paths], env);
366
+ const listing = git(tmp, ["ls-tree", "-r", "-z", "--format=%(objectname) %(objecttype) %(objectsize) %(path)", commit, "--", ...paths], env);
330
367
  const blobs = [];
368
+ const skipped = [];
331
369
  for (const line of listing.split("\0")) {
332
370
  if (!line) continue;
333
- const [sha, type, ...rest] = line.split(" ");
371
+ const [sha, type, rawSize, ...rest] = line.split(" ");
334
372
  if (type !== "blob") continue;
335
- blobs.push({ sha, path: posix.normalize(rest.join(" ")) });
373
+ const path = posix.normalize(rest.join(" "));
374
+ const size = Number(rawSize);
375
+ const reason = knownBinaryReason(path);
376
+ if (reason) skipped.push({ path, size, reason });
377
+ else blobs.push({ sha, path, size });
336
378
  }
337
379
  const files = new Map();
338
- if (blobs.length === 0) return files;
380
+ if (blobs.length === 0) return { files, skipped };
339
381
 
340
- // One process for every blob: `cat-file --batch` answers each sha with a
341
- // header line and the bytes.
382
+ // One `cat-file --batch` process answers every remaining sha with a header
383
+ // line and the bytes. Known binaries never enter this batch.
342
384
  const batch = execFileSync("git", ["cat-file", "--batch"], {
343
385
  cwd: tmp,
344
386
  env: { ...env, GIT_TERMINAL_PROMPT: "0" },
@@ -352,15 +394,30 @@ function download(url, commit, paths, env) {
352
394
  if (header[1] === "missing") throw new Error(`${blob.path} is missing from ${commit}`);
353
395
  const size = Number(header[2]);
354
396
  const start = newline + 1;
355
- files.set(blob.path, Buffer.from(batch.subarray(start, start + size)));
397
+ const contents = batch.subarray(start, start + size);
398
+ const reason = sniffedBinaryReason(contents);
399
+ if (reason) skipped.push({ path: blob.path, size, reason });
400
+ else files.set(blob.path, Buffer.from(contents));
356
401
  offset = start + size + 1;
357
402
  }
358
- return files;
403
+ return { files, skipped };
359
404
  } finally {
360
405
  rmSync(tmp, { recursive: true, force: true });
361
406
  }
362
407
  }
363
408
 
409
+ function knownBinaryReason(path) {
410
+ const lower = String(path).toLowerCase();
411
+ const suffix = BINARY_SUFFIXES.find((candidate) => lower.endsWith(candidate));
412
+ return suffix ? `known binary extension ${suffix}` : "";
413
+ }
414
+
415
+ function sniffedBinaryReason(contents) {
416
+ if (contents.subarray(0, SNIFF_BYTES).includes(0)) return `NUL byte in first ${SNIFF_BYTES} bytes`;
417
+ const text = contents.toString("utf8");
418
+ return Buffer.from(text, "utf8").equals(contents) ? "" : "invalid UTF-8";
419
+ }
420
+
364
421
  /** Accumulates a response the way plugin.Builder does. */
365
422
  class Builder {
366
423
  files = [];
@@ -372,11 +429,10 @@ class Builder {
372
429
  return;
373
430
  }
374
431
  const text = contents.toString("utf8");
375
- this.files.push(
376
- Buffer.from(text, "utf8").equals(contents)
377
- ? { name, contents: text }
378
- : { name, contents: contents.toString("base64"), encoding: "base64" },
379
- );
432
+ if (!Buffer.from(text, "utf8").equals(contents) || contents.subarray(0, SNIFF_BYTES).includes(0)) {
433
+ throw new Error(`${name} is binary and cannot be emitted by fetch-git`);
434
+ }
435
+ this.files.push({ name, contents: text });
380
436
  }
381
437
 
382
438
  warn(ref, message) {
@@ -1,11 +1,12 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
2
3
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
4
  import { tmpdir } from "node:os";
4
5
  import { basename, dirname, join } from "node:path";
5
6
  import { afterEach, describe, expect, it } from "vitest";
6
7
 
7
8
  import { runPlugin } from "../plugin-host.mjs";
8
- import { OFFLINE_ENV, encodeLock, pin, run, splitRepo, webRepo } from "./fetch-git.mjs";
9
+ import { LOCK_NAME, OFFLINE_ENV, encodeLock, pin, run, splitRepo, webRepo } from "./fetch-git.mjs";
9
10
 
10
11
  const created = [];
11
12
  afterEach(() => {
@@ -105,18 +106,71 @@ describe("fetch-git", () => {
105
106
  expect(inCi.warnings).toHaveLength(1);
106
107
  });
107
108
 
108
- it("preserves binary blobs online and from the offline cache", () => {
109
+ it("skips binary blobs online and from the offline cache", () => {
109
110
  const remote = repository();
110
111
  const cacheDir = cache();
111
112
  const first = fetch(options(remote, cacheDir, ["docs/example.png"]), online);
112
113
  const name = `${remote.copyDir}/docs/example.png`;
113
- const image = first.files.find((file) => file.name === name);
114
- expect(image?.encoding).toBe("base64");
114
+ expect(first.files.find((file) => file.name === name)).toBeUndefined();
115
+ expect(first.files.every((file) => file.encoding === undefined)).toBe(true);
116
+ const lockName = `${remote.copyDir}/${LOCK_NAME}`;
117
+ expect(JSON.parse(contentsOf(first, lockName)).repos[0].skipped).toEqual([
118
+ { path: "docs/example.png", size: 10, reason: "known binary extension .png" },
119
+ ]);
120
+ expect(first.warnings[0].message).toContain("skipped 1 binary file (10 bytes)");
115
121
  write(cacheDir, first);
116
- expect(readFileSync(join(cacheDir, name))).toEqual(readFileSync(join(remote.dir, "docs/example.png")));
122
+ expect(() => readFileSync(join(cacheDir, name))).toThrow();
117
123
 
118
124
  const replayed = fetch(options(remote, cacheDir, ["docs/example.png"]), offline);
119
- expect(replayed.files.find((file) => file.name === name)).toEqual(image);
125
+ expect(replayed.files.find((file) => file.name === name)).toBeUndefined();
126
+ expect(JSON.parse(contentsOf(replayed, lockName)).repos[0].skipped).toEqual([
127
+ { path: "docs/example.png", size: 10, reason: "known binary extension .png" },
128
+ ]);
129
+ });
130
+
131
+ it("does not read a large known binary into the plugin response and sniffs unknown binary files", () => {
132
+ const remote = repository();
133
+ const mmdb = "tests/integration/resources/GeoLite2-Country.mmdb";
134
+ const unknown = "tests/integration/resources/blob.data";
135
+ mkdirSync(dirname(join(remote.dir, mmdb)), { recursive: true });
136
+ writeFileSync(join(remote.dir, mmdb), Buffer.alloc(10 * 1024 * 1024, 0xab));
137
+ writeFileSync(join(remote.dir, unknown), Buffer.from([1, 0, 2]));
138
+ remote.git(["add", "."]);
139
+ remote.git(["commit", "--quiet", "-m", "binary fixtures"]);
140
+ remote.commit = remote.git(["rev-parse", "HEAD"]);
141
+
142
+ const response = fetch(options(remote, cache(), [mmdb, unknown]), online);
143
+ expect(response.files.every((file) => file.encoding === undefined)).toBe(true);
144
+ expect(JSON.stringify(response).length).toBeLessThan(20_000);
145
+ const lock = JSON.parse(contentsOf(response, `${remote.copyDir}/${LOCK_NAME}`)).repos[0];
146
+ expect(lock.files).toEqual([]);
147
+ expect(lock.skipped).toEqual([
148
+ { path: mmdb, size: 10 * 1024 * 1024, reason: "known binary extension .mmdb" },
149
+ { path: unknown, size: 3, reason: "NUL byte in first 8192 bytes" },
150
+ ]);
151
+ });
152
+
153
+ it("drops binary files from a cache written by an older Portolan", () => {
154
+ const remote = repository();
155
+ const cacheDir = cache();
156
+ const at = join(cacheDir, remote.copyDir);
157
+ const path = "docs/example.png";
158
+ const contents = readFileSync(join(remote.dir, path));
159
+ mkdirSync(dirname(join(at, path)), { recursive: true });
160
+ writeFileSync(join(at, path), contents);
161
+ writeFileSync(join(at, LOCK_NAME), encodeLock({
162
+ repo: remote.url,
163
+ commit: remote.commit,
164
+ paths: [path],
165
+ files: [{ path, size: contents.length, sha256: createHash("sha256").update(contents).digest("hex") }],
166
+ }));
167
+
168
+ const replayed = fetch(options(remote, cacheDir, [path]), offline);
169
+ expect(replayed.files.some((file) => file.name.endsWith(path))).toBe(false);
170
+ expect(JSON.parse(contentsOf(replayed, `${remote.copyDir}/${LOCK_NAME}`)).repos[0]).toMatchObject({
171
+ files: [],
172
+ skipped: [{ path, size: contents.length, reason: "known binary extension .png" }],
173
+ });
120
174
  });
121
175
 
122
176
  it("names the edited file when a vendored copy no longer matches its lock", () => {
@@ -223,8 +277,8 @@ describe("names", () => {
223
277
  repos: [{ repo: "github.com/acme/shop", commit: "c1d2e3f" }],
224
278
  });
225
279
 
226
- const lock = encodeLock({ repo: "github.com/acme/shop", commit: "abc", paths: ["services/oms", "proto"], files: [{ path: "b", sha256: "2", size: 1 }, { path: "a", sha256: "1", size: 1 }] });
227
- expect(JSON.parse(lock)).toEqual({ repos: [{ repo: "github.com/acme/shop", commit: "abc", paths: ["proto", "services/oms"], files: [{ path: "a", sha256: "1", size: 1 }, { path: "b", sha256: "2", size: 1 }] }] });
280
+ const lock = encodeLock({ repo: "github.com/acme/shop", commit: "abc", paths: ["services/oms", "proto"], files: [{ path: "b", sha256: "2", size: 1 }, { path: "a", sha256: "1", size: 1 }], skipped: [{ path: "z.png", size: 2, reason: "known binary extension .png" }] });
281
+ expect(JSON.parse(lock)).toEqual({ repos: [{ repo: "github.com/acme/shop", commit: "abc", paths: ["proto", "services/oms"], files: [{ path: "a", sha256: "1", size: 1 }, { path: "b", sha256: "2", size: 1 }], skipped: [{ path: "z.png", size: 2, reason: "known binary extension .png" }] }] });
228
282
  expect(JSON.parse(encodeLock({ repo: "r", commit: "c", paths: [], files: [] })).repos[0]).not.toHaveProperty("paths");
229
283
  });
230
284
  });
@@ -20,6 +20,7 @@ import { basename, dirname, join, posix, relative, resolve, sep } from "node:pat
20
20
 
21
21
  import { loadManifest, readManifest, readManifestText } from "./manifest.mjs";
22
22
  import { builtinPluginNames } from "./builtin-plugins.mjs";
23
+ import { djangoAggregateCandidates } from "../src/lib/django-aggregates.mjs";
23
24
  import { installDeliveryPreset, planDeliveryPreset, publicDeliveryPreset } from "./delivery-presets.mjs";
24
25
  import {
25
26
  discoverProject,
@@ -325,7 +326,7 @@ function pluginOptions(plugin, project, detectedOptions = {}) {
325
326
  if (plugin === "graphql") return { ...common, ...detectedOptions, out: "graphql.json" };
326
327
  if (plugin === "proto") return { ...common, ...detectedOptions, out: "proto.json" };
327
328
  if (plugin === "glossary") return { context: group, ...detectedOptions, out: "glossary.json" };
328
- if (plugin === "adr") return { ...detectedOptions, out: "adr.json" };
329
+ if (plugin === "adr") return { scope: [group, component].filter(Boolean).join(".") || "org", ...detectedOptions, out: "adr.json" };
329
330
  return {};
330
331
  }
331
332
 
@@ -617,6 +618,52 @@ export function writeManifest(path, manifest) {
617
618
  }
618
619
  }
619
620
 
621
+ export function djangoAggregateProposals(workspace) {
622
+ const path = join(workspace, "portolan.json");
623
+ const text = readFileSync(path, "utf8");
624
+ const manifest = readManifestText(text, path);
625
+ const revision = createHash("sha256").update(text).digest("hex");
626
+ let report;
627
+ try { report = JSON.parse(readFileSync(join(workspace, ".portolan/build-report.json"), "utf8")); } catch {}
628
+ const proposals = [];
629
+ for (const step of report?.steps ?? []) {
630
+ if (step.phase !== "extract") continue;
631
+ const matches = (manifest.extract ?? []).map((entry, index) => ({ entry, index }))
632
+ .filter(({ entry }) => entry.plugin === step.plugin && entry.in === step.input && entry.out === step.output);
633
+ if (matches.length !== 1) continue;
634
+ const { entry, index } = matches[0];
635
+ for (const message of step.warnings ?? []) {
636
+ const candidates = djangoAggregateCandidates(message);
637
+ if (!candidates) continue;
638
+ const id = `${index}:${candidates.app}`;
639
+ if (proposals.some((proposal) => proposal.id === id)) continue;
640
+ proposals.push({ id, step: index, plugin: entry.plugin, input: entry.in, output: entry.out, message, ...candidates });
641
+ }
642
+ }
643
+ return { revision, stale: !report || report.manifestSha256 !== revision || report.status === "running", proposals };
644
+ }
645
+
646
+ export function saveDjangoAggregates(workspace, request) {
647
+ const current = djangoAggregateProposals(workspace);
648
+ if (current.stale || request.revision !== current.revision) {
649
+ throw new Error("The manifest or extraction report has changed. Regenerate and review the candidates again.");
650
+ }
651
+ if (!Array.isArray(request.selections) || !request.selections.length) throw new Error("Choose at least one aggregate root.");
652
+ const manifest = readManifest(join(workspace, "portolan.json"));
653
+ const seen = new Set();
654
+ for (const choice of request.selections) {
655
+ const proposal = current.proposals.find((candidate) => candidate.id === choice?.id);
656
+ if (!proposal || !proposal.models.some((model) => model.name === choice.model) || seen.has(choice.id)) {
657
+ throw new Error("Choose one of the reported models for each application.");
658
+ }
659
+ seen.add(choice.id);
660
+ const step = manifest.extract[proposal.step];
661
+ step.options = { ...step.options, aggregates: { ...step.options?.aggregates, [proposal.app]: choice.model } };
662
+ }
663
+ writeManifest(join(workspace, "portolan.json"), manifest);
664
+ return { saved: request.selections.length };
665
+ }
666
+
620
667
  export function writeProject(workspace, request) {
621
668
  const manifestPath = join(workspace, "portolan.json");
622
669
  const before = readFileSync(manifestPath, "utf8");
@@ -747,11 +794,21 @@ function feed(job, stream, chunk) {
747
794
  }
748
795
  }
749
796
 
750
- function workspaceFingerprint(workspace) {
797
+ // The tree with nothing in it, which every repository has. Before the first
798
+ // commit there is no HEAD to diff against, and the index diffed against this
799
+ // tree is exactly what has been staged.
800
+ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
801
+
802
+ export function workspaceFingerprint(workspace) {
751
803
  const hash = createHash("sha256");
752
804
  try {
753
- const options = { cwd: workspace, encoding: "buffer", maxBuffer: 128 * 1024 * 1024 };
754
- hash.update(execFileSync("git", ["diff", "--binary", "HEAD", "--", "."], options));
805
+ // Git speaks to nobody here: a workspace that is not a checkout, or one
806
+ // without a commit yet, is a case this function handles, not an error to
807
+ // print from the dev server.
808
+ const options = { cwd: workspace, encoding: "buffer", maxBuffer: 128 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] };
809
+ let base = EMPTY_TREE;
810
+ try { base = execFileSync("git", ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], options).toString().trim() || EMPTY_TREE; } catch {}
811
+ hash.update(execFileSync("git", ["diff", "--binary", base, "--", "."], options));
755
812
  const untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], options)
756
813
  .toString().split("\0").filter(Boolean).sort();
757
814
  for (const name of untracked) {
@@ -865,6 +922,7 @@ export function summarizeProjectTrial(snapshot, plan, events) {
865
922
  fileCount: event.fileCount,
866
923
  changedCount: event.changedCount,
867
924
  warnings: event.warnings ?? [],
925
+ diagnostics: event.diagnostics ?? [],
868
926
  ...(event.message ? { message: event.message } : {}),
869
927
  }));
870
928
  const facts = new Map(TRIAL_FACTS.map(([key]) => [key, new Set()]));
@@ -912,10 +970,12 @@ export function summarizeProjectTrial(snapshot, plan, events) {
912
970
  for (const term of fragment.terms ?? []) add("terms", term.id ?? term.slug ?? term.name);
913
971
  }
914
972
  const warnings = steps.flatMap((step) => step.warnings.map((message) => ({ plugin: step.plugin, message })));
973
+ const diagnostics = steps.flatMap((step) => step.diagnostics);
915
974
  return {
916
975
  steps,
917
976
  facts: TRIAL_FACTS.map(([key, label]) => ({ key, label, count: facts.get(key)?.size ?? 0 })).filter((fact) => fact.count > 0),
918
977
  warnings,
978
+ diagnostics,
919
979
  generatedFiles: files.length,
920
980
  };
921
981
  }
@@ -1077,6 +1137,9 @@ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
1077
1137
  const active = [...jobs.values()].find((job) => job.status === "running");
1078
1138
  return send(res, 200, { local: true, workspace: realpathSync(workspace), setup: setup(workspace, publicSetupFrom), activeRun: active ? { id: active.id, mode: active.mode } : null });
1079
1139
  }
1140
+ if (req.method === "GET" && url.pathname === `${LOCAL_API_PREFIX}/django-aggregates`) {
1141
+ return send(res, 200, djangoAggregateProposals(workspace));
1142
+ }
1080
1143
  if (req.method === "GET" && url.pathname === `${LOCAL_API_PREFIX}/delivery-presets`) {
1081
1144
  const features = url.searchParams.has("features")
1082
1145
  ? url.searchParams.get("features").split(",").filter(Boolean)
@@ -1100,6 +1163,10 @@ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
1100
1163
  return send(res, 405, { error: "Use a local JSON request." });
1101
1164
  }
1102
1165
  const input = await body(req);
1166
+ if (url.pathname === `${LOCAL_API_PREFIX}/django-aggregates`) {
1167
+ if ([...jobs.values()].some((job) => job.status === "running")) throw new Error("Wait for the current generation to finish before saving aggregate roots.");
1168
+ return send(res, 200, saveDjangoAggregates(workspace, input));
1169
+ }
1103
1170
  if (url.pathname === `${LOCAL_API_PREFIX}/delivery-presets/install`) {
1104
1171
  return send(res, 201, installDeliveryPreset(workspace, input));
1105
1172
  }