@shortlink-org/portolan 0.2.1 → 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/init.mjs +14 -6
- package/cli/init.test.mjs +8 -1
- package/cli/portolan.mjs +92 -13
- package/cli/portolan.test.mjs +47 -0
- package/package.json +7 -6
- package/plugins/extract-django/README.md +1 -1
- package/plugins/extract-django/extract_test.py +1 -1
- package/plugins/extract-django/openapi.py +4 -14
- package/plugins/extract-django/openapi_test.py +4 -4
- package/plugins/extract-go/options.schema.json +5 -0
- package/plugins/extract-project/options.schema.json +21 -0
- package/plugins/extract-ts/README.md +1 -1
- package/plugins/extract-ts/clients.ts +1 -1
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/graphql.test.ts +2 -2
- package/plugins/extract-ts/openapi.test.ts +6 -7
- package/plugins/extract-ts/openapi.ts +3 -10
- package/plugins/portolan-go.wasm +0 -0
- package/public/404-cat-v1.webp +0 -0
- package/public/cat-about-v1.webp +0 -0
- package/public/cat-clear-skies-v1.webp +0 -0
- package/public/cat-no-changes-v1.webp +0 -0
- package/public/cat-onboarding-v1.webp +0 -0
- package/public/cat-trial-success-v1.webp +0 -0
- package/schema/portolan.schema.json +61 -0
- package/scripts/builtin-plugins.mjs +3 -2
- package/scripts/catalog-sources.mjs +14 -2
- package/scripts/catalog-sources.test.mjs +45 -0
- package/scripts/delivery-presets.mjs +207 -24
- package/scripts/diff.mjs +5 -1
- package/scripts/gen-likec4.mjs +242 -85
- package/scripts/gen-likec4.test.mjs +168 -14
- package/scripts/gen.mjs +5 -3
- package/scripts/host-plugins/fetch-git.mjs +22 -12
- package/scripts/host-plugins/fetch-git.test.mjs +25 -5
- package/scripts/local-api.mjs +330 -346
- package/scripts/local-api.test.mjs +245 -1
- package/scripts/local-discovery.mjs +378 -0
- package/scripts/manifest.mjs +42 -1
- package/scripts/manifest.test.mjs +24 -1
- package/scripts/plugin-host.mjs +14 -3
- package/scripts/plugin-host.test.mjs +8 -0
- package/scripts/run-builtin.mjs +4 -2
- package/scripts/schema.mjs +11 -0
- package/scripts/site-docs.mjs +2 -2
- package/src/app/CatalogApp.tsx +23 -8
- package/src/app/CommandPalette.tsx +7 -2
- 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/app/toast.tsx +11 -7
- 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 +49 -0
- package/src/catalog.ts +6 -3124
- package/src/components/CatIllustration.tsx +30 -0
- package/src/components/CommitLink.tsx +154 -0
- package/src/components/EditorLink.tsx +15 -8
- package/src/components/GrpcMethodReference.tsx +93 -0
- package/src/components/Markdown.tsx +17 -0
- package/src/components/MethodRows.tsx +8 -1
- package/src/components/PageHeader.test.tsx +23 -0
- package/src/components/PageHeader.tsx +34 -0
- package/src/components/ProblemRow.tsx +3 -0
- package/src/components/ShapeRows.tsx +12 -0
- package/src/components/SourceDoc.tsx +27 -12
- package/src/components/SourcePreview.tsx +125 -37
- package/src/enrich.test.ts +209 -2
- package/src/enrich.ts +284 -15
- package/src/er/RedisSchema.tsx +86 -5
- package/src/flow/FlowTable.tsx +19 -4
- package/src/flow/StepDetail.tsx +461 -101
- package/src/flow/StepRail.tsx +36 -13
- package/src/flow/answers-response.test.ts +100 -0
- package/src/flow/answers.ts +11 -8
- package/src/flow/chapters.ts +2 -1
- package/src/flow/labels.ts +10 -0
- package/src/flow/mermaid.test.ts +87 -3
- package/src/flow/mermaid.ts +26 -14
- package/src/index.css +132 -17
- package/src/landing/LandingPage.tsx +16 -12
- package/src/lib/all-problems.ts +1 -1
- package/src/lib/api.test.ts +4 -6
- package/src/lib/build-info.test.ts +29 -0
- package/src/lib/build-info.ts +21 -0
- package/src/lib/data-model.ts +14 -0
- package/src/lib/derive.ts +1 -0
- package/src/lib/flow-tree.ts +1 -1
- package/src/lib/github-catalog.test.ts +51 -0
- package/src/lib/github-catalog.ts +69 -0
- package/src/lib/grpc-reference.test.ts +72 -0
- package/src/lib/grpc-reference.ts +117 -0
- package/src/lib/local-api.ts +36 -11
- package/src/lib/motion.test.ts +4 -2
- package/src/lib/motion.tsx +5 -4
- package/src/lib/product.ts +7 -0
- package/src/lib/proto-problems.test.ts +170 -3
- package/src/lib/proto-problems.ts +176 -4
- package/src/lib/queries.ts +16 -1
- package/src/lib/readme-assets.test.ts +19 -0
- package/src/lib/readme-assets.ts +34 -0
- package/src/lib/setup-info.test.ts +7 -0
- package/src/lib/setup-info.ts +5 -0
- package/src/lib/source-link.test.ts +106 -18
- package/src/lib/source-link.ts +22 -6
- package/src/merge.test.ts +101 -0
- package/src/merge.ts +65 -1
- package/src/pages/AdrDetail.tsx +3 -2
- package/src/pages/AdrIndex.tsx +10 -7
- package/src/pages/AggregatePage.tsx +34 -4
- package/src/pages/BlockPage.tsx +39 -32
- package/src/pages/Changes.tsx +7 -9
- package/src/pages/ContextMap.tsx +11 -2
- package/src/pages/ContextPage.tsx +11 -4
- package/src/pages/FlowIndex.tsx +16 -7
- package/src/pages/GraphPage.tsx +21 -7
- package/src/pages/Language.tsx +9 -9
- package/src/pages/NotFound.tsx +58 -15
- package/src/pages/Overview.tsx +6 -0
- package/src/pages/Problems.tsx +10 -8
- package/src/pages/RegistryIndex.tsx +10 -8
- package/src/pages/ServicePage.tsx +1 -1
- package/src/pages/Settings.tsx +257 -44
- package/src/pages/settings/AboutSettings.tsx +129 -0
- package/src/pages/settings/DeliverySettings.tsx +71 -15
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +213 -113
- package/src/selection/pages.test.ts +14 -1
- package/src/selection/pages.ts +9 -3
- package/src/testing/setup.ts +14 -0
- package/vite.config.ts +3 -1
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -888,6 +888,16 @@
|
|
|
888
888
|
"minLength": 1,
|
|
889
889
|
"description": "Component slug inside the group."
|
|
890
890
|
},
|
|
891
|
+
"components": {
|
|
892
|
+
"type": "array",
|
|
893
|
+
"minItems": 2,
|
|
894
|
+
"uniqueItems": true,
|
|
895
|
+
"items": {
|
|
896
|
+
"type": "string",
|
|
897
|
+
"minLength": 1
|
|
898
|
+
},
|
|
899
|
+
"description": "Deployable component slugs discovered inside one repository-backed project."
|
|
900
|
+
},
|
|
891
901
|
"groupKind": {
|
|
892
902
|
"enum": [
|
|
893
903
|
"bounded-context",
|
|
@@ -1025,6 +1035,14 @@
|
|
|
1025
1035
|
],
|
|
1026
1036
|
"description": "Semantic role of the top-level group. Defaults to system for this neutral extractor."
|
|
1027
1037
|
},
|
|
1038
|
+
"classification": {
|
|
1039
|
+
"enum": [
|
|
1040
|
+
"core",
|
|
1041
|
+
"supporting",
|
|
1042
|
+
"generic"
|
|
1043
|
+
],
|
|
1044
|
+
"description": "DDD classification of a bounded context when the group represents one."
|
|
1045
|
+
},
|
|
1028
1046
|
"component": {
|
|
1029
1047
|
"type": "string",
|
|
1030
1048
|
"description": "Slug of the component. Defaults to the input directory name."
|
|
@@ -1058,6 +1076,41 @@
|
|
|
1058
1076
|
"type": "string",
|
|
1059
1077
|
"description": "Source repository identity. Defaults to a Go module, package.json repository, Cargo package, or Maven coordinates when present."
|
|
1060
1078
|
},
|
|
1079
|
+
"components": {
|
|
1080
|
+
"type": "array",
|
|
1081
|
+
"minItems": 1,
|
|
1082
|
+
"items": {
|
|
1083
|
+
"type": "object",
|
|
1084
|
+
"additionalProperties": false,
|
|
1085
|
+
"required": [
|
|
1086
|
+
"slug"
|
|
1087
|
+
],
|
|
1088
|
+
"properties": {
|
|
1089
|
+
"slug": {
|
|
1090
|
+
"type": "string",
|
|
1091
|
+
"minLength": 1
|
|
1092
|
+
},
|
|
1093
|
+
"name": {
|
|
1094
|
+
"type": "string",
|
|
1095
|
+
"minLength": 1
|
|
1096
|
+
},
|
|
1097
|
+
"kind": {
|
|
1098
|
+
"enum": [
|
|
1099
|
+
"service",
|
|
1100
|
+
"application",
|
|
1101
|
+
"webapp",
|
|
1102
|
+
"worker",
|
|
1103
|
+
"job",
|
|
1104
|
+
"function",
|
|
1105
|
+
"cli",
|
|
1106
|
+
"library",
|
|
1107
|
+
"data-pipeline"
|
|
1108
|
+
]
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
},
|
|
1112
|
+
"description": "Independently deployable runtime components found inside one repository. When present, the singular component fields are ignored."
|
|
1113
|
+
},
|
|
1061
1114
|
"out": {
|
|
1062
1115
|
"type": "string",
|
|
1063
1116
|
"description": "Name of the fragment file inside the step output directory.",
|
|
@@ -1133,6 +1186,14 @@
|
|
|
1133
1186
|
"type": "string",
|
|
1134
1187
|
"description": "What the service is called on the page. Defaults to the title of its README, and failing that to the slug, titled."
|
|
1135
1188
|
},
|
|
1189
|
+
"scope": {
|
|
1190
|
+
"type": "string",
|
|
1191
|
+
"description": "Deployable name in a shared Go module. Limits owned source to internal/<scope> while allowing calls to imported application packages to be followed.",
|
|
1192
|
+
"examples": [
|
|
1193
|
+
"api",
|
|
1194
|
+
"billing"
|
|
1195
|
+
]
|
|
1196
|
+
},
|
|
1136
1197
|
"repo": {
|
|
1137
1198
|
"type": "string",
|
|
1138
1199
|
"description": "Where the source lives. Defaults to the module path in go.mod.",
|
|
@@ -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)
|
|
@@ -37,7 +38,11 @@ export async function loadCatalog(manifestPath = "portolan.json", { exclude = []
|
|
|
37
38
|
if (!excluded.has(normalize(path))) paths.push(path);
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
const emptyWorkspace = Array.isArray(manifest.projects)
|
|
42
|
+
&& manifest.projects.length === 0
|
|
43
|
+
&& (manifest.extract ?? []).length === 0
|
|
44
|
+
&& (manifest.verify ?? []).length === 0;
|
|
45
|
+
if (paths.length === 0 && !emptyWorkspace) {
|
|
41
46
|
throw new Error(
|
|
42
47
|
`${manifestPath}: no catalog matched ${JSON.stringify(patterns)}`,
|
|
43
48
|
);
|
|
@@ -49,6 +54,13 @@ export async function loadCatalog(manifestPath = "portolan.json", { exclude = []
|
|
|
49
54
|
catalog: JSON.parse(readFileSync(path, "utf8")),
|
|
50
55
|
})),
|
|
51
56
|
);
|
|
57
|
+
if (paths.length === 0) {
|
|
58
|
+
// An intentionally empty workspace still needs a valid, deterministic
|
|
59
|
+
// catalog value for generators and the local preview. These are state
|
|
60
|
+
// markers, not provenance claims: there is no source file to stamp.
|
|
61
|
+
merged.catalog.generatedAt = "1970-01-01T00:00:00Z";
|
|
62
|
+
merged.catalog.commit = "empty";
|
|
63
|
+
}
|
|
52
64
|
|
|
53
65
|
// The edges the flows imply are added before validation, the same way the
|
|
54
66
|
// app does it, so a generator draws the same estate the reader sees.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { loadCatalog } from "./catalog-sources.mjs";
|
|
7
|
+
|
|
8
|
+
const roots = [];
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function manifest(value) {
|
|
14
|
+
const root = mkdtempSync(join(tmpdir(), "portolan-empty-catalog-"));
|
|
15
|
+
roots.push(root);
|
|
16
|
+
const path = join(root, "portolan.json");
|
|
17
|
+
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
18
|
+
return { path, missing: join(root, "portolan/*.json") };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("catalog sources", () => {
|
|
22
|
+
it("loads an intentional empty workspace as an empty catalog", async () => {
|
|
23
|
+
const { path, missing } = manifest({ sources: [], projects: [], extract: [], verify: [] });
|
|
24
|
+
writeFileSync(path, `${JSON.stringify({ sources: [missing], projects: [], extract: [], verify: [] }, null, 2)}\n`);
|
|
25
|
+
const loaded = await loadCatalog(path);
|
|
26
|
+
expect(loaded.sources).toEqual([]);
|
|
27
|
+
expect(loaded.catalog).toMatchObject({ contexts: [], defs: {}, flows: [], adrs: [] });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("still reports missing sources for a configured project", async () => {
|
|
31
|
+
const { path, missing } = manifest({ sources: [], projects: [] });
|
|
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`);
|
|
43
|
+
await expect(loadCatalog(path)).rejects.toThrow(/no catalog matched/);
|
|
44
|
+
});
|
|
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])
|