@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.
- package/README.md +23 -0
- package/catalog/enum_test.go +46 -0
- package/catalog/evidence_test.go +35 -0
- package/catalog/model.go +1066 -0
- package/catalog/roundtrip_test.go +203 -0
- package/catalog/via_test.go +38 -0
- package/cli/init.test.mjs +6 -1
- package/cli/portolan.mjs +8 -0
- package/cli/portolan.test.mjs +49 -0
- package/go.mod +14 -0
- package/go.sum +20 -0
- package/internal/gocall/README.md +19 -0
- package/internal/gocall/analyze.go +189 -0
- package/internal/gocall/analyze_test.go +107 -0
- package/internal/gohttp/analyze.go +2562 -0
- package/internal/gohttp/destination.go +373 -0
- package/internal/gohttp/endpoints.go +1067 -0
- package/internal/gohttp/roots.go +320 -0
- package/internal/gohttp/typed.go +96 -0
- package/internal/goscan/constants.go +85 -0
- package/internal/goscan/goscan_test.go +227 -0
- package/internal/goscan/index.go +629 -0
- package/internal/goscan/index_test.go +66 -0
- package/internal/goscan/names.go +52 -0
- package/internal/goscan/parse_test.go +11 -0
- package/internal/goscan/source.go +37 -0
- package/internal/goscan/tree.go +284 -0
- package/internal/goscan/types.go +99 -0
- package/internal/wsdl/ids.go +127 -0
- package/internal/wsdl/ids_test.go +21 -0
- package/internal/wsdl/model.go +70 -0
- package/internal/wsdl/parse.go +949 -0
- package/internal/wsdl/parse_test.go +170 -0
- package/package.json +22 -10
- package/plugin/describe.go +118 -0
- package/plugin/describe_test.go +114 -0
- package/plugin/protocol.go +141 -0
- package/plugin/schematest/schematest.go +126 -0
- package/plugins/README.md +109 -46
- package/plugins/cmd/portolan-http-clients/main.go +19 -0
- package/plugins/extract-celery/extract.py +0 -2
- package/plugins/extract-celery/extract_test.py +1 -1
- package/plugins/extract-django/README.md +39 -17
- package/plugins/extract-django/domain.py +28 -17
- package/plugins/extract-django/extract.py +21 -7
- package/plugins/extract-django/extract_test.py +55 -2
- package/plugins/extract-django/lifecycle.py +2 -0
- package/plugins/extract-django/operations.py +1 -1
- package/plugins/extract-django/routing_test.py +109 -1
- package/plugins/extract-django/store.py +1 -1
- package/plugins/extract-django/transport.py +101 -55
- package/plugins/extract-django/verbs.py +241 -0
- package/plugins/extract-go/README.md +47 -0
- package/plugins/extract-http-clients/describe.go +19 -0
- package/plugins/extract-http-clients/describe_test.go +11 -0
- package/plugins/extract-http-clients/extract.go +740 -0
- package/plugins/extract-http-clients/extract_test.go +1561 -0
- package/plugins/extract-http-clients/main.go +41 -0
- package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
- package/plugins/extract-python-kafka/extract.py +0 -2
- package/plugins/extract-python-kafka/extract_test.py +1 -1
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/extract.ts +4 -5
- package/plugins/extract-ts/graphql.test.ts +1 -1
- package/plugins/openapi/ids.go +261 -0
- package/plugins/openapi/ids_test.go +98 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/protocol.py +1 -5
- package/portolan.json +3 -2
- package/schema/portolan.schema.json +34 -0
- package/scripts/README.md +18 -12
- package/scripts/catalog-sources.mjs +6 -0
- package/scripts/delivery-presets.mjs +21 -11
- package/scripts/diff.mjs +5 -1
- package/scripts/django-aggregates.test.mjs +58 -0
- package/scripts/gen-likec4.mjs +1 -1
- package/scripts/gen.mjs +118 -115
- package/scripts/go-discovery.test.mjs +30 -0
- package/scripts/history.mjs +186 -3
- package/scripts/history.test.mjs +1 -1
- package/scripts/host-plugins/fetch-git.mjs +77 -21
- package/scripts/host-plugins/fetch-git.test.mjs +62 -8
- package/scripts/local-api.mjs +71 -4
- package/scripts/local-api.test.mjs +63 -4
- package/scripts/local-discovery.mjs +82 -9
- package/scripts/manifest.mjs +5 -3
- package/scripts/manifest.test.mjs +24 -0
- package/scripts/output-diff.mjs +94 -0
- package/scripts/output-diff.test.mjs +36 -0
- package/scripts/package-smoke.mjs +62 -4
- package/scripts/plugin-host.mjs +22 -2
- package/scripts/plugin-host.test.mjs +9 -0
- package/scripts/plugin-wasm-worker.mjs +4 -1
- package/scripts/provenance.mjs +72 -0
- package/scripts/provenance.test.mjs +149 -0
- package/scripts/run-builtin.mjs +39 -5
- package/scripts/schema.mjs +29 -0
- package/scripts/warning-policy.mjs +161 -0
- package/scripts/warning-policy.test.mjs +56 -0
- package/src/app/Sidebar.tsx +3 -3
- package/src/catalog-docs.test.ts +64 -0
- package/src/catalog-docs.ts +35 -0
- package/src/catalog-error.test.ts +15 -0
- package/src/catalog-model.ts +48 -5
- package/src/catalog-validation.ts +9 -0
- package/src/chat/Starter.tsx +5 -11
- package/src/chat/tools.test.ts +27 -0
- package/src/chat/tools.ts +5 -9
- package/src/components/CatalogStamp.tsx +10 -8
- package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
- package/src/components/HTTPDestinationEvidence.tsx +31 -0
- package/src/components/Integrations.tsx +1 -1
- package/src/components/MachineDocs.tsx +6 -5
- package/src/components/MethodRows.tsx +9 -2
- package/src/components/RelationEvidence.test.tsx +14 -0
- package/src/components/RelationEvidence.tsx +53 -0
- package/src/data.ts +25 -7
- package/src/enrich.test.ts +332 -1
- package/src/enrich.ts +206 -3
- package/src/flow/StepDetail.tsx +6 -0
- package/src/flow/evidence.test.ts +16 -0
- package/src/flow/evidence.ts +34 -0
- package/src/index.css +44 -0
- package/src/landing/DraggableReveal.tsx +3 -2
- package/src/landing/EvidencePipeline.tsx +105 -0
- package/src/landing/LandingPage.tsx +2 -59
- package/src/lib/catalog-diff.ts +1 -1
- package/src/lib/django-aggregates.d.mts +9 -0
- package/src/lib/django-aggregates.mjs +36 -0
- package/src/lib/django-aggregates.test.ts +29 -0
- package/src/lib/django-aggregates.ts +5 -0
- package/src/lib/local-api.ts +20 -2
- package/src/lib/setup-info.test.ts +17 -0
- package/src/lib/setup-info.ts +58 -0
- package/src/lib/warnings.test.ts +54 -0
- package/src/lib/warnings.ts +260 -0
- package/src/map/ContextMapGraph.tsx +76 -32
- package/src/merge.ts +16 -9
- package/src/pages/AggregatePage.tsx +8 -7
- package/src/pages/ContextPage.tsx +6 -5
- package/src/pages/ServicePage.tsx +4 -3
- package/src/pages/Settings.tsx +189 -41
- package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
- package/src/selection/DetailPanel.tsx +15 -0
- package/src/virtual-provenance.d.ts +11 -0
- package/vite.config.ts +5 -0
- package/scripts/vendor-lock.mjs +0 -58
- package/scripts/vendor-lock.test.mjs +0 -69
package/scripts/history.mjs
CHANGED
|
@@ -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
|
package/scripts/history.test.mjs
CHANGED
|
@@ -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
|
|
195
|
-
if (files.size === 0
|
|
196
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
341
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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("
|
|
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
|
-
|
|
114
|
-
expect(
|
|
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))).
|
|
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)).
|
|
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
|
});
|
package/scripts/local-api.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
754
|
-
|
|
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
|
}
|