@shortlink-org/portolan 0.2.2 → 0.2.3
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 +7 -2
- package/cli/portolan.mjs +36 -6
- package/package.json +4 -4
- package/plugins/portolan-go.wasm +0 -0
- package/scripts/builtin-plugins.mjs +3 -2
- package/scripts/catalog-sources.mjs +2 -1
- package/scripts/catalog-sources.test.mjs +11 -1
- package/scripts/delivery-presets.mjs +207 -24
- package/scripts/diff.mjs +5 -1
- package/scripts/local-api.mjs +26 -377
- package/scripts/local-api.test.mjs +50 -0
- package/scripts/local-discovery.mjs +378 -0
- package/scripts/manifest.mjs +42 -1
- package/scripts/manifest.test.mjs +24 -1
- package/scripts/run-builtin.mjs +4 -2
- package/scripts/schema.mjs +4 -0
- package/scripts/site-docs.mjs +2 -2
- package/src/app/CatalogApp.tsx +4 -4
- package/src/app/Sidebar.tsx +13 -730
- package/src/app/SidebarFlowSections.tsx +251 -0
- package/src/app/SidebarFooter.tsx +160 -0
- package/src/app/SidebarTree.tsx +322 -0
- package/src/catalog-index.ts +485 -0
- package/src/catalog-model.ts +1339 -0
- package/src/catalog-validation.ts +1570 -0
- package/src/catalog.test.ts +16 -0
- package/src/catalog.ts +6 -3306
- package/src/components/{PageHeader.test.ts → PageHeader.test.tsx} +9 -10
- package/src/components/ProblemRow.tsx +3 -0
- package/src/components/SourcePreview.tsx +1 -1
- package/src/index.css +0 -17
- package/src/landing/LandingPage.tsx +4 -4
- package/src/lib/all-problems.ts +1 -1
- package/src/lib/derive.ts +1 -0
- package/src/lib/local-api.ts +19 -7
- package/src/lib/motion.test.ts +4 -2
- package/src/lib/motion.tsx +5 -4
- package/src/lib/proto-problems.test.ts +170 -3
- package/src/lib/proto-problems.ts +176 -4
- package/src/merge.test.ts +46 -0
- package/src/merge.ts +35 -1
- package/src/pages/Settings.tsx +1 -126
- package/src/pages/settings/AboutSettings.tsx +129 -0
- package/src/pages/settings/DeliverySettings.tsx +71 -15
- package/src/selection/pages.test.ts +14 -1
- package/src/selection/pages.ts +9 -3
- package/vite.config.ts +3 -1
package/README.md
CHANGED
|
@@ -201,8 +201,13 @@ for against what is on `PATH`. The Docker image contains all of them.
|
|
|
201
201
|
|
|
202
202
|
While `portolan dev` is running, open **Settings → Delivery presets**. Portolan
|
|
203
203
|
detects GitHub or GitLab from the repository's `origin`, previews the exact CI
|
|
204
|
-
changes, and
|
|
205
|
-
|
|
204
|
+
changes, and generates only the jobs selected there. Architecture checks and
|
|
205
|
+
static catalog publishing are selected by default; pull-request architecture
|
|
206
|
+
diffs and GitHub SARIF annotations are opt-in. Check, review, and publication
|
|
207
|
+
live in separate GitHub workflows, while SARIF augments the review workflow, so
|
|
208
|
+
comment and code-scanning permissions are granted only when those capabilities
|
|
209
|
+
are enabled. Existing unmanaged workflow files are never overwritten, and
|
|
210
|
+
disabling a capability removes only files managed by Portolan.
|
|
206
211
|
|
|
207
212
|
### Run without installing Node or language toolchains
|
|
208
213
|
|
package/cli/portolan.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
|
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
|
|
21
21
|
import { InitError, commandWorks, init as runInit, isInteractive, promptAnswers, toolchainFor } from "./init.mjs";
|
|
22
|
+
import { loadManifest, readManifest } from "../scripts/manifest.mjs";
|
|
22
23
|
|
|
23
24
|
const installRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
24
25
|
const packageJson = JSON.parse(readFileSync(resolve(installRoot, "package.json"), "utf8"));
|
|
@@ -54,7 +55,10 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
54
55
|
return dev(workspace, parsed);
|
|
55
56
|
case "diff":
|
|
56
57
|
if (!parsed.positionals[0]) fail("diff requires a base branch, tag, or commit");
|
|
57
|
-
return runScript("scripts/diff.mjs", parsed
|
|
58
|
+
return runScript("scripts/diff.mjs", diffArgs(parsed), workspace);
|
|
59
|
+
case "comment":
|
|
60
|
+
if (!parsed.positionals[0]) fail("comment requires a markdown file");
|
|
61
|
+
return runScript("scripts/forge-comment.mjs", [parsed.positionals[0]], workspace);
|
|
58
62
|
case "doctor":
|
|
59
63
|
return doctor(workspace);
|
|
60
64
|
default:
|
|
@@ -68,7 +72,7 @@ function parse(argv) {
|
|
|
68
72
|
const arg = argv[index];
|
|
69
73
|
if (arg === "--help" || arg === "-h") value.help = true;
|
|
70
74
|
else if (arg === "--version" || arg === "-v") value.version = true;
|
|
71
|
-
else if (["--cwd", "--output", "--base", "--host", "--port"].includes(arg)) {
|
|
75
|
+
else if (["--cwd", "--output", "--base", "--host", "--port", "--format", "--site", "--head"].includes(arg)) {
|
|
72
76
|
const next = argv[++index];
|
|
73
77
|
if (!next) fail(`${arg} requires a value`);
|
|
74
78
|
value[arg.slice(2)] = next;
|
|
@@ -80,6 +84,14 @@ function parse(argv) {
|
|
|
80
84
|
return value;
|
|
81
85
|
}
|
|
82
86
|
|
|
87
|
+
function diffArgs(parsed) {
|
|
88
|
+
const args = [parsed.positionals[0]];
|
|
89
|
+
for (const name of ["format", "output", "site", "head"]) {
|
|
90
|
+
if (parsed[name]) args.push(`--${name}`, parsed[name]);
|
|
91
|
+
}
|
|
92
|
+
return args;
|
|
93
|
+
}
|
|
94
|
+
|
|
83
95
|
function help() {
|
|
84
96
|
console.log(`Portolan ${VERSION}
|
|
85
97
|
|
|
@@ -92,6 +104,7 @@ Commands:
|
|
|
92
104
|
check fail when committed generated files are out of date
|
|
93
105
|
build build the static site
|
|
94
106
|
diff BASE describe architecture changes against BASE
|
|
107
|
+
comment FILE post or update a pull-request comment from Markdown
|
|
95
108
|
doctor check the local runtime and project configuration
|
|
96
109
|
version print the CLI version
|
|
97
110
|
|
|
@@ -99,6 +112,9 @@ Options:
|
|
|
99
112
|
--cwd DIR project directory (default: current directory)
|
|
100
113
|
--output DIR build output (default: dist)
|
|
101
114
|
--base PATH deployed URL base (default: /)
|
|
115
|
+
--format TYPE diff format: markdown, json, or sarif
|
|
116
|
+
--site URL catalog URL linked from an architecture diff
|
|
117
|
+
--head BRANCH head branch linked from an architecture diff
|
|
102
118
|
--host HOST dev server host (default: 127.0.0.1)
|
|
103
119
|
--port PORT dev server port
|
|
104
120
|
--yes, -y init without questions: take every detected default`);
|
|
@@ -118,10 +134,24 @@ async function init(workspace, options) {
|
|
|
118
134
|
|
|
119
135
|
function doctor(workspace) {
|
|
120
136
|
const manifestPath = resolve(workspace, "portolan.json");
|
|
121
|
-
|
|
137
|
+
let manifest = null;
|
|
138
|
+
let manifestProblems = [];
|
|
139
|
+
if (existsSync(manifestPath)) {
|
|
140
|
+
try {
|
|
141
|
+
const loaded = loadManifest(manifestPath);
|
|
142
|
+
manifest = loaded.manifest;
|
|
143
|
+
manifestProblems = loaded.problems;
|
|
144
|
+
} catch (cause) {
|
|
145
|
+
manifestProblems = [cause instanceof Error ? cause.message : String(cause)];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
122
148
|
const checks = [
|
|
123
149
|
["Node.js >= 24", Number(process.versions.node.split(".")[0]) >= 24, process.version],
|
|
124
|
-
[
|
|
150
|
+
[
|
|
151
|
+
"portolan.json",
|
|
152
|
+
Boolean(manifest) && manifestProblems.length === 0,
|
|
153
|
+
manifestProblems.length > 0 ? manifestProblems.join("; ") : "required",
|
|
154
|
+
],
|
|
125
155
|
["Git", commandWorks("git", ["--version"]), "used for deterministic source stamps"],
|
|
126
156
|
];
|
|
127
157
|
|
|
@@ -157,7 +187,7 @@ async function build(workspace, options) {
|
|
|
157
187
|
runNode(packageBin("vite", "bin/vite.js"), ["build", stage, "--config", resolve(stage, "vite.config.ts"), "--outDir", output, "--emptyOutDir"], workspace, env);
|
|
158
188
|
|
|
159
189
|
const { siteDocs } = await import(resolve(installRoot, "scripts/site-docs.mjs"));
|
|
160
|
-
const manifest =
|
|
190
|
+
const manifest = readManifest(resolve(workspace, "portolan.json"));
|
|
161
191
|
const written = siteDocs({ manifest, dist: output });
|
|
162
192
|
copyFileSync(resolve(output, "index.html"), resolve(output, "404.html"));
|
|
163
193
|
console.log(`site: ${relative(workspace, output) || "."}${written.length ? `; mounted ${written.join(", ")}` : ""}`);
|
|
@@ -178,7 +208,7 @@ async function dev(workspace, options) {
|
|
|
178
208
|
export async function prepareSite(workspace) {
|
|
179
209
|
const manifestPath = resolve(workspace, "portolan.json");
|
|
180
210
|
if (!existsSync(manifestPath)) fail("portolan.json is missing; run portolan init first");
|
|
181
|
-
const manifest =
|
|
211
|
+
const manifest = readManifest(manifestPath);
|
|
182
212
|
const stage = resolve(workspace, ".portolan", "site");
|
|
183
213
|
rmSync(stage, { recursive: true, force: true });
|
|
184
214
|
mkdirSync(stage, { recursive: true });
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@shortlink-org/portolan",
|
|
3
3
|
"description": "Generate a navigable architecture catalog from code and specifications.",
|
|
4
4
|
"private": false,
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.3",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"homepage": "https://github.com/shortlink-org/portolan#readme",
|
|
@@ -68,10 +68,10 @@
|
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"@ai-sdk/openai-compatible": "3.0.45",
|
|
71
|
-
"@ai-sdk/react": "4.0.
|
|
71
|
+
"@ai-sdk/react": "4.0.98",
|
|
72
72
|
"@asyncapi/react-component": "^3.1.8",
|
|
73
73
|
"@clack/prompts": "^1.8.0",
|
|
74
|
-
"@floating-ui/react": "^0.27.
|
|
74
|
+
"@floating-ui/react": "^0.27.20",
|
|
75
75
|
"@headlessui/react": "^2.2.10",
|
|
76
76
|
"@scalar/api-reference-react": "^0.9.66",
|
|
77
77
|
"@tailwindcss/vite": "^4.0.0",
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
"@tanstack/react-virtual": "^3.14.10",
|
|
81
81
|
"@vitejs/plugin-react": "^6.1.1",
|
|
82
82
|
"@xyflow/react": "^12.11.5",
|
|
83
|
-
"ai": "7.0.
|
|
83
|
+
"ai": "7.0.95",
|
|
84
84
|
"ajv": "^8.20.0",
|
|
85
85
|
"elkjs": "^0.12.0",
|
|
86
86
|
"html-to-image": "^1.11.13",
|
package/plugins/portolan-go.wasm
CHANGED
|
Binary file
|
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
// package happens to run them. Custom plugins may still be declared in the
|
|
5
5
|
// manifest with `process` or `wasm`; those declarations always win.
|
|
6
6
|
|
|
7
|
-
import { readFileSync } from "node:fs";
|
|
8
7
|
import { dirname, resolve } from "node:path";
|
|
9
8
|
import { fileURLToPath } from "node:url";
|
|
10
9
|
|
|
10
|
+
import { readManifest } from "./manifest.mjs";
|
|
11
|
+
|
|
11
12
|
export const INSTALL_ROOT = process.env.PORTOLAN_INSTALL_ROOT
|
|
12
13
|
? resolve(process.env.PORTOLAN_INSTALL_ROOT)
|
|
13
14
|
: resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -16,7 +17,7 @@ let definitions;
|
|
|
16
17
|
|
|
17
18
|
function shippedDefinitions() {
|
|
18
19
|
if (definitions) return definitions;
|
|
19
|
-
const manifest =
|
|
20
|
+
const manifest = readManifest(resolve(INSTALL_ROOT, "portolan.json"));
|
|
20
21
|
definitions = new Map((manifest.plugins ?? []).map((plugin) => [plugin.name, plugin]));
|
|
21
22
|
return definitions;
|
|
22
23
|
}
|
|
@@ -14,6 +14,7 @@ import { validateCatalog } from "../src/catalog.ts";
|
|
|
14
14
|
import { filterCatalogForProfile } from "../src/catalog-profile.ts";
|
|
15
15
|
import { enrichCatalog } from "../src/enrich.ts";
|
|
16
16
|
import { mergeCatalogs } from "../src/merge.ts";
|
|
17
|
+
import { readManifest } from "./manifest.mjs";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* `exclude` names source files to leave out, as the manifest would spell
|
|
@@ -22,7 +23,7 @@ import { mergeCatalogs } from "../src/merge.ts";
|
|
|
22
23
|
* what it wrote last time would count as what it saw this time.
|
|
23
24
|
*/
|
|
24
25
|
export async function loadCatalog(manifestPath = "portolan.json", { exclude = [], profile } = {}) {
|
|
25
|
-
const manifest =
|
|
26
|
+
const manifest = readManifest(manifestPath);
|
|
26
27
|
const excluded = new Set(exclude.map((path) => normalize(path)));
|
|
27
28
|
const selected = profile
|
|
28
29
|
? (manifest.catalogs ?? []).find((candidate) => candidate.id === profile)
|
|
@@ -29,7 +29,17 @@ describe("catalog sources", () => {
|
|
|
29
29
|
|
|
30
30
|
it("still reports missing sources for a configured project", async () => {
|
|
31
31
|
const { path, missing } = manifest({ sources: [], projects: [] });
|
|
32
|
-
writeFileSync(path, `${JSON.stringify({
|
|
32
|
+
writeFileSync(path, `${JSON.stringify({
|
|
33
|
+
sources: [missing],
|
|
34
|
+
projects: [{
|
|
35
|
+
id: "billing",
|
|
36
|
+
name: "Billing",
|
|
37
|
+
root: "services/billing",
|
|
38
|
+
context: "shop",
|
|
39
|
+
service: "billing",
|
|
40
|
+
}],
|
|
41
|
+
extract: [],
|
|
42
|
+
}, null, 2)}\n`);
|
|
33
43
|
await expect(loadCatalog(path)).rejects.toThrow(/no catalog matched/);
|
|
34
44
|
});
|
|
35
45
|
});
|
|
@@ -22,6 +22,26 @@ const GENERATED = "# Generated by Portolan. Preview changes in Settings before u
|
|
|
22
22
|
const GITLAB_START = "# >>> Portolan delivery preset >>>";
|
|
23
23
|
const GITLAB_END = "# <<< Portolan delivery preset <<<";
|
|
24
24
|
const PROVIDERS = new Set(["github", "gitlab"]);
|
|
25
|
+
const FEATURE_IDS = ["check", "diff", "sarif", "pages"];
|
|
26
|
+
const DEFAULT_FEATURES = ["check", "pages"];
|
|
27
|
+
const FEATURE_DETAILS = {
|
|
28
|
+
check: {
|
|
29
|
+
label: "Architecture check",
|
|
30
|
+
description: "Fail when committed architecture files are stale.",
|
|
31
|
+
},
|
|
32
|
+
diff: {
|
|
33
|
+
label: "Pull-request architecture diff",
|
|
34
|
+
description: "Post a sticky architecture summary where review happens.",
|
|
35
|
+
},
|
|
36
|
+
sarif: {
|
|
37
|
+
label: "SARIF annotations",
|
|
38
|
+
description: "Annotate changed architecture in GitHub's checks and code-scanning views.",
|
|
39
|
+
},
|
|
40
|
+
pages: {
|
|
41
|
+
label: "Static catalog publishing",
|
|
42
|
+
description: "Build and publish the catalog after changes reach the default branch.",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
25
45
|
|
|
26
46
|
function exists(path) {
|
|
27
47
|
try { lstatSync(path); return true; } catch { return false; }
|
|
@@ -153,9 +173,58 @@ jobs:
|
|
|
153
173
|
`;
|
|
154
174
|
}
|
|
155
175
|
|
|
156
|
-
function
|
|
157
|
-
|
|
158
|
-
"
|
|
176
|
+
function githubDiff({ pages, sarif }) {
|
|
177
|
+
const site = pages
|
|
178
|
+
? "\n site: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}"
|
|
179
|
+
: "";
|
|
180
|
+
const sarifInput = sarif ? "\n sarif-output: .portolan/diff.sarif" : "";
|
|
181
|
+
const sarifPermission = sarif ? "\n security-events: write # annotations in the checks tab" : "";
|
|
182
|
+
const upload = sarif
|
|
183
|
+
? `
|
|
184
|
+
- name: Annotate the checks tab
|
|
185
|
+
uses: github/codeql-action/upload-sarif@v4
|
|
186
|
+
with:
|
|
187
|
+
sarif_file: .portolan/diff.sarif
|
|
188
|
+
category: architecture-diff
|
|
189
|
+
# Fork pull requests have a read-only token; the Markdown summary still works.
|
|
190
|
+
continue-on-error: true
|
|
191
|
+
`
|
|
192
|
+
: "";
|
|
193
|
+
return `${GENERATED}
|
|
194
|
+
name: Portolan architecture diff
|
|
195
|
+
|
|
196
|
+
on:
|
|
197
|
+
pull_request:
|
|
198
|
+
|
|
199
|
+
permissions:
|
|
200
|
+
contents: read
|
|
201
|
+
pull-requests: write${sarifPermission}
|
|
202
|
+
|
|
203
|
+
concurrency:
|
|
204
|
+
group: portolan-diff-\${{ github.ref }}
|
|
205
|
+
cancel-in-progress: true
|
|
206
|
+
|
|
207
|
+
jobs:
|
|
208
|
+
describe:
|
|
209
|
+
runs-on: ubuntu-latest
|
|
210
|
+
steps:
|
|
211
|
+
- uses: actions/checkout@v7
|
|
212
|
+
with:
|
|
213
|
+
fetch-depth: 0
|
|
214
|
+
- uses: shortlink-org/portolan@${VERSION}
|
|
215
|
+
env:
|
|
216
|
+
GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
217
|
+
with:
|
|
218
|
+
command: diff
|
|
219
|
+
version: ${VERSION}
|
|
220
|
+
base-ref: origin/\${{ github.base_ref }}
|
|
221
|
+
head-ref: \${{ github.head_ref }}
|
|
222
|
+
comment: true${site}${sarifInput}
|
|
223
|
+
${upload}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function gitlabCheck() {
|
|
227
|
+
return `"portolan:check":
|
|
159
228
|
stage: .pre
|
|
160
229
|
image:
|
|
161
230
|
name: ghcr.io/shortlink-org/portolan:${VERSION}
|
|
@@ -167,8 +236,30 @@ function gitlabBlock() {
|
|
|
167
236
|
rules:
|
|
168
237
|
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
|
169
238
|
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
|
239
|
+
`;
|
|
240
|
+
}
|
|
170
241
|
|
|
171
|
-
|
|
242
|
+
function gitlabDiff({ pages }) {
|
|
243
|
+
const site = pages ? ' --site "$CI_PAGES_URL"' : "";
|
|
244
|
+
return `"portolan:review":
|
|
245
|
+
stage: .pre
|
|
246
|
+
image:
|
|
247
|
+
name: ghcr.io/shortlink-org/portolan:${VERSION}
|
|
248
|
+
entrypoint: [""]
|
|
249
|
+
variables:
|
|
250
|
+
GIT_DEPTH: "0"
|
|
251
|
+
script:
|
|
252
|
+
- portolan diff "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" --head "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"${site} --output .portolan/diff.md
|
|
253
|
+
- cat .portolan/diff.md
|
|
254
|
+
- portolan comment .portolan/diff.md
|
|
255
|
+
allow_failure: true
|
|
256
|
+
rules:
|
|
257
|
+
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
|
258
|
+
`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function gitlabPages() {
|
|
262
|
+
return `"portolan:pages":
|
|
172
263
|
stage: .post
|
|
173
264
|
image:
|
|
174
265
|
name: ghcr.io/shortlink-org/portolan:${VERSION}
|
|
@@ -182,11 +273,19 @@ function gitlabBlock() {
|
|
|
182
273
|
publish: dist
|
|
183
274
|
rules:
|
|
184
275
|
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
|
185
|
-
${GITLAB_END}
|
|
186
276
|
`;
|
|
187
277
|
}
|
|
188
278
|
|
|
189
|
-
function
|
|
279
|
+
function gitlabBlock(features) {
|
|
280
|
+
const jobs = [];
|
|
281
|
+
if (features.has("check")) jobs.push(gitlabCheck());
|
|
282
|
+
if (features.has("diff")) jobs.push(gitlabDiff({ pages: features.has("pages") }));
|
|
283
|
+
if (features.has("pages")) jobs.push(gitlabPages());
|
|
284
|
+
if (jobs.length === 0) return "";
|
|
285
|
+
return `${GITLAB_START}\n${jobs.join("\n")}${GITLAB_END}\n`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function mergeGitlab(existing, features) {
|
|
190
289
|
const start = existing.indexOf(GITLAB_START);
|
|
191
290
|
const end = existing.indexOf(GITLAB_END);
|
|
192
291
|
if ((start === -1) !== (end === -1) || (start !== -1 && end < start)) {
|
|
@@ -194,12 +293,17 @@ function mergeGitlab(existing) {
|
|
|
194
293
|
}
|
|
195
294
|
if (start !== -1) {
|
|
196
295
|
const after = end + GITLAB_END.length;
|
|
197
|
-
|
|
296
|
+
const replacement = gitlabBlock(features).trimEnd();
|
|
297
|
+
const content = `${existing.slice(0, start)}${replacement}${existing.slice(after)}`
|
|
298
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
299
|
+
.replace(/^\n+|\n+$/g, "");
|
|
300
|
+
return { content: content ? `${content}\n` : "" };
|
|
198
301
|
}
|
|
199
|
-
if (/^[ \t]*["']?portolan:(?:check|pages)["']?\s*:/m.test(existing)) {
|
|
302
|
+
if (/^[ \t]*["']?portolan:(?:check|review|pages)["']?\s*:/m.test(existing)) {
|
|
200
303
|
return { conflict: "This pipeline already declares a Portolan job outside the managed region." };
|
|
201
304
|
}
|
|
202
|
-
|
|
305
|
+
const block = gitlabBlock(features);
|
|
306
|
+
return { content: block ? `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${block}` : existing };
|
|
203
307
|
}
|
|
204
308
|
|
|
205
309
|
function textDiff(path, before, after) {
|
|
@@ -251,6 +355,16 @@ function readTarget(workspace, path) {
|
|
|
251
355
|
function githubFile(workspace, path, content) {
|
|
252
356
|
const current = readTarget(workspace, path);
|
|
253
357
|
const managed = current.safe && current.content.startsWith(GENERATED);
|
|
358
|
+
if (content === null) {
|
|
359
|
+
if (!current.exists || !managed) return null;
|
|
360
|
+
return {
|
|
361
|
+
path,
|
|
362
|
+
status: "removed",
|
|
363
|
+
diff: textDiff(path, current.content, ""),
|
|
364
|
+
current: current.content,
|
|
365
|
+
next: "",
|
|
366
|
+
};
|
|
367
|
+
}
|
|
254
368
|
const status = !current.exists ? "added" : !current.safe || !managed ? "conflict" : current.content === content ? "unchanged" : "changed";
|
|
255
369
|
return {
|
|
256
370
|
path,
|
|
@@ -262,39 +376,102 @@ function githubFile(workspace, path, content) {
|
|
|
262
376
|
};
|
|
263
377
|
}
|
|
264
378
|
|
|
265
|
-
function gitlabFile(workspace) {
|
|
379
|
+
function gitlabFile(workspace, features) {
|
|
266
380
|
const path = ".gitlab-ci.yml";
|
|
267
381
|
const current = readTarget(workspace, path);
|
|
268
382
|
if (!current.safe) return { path, status: "conflict", message: "The existing pipeline is not a safe UTF-8 file.", diff: "", current: "", next: "" };
|
|
269
|
-
const merged = mergeGitlab(current.content);
|
|
383
|
+
const merged = mergeGitlab(current.content, features);
|
|
270
384
|
if (merged.conflict) return { path, status: "conflict", message: merged.conflict, diff: "", current: current.content, next: "" };
|
|
271
|
-
const status =
|
|
385
|
+
const status = current.content === merged.content ? "unchanged" : !current.exists ? "added" : "changed";
|
|
272
386
|
return { path, status, diff: textDiff(path, current.content, merged.content), current: current.content, next: merged.content };
|
|
273
387
|
}
|
|
274
388
|
|
|
275
|
-
function revisionFor(provider, files) {
|
|
389
|
+
function revisionFor(provider, features, files) {
|
|
276
390
|
const hash = createHash("sha256").update(provider);
|
|
391
|
+
hash.update("\0"); hash.update([...features].sort().join(","));
|
|
277
392
|
for (const file of files) {
|
|
278
393
|
hash.update("\0"); hash.update(file.path); hash.update("\0"); hash.update(file.current); hash.update("\0"); hash.update(file.next);
|
|
279
394
|
}
|
|
280
395
|
return hash.digest("hex");
|
|
281
396
|
}
|
|
282
397
|
|
|
283
|
-
|
|
398
|
+
function deliveryRequest(request) {
|
|
399
|
+
return typeof request === "string" || request == null ? { provider: request ?? undefined } : request;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function installedFeatures(workspace, provider) {
|
|
403
|
+
const selected = new Set();
|
|
404
|
+
if (provider === "github") {
|
|
405
|
+
const check = readTarget(workspace, ".github/workflows/portolan-check.yml");
|
|
406
|
+
const review = readTarget(workspace, ".github/workflows/portolan-review.yml");
|
|
407
|
+
const pages = readTarget(workspace, ".github/workflows/portolan-pages.yml");
|
|
408
|
+
if (check.safe && check.content.startsWith(GENERATED)) selected.add("check");
|
|
409
|
+
if (review.safe && review.content.startsWith(GENERATED)) {
|
|
410
|
+
selected.add("diff");
|
|
411
|
+
if (review.content.includes("sarif-output:")) selected.add("sarif");
|
|
412
|
+
}
|
|
413
|
+
if (pages.safe && pages.content.startsWith(GENERATED)) selected.add("pages");
|
|
414
|
+
return selected;
|
|
415
|
+
}
|
|
416
|
+
const pipeline = readTarget(workspace, ".gitlab-ci.yml");
|
|
417
|
+
if (!pipeline.safe || !pipeline.content.includes(GITLAB_START)) return selected;
|
|
418
|
+
const managed = pipeline.content.slice(
|
|
419
|
+
pipeline.content.indexOf(GITLAB_START),
|
|
420
|
+
pipeline.content.indexOf(GITLAB_END) + GITLAB_END.length,
|
|
421
|
+
);
|
|
422
|
+
if (/^[ \t]*["']?portolan:check["']?\s*:/m.test(managed)) selected.add("check");
|
|
423
|
+
if (/^[ \t]*["']?portolan:review["']?\s*:/m.test(managed)) selected.add("diff");
|
|
424
|
+
if (/^[ \t]*["']?portolan:pages["']?\s*:/m.test(managed)) selected.add("pages");
|
|
425
|
+
return selected;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function selectedFeatures(workspace, request, provider) {
|
|
429
|
+
const installed = request.features == null ? installedFeatures(workspace, provider) : null;
|
|
430
|
+
const raw = request.features == null
|
|
431
|
+
? installed.size > 0 ? [...installed] : DEFAULT_FEATURES
|
|
432
|
+
: Array.isArray(request.features)
|
|
433
|
+
? request.features
|
|
434
|
+
: String(request.features).split(",").filter(Boolean);
|
|
435
|
+
const selected = new Set(raw);
|
|
436
|
+
for (const feature of selected) {
|
|
437
|
+
if (!FEATURE_IDS.includes(feature)) throw new Error(`Unknown delivery feature ${feature}.`);
|
|
438
|
+
}
|
|
439
|
+
if (selected.has("sarif")) selected.add("diff");
|
|
440
|
+
if (provider !== "github" && selected.has("sarif")) {
|
|
441
|
+
throw new Error("SARIF annotations are currently available only for GitHub.");
|
|
442
|
+
}
|
|
443
|
+
return selected;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function featureOptions(provider, selected) {
|
|
447
|
+
return FEATURE_IDS.map((id) => ({
|
|
448
|
+
id,
|
|
449
|
+
...FEATURE_DETAILS[id],
|
|
450
|
+
selected: selected.has(id),
|
|
451
|
+
available: id !== "sarif" || provider === "github",
|
|
452
|
+
requires: id === "sarif" ? ["diff"] : [],
|
|
453
|
+
}));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function planDeliveryPreset(workspace, requested = {}) {
|
|
457
|
+
const request = deliveryRequest(requested);
|
|
284
458
|
const detected = detectDeliveryForge(workspace);
|
|
285
|
-
const provider =
|
|
459
|
+
const provider = request.provider || detected.provider || "github";
|
|
286
460
|
if (!PROVIDERS.has(provider)) throw new Error("Delivery preset must be github or gitlab.");
|
|
461
|
+
const features = selectedFeatures(workspace, request, provider);
|
|
287
462
|
const files = provider === "github"
|
|
288
463
|
? [
|
|
289
|
-
githubFile(workspace, ".github/workflows/portolan-check.yml", githubCheck(detected.defaultBranch)),
|
|
290
|
-
githubFile(workspace, ".github/workflows/portolan-
|
|
291
|
-
|
|
292
|
-
|
|
464
|
+
githubFile(workspace, ".github/workflows/portolan-check.yml", features.has("check") ? githubCheck(detected.defaultBranch) : null),
|
|
465
|
+
githubFile(workspace, ".github/workflows/portolan-review.yml", features.has("diff") ? githubDiff({ pages: features.has("pages"), sarif: features.has("sarif") }) : null),
|
|
466
|
+
githubFile(workspace, ".github/workflows/portolan-pages.yml", features.has("pages") ? githubPages(detected.defaultBranch) : null),
|
|
467
|
+
].filter(Boolean)
|
|
468
|
+
: [gitlabFile(workspace, features)];
|
|
293
469
|
const status = files.some((file) => file.status === "conflict")
|
|
294
470
|
? "conflict"
|
|
295
471
|
: files.every((file) => file.status === "unchanged")
|
|
296
472
|
? "installed"
|
|
297
|
-
: files.some((file) =>
|
|
473
|
+
: files.some((file) => ["removed", "unchanged"].includes(file.status))
|
|
474
|
+
|| files.some((file) => file.status === "changed" && (provider !== "gitlab" || file.current.includes(GITLAB_START)))
|
|
298
475
|
? "update"
|
|
299
476
|
: "available";
|
|
300
477
|
return {
|
|
@@ -304,8 +481,8 @@ export function planDeliveryPreset(workspace, requestedProvider) {
|
|
|
304
481
|
repository: detected.repository,
|
|
305
482
|
defaultBranch: detected.defaultBranch,
|
|
306
483
|
status,
|
|
307
|
-
revision: revisionFor(provider, files),
|
|
308
|
-
features:
|
|
484
|
+
revision: revisionFor(provider, features, files),
|
|
485
|
+
features: featureOptions(provider, features),
|
|
309
486
|
files,
|
|
310
487
|
};
|
|
311
488
|
}
|
|
@@ -315,7 +492,8 @@ export function publicDeliveryPreset(plan) {
|
|
|
315
492
|
}
|
|
316
493
|
|
|
317
494
|
export function installDeliveryPreset(workspace, request) {
|
|
318
|
-
const
|
|
495
|
+
const selection = { provider: request.provider, features: request.features };
|
|
496
|
+
const plan = planDeliveryPreset(workspace, selection);
|
|
319
497
|
if (!request.revision || request.revision !== plan.revision) {
|
|
320
498
|
const error = new Error("Files changed after this preset preview. Preview it again.");
|
|
321
499
|
error.status = 409;
|
|
@@ -339,6 +517,11 @@ export function installDeliveryPreset(workspace, request) {
|
|
|
339
517
|
for (const file of plan.files) {
|
|
340
518
|
if (file.status === "unchanged") continue;
|
|
341
519
|
const target = resolve(workspace, file.path);
|
|
520
|
+
if (file.status === "removed") {
|
|
521
|
+
rmSync(target);
|
|
522
|
+
written.push(file.path);
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
342
525
|
mkdirSync(dirname(target), { recursive: true });
|
|
343
526
|
const temporary = join(dirname(target), `.portolan-${randomUUID()}.tmp`);
|
|
344
527
|
try {
|
|
@@ -349,5 +532,5 @@ export function installDeliveryPreset(workspace, request) {
|
|
|
349
532
|
}
|
|
350
533
|
written.push(file.path);
|
|
351
534
|
}
|
|
352
|
-
return { ...publicDeliveryPreset(planDeliveryPreset(workspace,
|
|
535
|
+
return { ...publicDeliveryPreset(planDeliveryPreset(workspace, selection)), written };
|
|
353
536
|
}
|
package/scripts/diff.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { enrichCatalog } from "../src/enrich.ts";
|
|
|
29
29
|
import { mergeCatalogs } from "../src/merge.ts";
|
|
30
30
|
import { diffCatalogs, SEVERITIES } from "../src/lib/catalog-diff.ts";
|
|
31
31
|
import { loadCatalog } from "./catalog-sources.mjs";
|
|
32
|
+
import { readManifestText } from "./manifest.mjs";
|
|
32
33
|
|
|
33
34
|
const HEADINGS = {
|
|
34
35
|
breaking: "Breaking",
|
|
@@ -142,7 +143,10 @@ function defaultBase() {
|
|
|
142
143
|
|
|
143
144
|
/** The merged, enriched, validated catalog as it stood at `ref`. */
|
|
144
145
|
function catalogAt(ref) {
|
|
145
|
-
const manifest =
|
|
146
|
+
const manifest = readManifestText(
|
|
147
|
+
git(["show", ref + ":portolan.json"]),
|
|
148
|
+
`${ref}:portolan.json`,
|
|
149
|
+
);
|
|
146
150
|
const patterns = (manifest.sources ?? []).map(globToRegExp);
|
|
147
151
|
|
|
148
152
|
const paths = git(["ls-tree", "-r", "--name-only", ref])
|