@shortlink-org/portolan 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/README.md +151 -6
  2. package/catalog/model.go +147 -0
  3. package/catalog/roundtrip_test.go +7 -0
  4. package/cli/portolan.mjs +6 -1
  5. package/go.mod +7 -0
  6. package/go.sum +20 -6
  7. package/package.json +10 -9
  8. package/plugin/describe.go +38 -0
  9. package/plugins/README.md +296 -5
  10. package/plugins/extract-argocd/options.schema.json +44 -0
  11. package/plugins/extract-celery/main.py +1 -0
  12. package/plugins/extract-csharp-ddd/README.md +213 -0
  13. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  14. package/plugins/extract-django/README.md +13 -2
  15. package/plugins/extract-django/choices.py +47 -0
  16. package/plugins/extract-django/domain.py +2 -1
  17. package/plugins/extract-django/extract_test.py +13 -0
  18. package/plugins/extract-django/lifecycle.py +2 -28
  19. package/plugins/extract-django/main.py +1 -0
  20. package/plugins/extract-django/rules.py +154 -0
  21. package/plugins/extract-django/rules_test.py +158 -0
  22. package/plugins/extract-go-sqs/options.schema.json +21 -0
  23. package/plugins/extract-http-clients/describe.go +5 -4
  24. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  25. package/plugins/extract-k8s/options.schema.json +32 -0
  26. package/plugins/extract-laravel/Cargo.lock +962 -0
  27. package/plugins/extract-laravel/Cargo.toml +20 -0
  28. package/plugins/extract-laravel/README.md +200 -0
  29. package/plugins/extract-laravel/options.schema.json +68 -0
  30. package/plugins/extract-laravel/rustfmt.toml +1 -0
  31. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  32. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  33. package/plugins/extract-php-ddd/README.md +141 -0
  34. package/plugins/extract-php-ddd/options.schema.json +50 -0
  35. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  36. package/plugins/extract-python-kafka/main.py +1 -0
  37. package/plugins/extract-terraform/options.schema.json +26 -0
  38. package/plugins/extract-ts/main.ts +1 -0
  39. package/plugins/phpscan/Cargo.lock +953 -0
  40. package/plugins/phpscan/Cargo.toml +19 -0
  41. package/plugins/phpscan/rustfmt.toml +1 -0
  42. package/plugins/portolan-go.wasm +0 -0
  43. package/plugins/pyplugin/catalog.py +24 -1
  44. package/plugins/verify-otel/options.schema.json +12 -0
  45. package/portolan.json +130 -3
  46. package/rules/builtin.json +218 -0
  47. package/schema/portolan.schema.json +880 -13
  48. package/scripts/README.md +3 -1
  49. package/scripts/delivery-presets.mjs +36 -3
  50. package/scripts/gen-likec4.mjs +149 -16
  51. package/scripts/gen-likec4.test.mjs +96 -0
  52. package/scripts/gen.mjs +30 -3
  53. package/scripts/gitops-example.test.mjs +108 -0
  54. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  55. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  56. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  57. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  58. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  59. package/scripts/host-plugins/fetch-git.mjs +1 -0
  60. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  61. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  62. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  63. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  64. package/scripts/json-format.mjs +192 -0
  65. package/scripts/json-format.test.mjs +97 -0
  66. package/scripts/local-api.mjs +216 -8
  67. package/scripts/local-api.test.mjs +88 -2
  68. package/scripts/local-discovery.mjs +12 -0
  69. package/scripts/manifest.mjs +10 -1
  70. package/scripts/plugin-host.mjs +23 -1
  71. package/scripts/plugins-fresh.mjs +97 -0
  72. package/scripts/plugins-fresh.test.mjs +64 -0
  73. package/scripts/run-builtin.mjs +26 -5
  74. package/scripts/schema.mjs +138 -19
  75. package/scripts/trace-trials.mjs +176 -0
  76. package/scripts/trace-trials.test.mjs +142 -0
  77. package/scripts/warning-policy.mjs +12 -6
  78. package/scripts/warning-policy.test.mjs +40 -3
  79. package/src/app/Breadcrumbs.test.ts +3 -0
  80. package/src/app/Breadcrumbs.tsx +3 -0
  81. package/src/app/CatalogApp.tsx +2 -0
  82. package/src/app/SidebarFooter.tsx +20 -4
  83. package/src/catalog-index.ts +25 -0
  84. package/src/catalog-model.ts +246 -1
  85. package/src/catalog-validation.ts +104 -2
  86. package/src/catalog.test.ts +40 -1
  87. package/src/components/ChannelRows.test.tsx +76 -1
  88. package/src/components/ChannelRows.tsx +223 -60
  89. package/src/components/DeploymentRows.tsx +131 -0
  90. package/src/components/DocsLinks.test.tsx +27 -0
  91. package/src/components/DocsLinks.tsx +56 -0
  92. package/src/components/FieldTree.tsx +5 -2
  93. package/src/components/HTTPDestinationEvidence.tsx +1 -1
  94. package/src/components/PluginIcon.tsx +77 -0
  95. package/src/components/ProblemRow.tsx +173 -153
  96. package/src/components/RuleMarks.tsx +22 -0
  97. package/src/components/ShapeRows.tsx +24 -20
  98. package/src/data.ts +21 -0
  99. package/src/enrich.test.ts +127 -3
  100. package/src/enrich.ts +104 -6
  101. package/src/er/ErCanvas.tsx +218 -12
  102. package/src/er/GroupNode.tsx +57 -0
  103. package/src/er/StoreHeader.tsx +1 -0
  104. package/src/er/layout.test.ts +85 -2
  105. package/src/er/layout.ts +140 -5
  106. package/src/er/spec.test.ts +17 -0
  107. package/src/er/spec.ts +23 -10
  108. package/src/flow/Recordings.test.tsx +52 -0
  109. package/src/flow/Recordings.tsx +236 -0
  110. package/src/flow/StepDetail.tsx +53 -0
  111. package/src/flow/TraceTrial.tsx +419 -0
  112. package/src/flow/examples.test.ts +33 -0
  113. package/src/flow/examples.ts +37 -0
  114. package/src/flow/outline.test.ts +28 -0
  115. package/src/flow/outline.ts +7 -2
  116. package/src/flow/trace-trial-resume.test.ts +42 -0
  117. package/src/flow/trace-trial-resume.ts +74 -0
  118. package/src/graph/elk.ts +78 -0
  119. package/src/index.css +24 -0
  120. package/src/landing/LandingPage.tsx +15 -11
  121. package/src/landing/ProductTour.tsx +6 -6
  122. package/src/lib/all-problems.ts +27 -17
  123. package/src/lib/centrality.test.ts +251 -0
  124. package/src/lib/centrality.ts +232 -0
  125. package/src/lib/confluence.test.ts +37 -0
  126. package/src/lib/confluence.ts +41 -0
  127. package/src/lib/context-color.ts +1 -1
  128. package/src/lib/deployment-drift.ts +22 -0
  129. package/src/lib/derive.ts +43 -69
  130. package/src/lib/environments.test.ts +83 -0
  131. package/src/lib/environments.ts +57 -0
  132. package/src/lib/integration-url.test.ts +30 -0
  133. package/src/lib/integration-url.ts +63 -0
  134. package/src/lib/kafka-ui.ts +3 -45
  135. package/src/lib/local-api.ts +96 -2
  136. package/src/lib/notion.ts +13 -0
  137. package/src/lib/plugin-index.json +2765 -0
  138. package/src/lib/plugins.test.ts +68 -0
  139. package/src/lib/plugins.ts +264 -0
  140. package/src/lib/problem-flows.test.ts +61 -0
  141. package/src/lib/problem-flows.ts +78 -0
  142. package/src/lib/problem-rules-cel.d.mts +43 -0
  143. package/src/lib/problem-rules-cel.mjs +407 -0
  144. package/src/lib/problem-rules.test.ts +287 -0
  145. package/src/lib/problem-rules.ts +271 -0
  146. package/src/lib/problem-subjects.ts +737 -0
  147. package/src/lib/rule-entries.ts +39 -0
  148. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  149. package/src/lib/rules-deploy.test.ts +133 -0
  150. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  151. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  152. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  153. package/src/lib/rules.test.ts +51 -0
  154. package/src/lib/rules.ts +86 -0
  155. package/src/lib/shape.test.ts +32 -0
  156. package/src/lib/shape.ts +30 -6
  157. package/src/lib/tech.ts +16 -0
  158. package/src/lib/trace-project.test.ts +34 -0
  159. package/src/lib/trace-project.ts +50 -0
  160. package/src/lib/use-problems.ts +23 -0
  161. package/src/lib/warnings.test.ts +9 -0
  162. package/src/lib/warnings.ts +10 -10
  163. package/src/likec4/ids.test.ts +6 -2
  164. package/src/likec4/ids.ts +43 -0
  165. package/src/main.tsx +23 -0
  166. package/src/merge-deployments.test.ts +127 -0
  167. package/src/merge.test.ts +82 -0
  168. package/src/merge.ts +173 -9
  169. package/src/pages/AggregatePage.tsx +57 -7
  170. package/src/pages/ContextMap.tsx +45 -3
  171. package/src/pages/ContextPage.tsx +2 -0
  172. package/src/pages/EventPage.tsx +15 -5
  173. package/src/pages/FlowDetail.tsx +23 -2
  174. package/src/pages/GraphPage.tsx +40 -3
  175. package/src/pages/Overview.tsx +152 -12
  176. package/src/pages/PluginIndex.tsx +190 -0
  177. package/src/pages/Problems.tsx +396 -128
  178. package/src/pages/ServicePage.tsx +58 -2
  179. package/src/pages/Settings.tsx +28 -2
  180. package/src/pages/settings/AboutSettings.tsx +8 -1
  181. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  182. package/src/pages/settings/RecordingSettings.tsx +138 -0
  183. package/src/pages/settings/RulesSettings.tsx +825 -0
  184. package/src/routes.test.ts +9 -0
  185. package/src/routes.ts +22 -1
  186. package/src/lib/data-problems.ts +0 -314
  187. package/src/lib/proto-problems.ts +0 -237
  188. package/src/lib/wire-problems.ts +0 -342
@@ -0,0 +1,183 @@
1
+ // The part of reading Kubernetes objects that does not care where they came
2
+ // from: which names a workload answers on, and which in-cluster names it is
3
+ // configured to dial. fetch-k8s hands it what `kubectl get -o json` said;
4
+ // the same rules, in Go, read the manifests in a repository (extract-k8s),
5
+ // and the two are held together by the same table of cases in their tests.
6
+ //
7
+ // Nothing here writes a value. A value is looked at once, for one question -
8
+ // does it name a host inside the cluster - and the host alone survives.
9
+
10
+ export const WORKLOAD_KINDS = {
11
+ Deployment: "",
12
+ StatefulSet: "",
13
+ DaemonSet: "",
14
+ ReplicaSet: "",
15
+ Job: "job",
16
+ CronJob: "job",
17
+ };
18
+
19
+ /** Every DNS form the cluster answers for one Service. */
20
+ export function hostForms(name, namespace) {
21
+ const ns = namespace || "default";
22
+ return [name, `${name}.${ns}`, `${name}.${ns}.svc`, `${name}.${ns}.svc.cluster.local`];
23
+ }
24
+
25
+ const BARE_HOST = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:[0-9]+)?$/;
26
+ // A name only a cluster's DNS answers for, which is what lets a name from
27
+ // another namespace's Service through: nothing else looks like `pricing.shop.svc`.
28
+ const SVC_FORM = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?\.[a-z0-9]([a-z0-9-]*[a-z0-9])?\.svc(\.cluster\.local)?$/;
29
+
30
+ /**
31
+ * The in-cluster host a configured value names, or undefined. A URL gives up
32
+ * its hostname and keeps nothing else - not the scheme, port, path, query,
33
+ * nor the user and password in front of the host. A bare `host[:port]`
34
+ * gives up the port. What is left qualifies when the tree's own Services
35
+ * answer to it, or when it has the `<name>.<namespace>.svc` shape. A
36
+ * password, a token, a level, a flag: none is a host, and none passes.
37
+ *
38
+ * @param {string} value
39
+ * @param {Set<string>} known
40
+ */
41
+ export function hostOnly(value, known) {
42
+ const trimmed = String(value ?? "").trim();
43
+ if (!trimmed) return undefined;
44
+ let host;
45
+ if (trimmed.includes("://")) {
46
+ try {
47
+ host = new URL(trimmed).hostname.toLowerCase();
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ } else {
52
+ const lower = trimmed.toLowerCase();
53
+ if (!BARE_HOST.test(lower)) return undefined;
54
+ host = lower.replace(/:[0-9]+$/, "");
55
+ }
56
+ if (!host) return undefined;
57
+ return known.has(host) || SVC_FORM.test(host) ? host : undefined;
58
+ }
59
+
60
+ /** Whether a Service's selector matches a pod's labels: every key, with its value. */
61
+ export function selects(selector, podLabels) {
62
+ const keys = Object.keys(selector ?? {});
63
+ if (keys.length === 0) return false;
64
+ return keys.every((key) => (podLabels ?? {})[key] === selector[key]);
65
+ }
66
+
67
+ export function podTemplate(object) {
68
+ const spec = object.spec ?? {};
69
+ if (object.kind === "CronJob") return spec.jobTemplate?.spec?.template ?? {};
70
+ return spec.template ?? {};
71
+ }
72
+
73
+ /**
74
+ * Every configured value of one container this reader may look at: a literal
75
+ * `value`, a `configMapKeyRef` followed into the ConfigMaps read, and what an
76
+ * `envFrom.configMapRef` brings in. A `secretKeyRef` or `secretRef` is not
77
+ * followed, by rule.
78
+ */
79
+ export function containerValues(container, namespace, configMaps) {
80
+ const out = [];
81
+ for (const env of container.env ?? []) {
82
+ if (typeof env.value === "string" && env.value.trim()) {
83
+ out.push(env.value);
84
+ continue;
85
+ }
86
+ const ref = env.valueFrom?.configMapKeyRef;
87
+ if (!ref) continue;
88
+ const data = configMaps.get(`${namespace}/${ref.name ?? ""}`);
89
+ if (data && Object.hasOwn(data, ref.key ?? "")) out.push(String(data[ref.key]));
90
+ }
91
+ for (const from of container.envFrom ?? []) {
92
+ const ref = from.configMapRef;
93
+ if (!ref) continue;
94
+ const data = configMaps.get(`${namespace}/${ref.name ?? ""}`) ?? {};
95
+ for (const key of Object.keys(data).sort()) out.push(String(data[key]));
96
+ }
97
+ return out;
98
+ }
99
+
100
+ /** The hosts an Ingress or a Gateway API route answers on for the named Services. */
101
+ export function frontingHosts(objects, backends) {
102
+ const out = [];
103
+ for (const object of objects) {
104
+ const spec = object.spec ?? {};
105
+ if (object.kind === "Ingress") {
106
+ const rules = Array.isArray(spec.rules) ? spec.rules : [];
107
+ if (backends.has(spec.defaultBackend?.service?.name ?? "")) {
108
+ for (const rule of rules) if (rule.host) out.push(rule.host);
109
+ }
110
+ for (const rule of rules) {
111
+ if (!rule.host) continue;
112
+ const paths = Array.isArray(rule.http?.paths) ? rule.http.paths : [];
113
+ if (paths.some((path) => backends.has(path.backend?.service?.name ?? ""))) out.push(rule.host);
114
+ }
115
+ } else if (["HTTPRoute", "GRPCRoute", "TLSRoute"].includes(object.kind)) {
116
+ const hostnames = Array.isArray(spec.hostnames) ? spec.hostnames : [];
117
+ if (hostnames.length === 0) continue;
118
+ const rules = Array.isArray(spec.rules) ? spec.rules : [];
119
+ if (rules.some((rule) => (rule.backendRefs ?? []).some((ref) => backends.has(ref.name ?? "")))) out.push(...hostnames);
120
+ }
121
+ }
122
+ return out;
123
+ }
124
+
125
+ function sortedUnique(values) {
126
+ return [...new Set(values.filter(Boolean))].sort();
127
+ }
128
+
129
+ /**
130
+ * Every workload among the objects, with the names it answers on and the
131
+ * names it dials. Objects are what the API returned: `kind`, `metadata`,
132
+ * `spec`, and for a ConfigMap `data`. A Secret is never among them, because
133
+ * it is never asked for.
134
+ *
135
+ * @returns {{object: object, kind: string, hosts: string[], dials: string[]}[]}
136
+ */
137
+ export function topology(objects) {
138
+ const configMaps = new Map();
139
+ const services = [];
140
+ const known = new Set();
141
+ for (const object of objects) {
142
+ const ns = object.metadata?.namespace ?? "";
143
+ const name = object.metadata?.name ?? "";
144
+ if (!name) continue;
145
+ if (object.kind === "ConfigMap") {
146
+ const data = configMaps.get(`${ns}/${name}`) ?? {};
147
+ for (const [key, value] of Object.entries(object.data ?? {})) data[key] = value;
148
+ configMaps.set(`${ns}/${name}`, data);
149
+ } else if (object.kind === "Service") {
150
+ services.push({ name, namespace: ns, selector: object.spec?.selector ?? {} });
151
+ for (const form of hostForms(name, ns)) known.add(form);
152
+ }
153
+ }
154
+
155
+ const out = [];
156
+ for (const object of objects) {
157
+ if (!Object.hasOwn(WORKLOAD_KINDS, object.kind ?? "")) continue;
158
+ const ns = object.metadata?.namespace ?? "";
159
+ if (!object.metadata?.name) continue;
160
+ const template = podTemplate(object);
161
+ const podLabels = template.metadata?.labels ?? {};
162
+ const backends = new Set();
163
+ const hosts = [];
164
+ for (const service of services) {
165
+ if (service.namespace === ns && selects(service.selector, podLabels)) {
166
+ hosts.push(...hostForms(service.name, service.namespace));
167
+ backends.add(service.name);
168
+ }
169
+ }
170
+ hosts.push(...frontingHosts(objects.filter((o) => (o.metadata?.namespace ?? "") === ns), backends));
171
+ const own = new Set(hosts);
172
+ const dials = [];
173
+ const spec = template.spec ?? {};
174
+ for (const container of [...(spec.initContainers ?? []), ...(spec.containers ?? [])]) {
175
+ for (const value of containerValues(container, ns, configMaps)) {
176
+ const host = hostOnly(value, known);
177
+ if (host && !own.has(host)) dials.push(host);
178
+ }
179
+ }
180
+ out.push({ object, kind: WORKLOAD_KINDS[object.kind], hosts: sortedUnique(hosts), dials: sortedUnique(dials) });
181
+ }
182
+ return out;
183
+ }
@@ -0,0 +1,192 @@
1
+ // Writes a JSON value the way the file already on disk writes it.
2
+ //
3
+ // A manifest is edited by hand and by the page, and the two must not fight:
4
+ // a page that adds one glob to one array and rewrites every other array in
5
+ // the file has made a diff nobody can review. So the value is written against
6
+ // the old text. A subtree that did not change is copied out of the old text
7
+ // byte for byte; one that did is written in the style its old self had - an
8
+ // array on one line stays on one line, one spread over lines stays spread -
9
+ // and only something with no old self at all gets the default shape: scalars
10
+ // on one line, objects and arrays of objects spread.
11
+
12
+ /** A parsed value with where it sat in the text it came from. */
13
+ class Node {
14
+ constructor(type, start, end, value) {
15
+ this.type = type;
16
+ this.start = start;
17
+ this.end = end;
18
+ this.value = value;
19
+ /** For objects: key -> Node, in source order. For arrays: Node[]. */
20
+ this.children = type === "object" ? new Map() : type === "array" ? [] : null;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Parses JSON into nodes that remember their source range. Strict JSON, the
26
+ * same grammar JSON.parse accepts; anything else throws, and the caller
27
+ * falls back to writing from scratch.
28
+ */
29
+ export function parseWithRanges(text) {
30
+ let at = 0;
31
+ const skip = () => {
32
+ while (at < text.length && /\s/.test(text[at])) at += 1;
33
+ };
34
+ const expect = (ch) => {
35
+ if (text[at] !== ch) throw new SyntaxError(`Expected ${ch} at ${at}`);
36
+ at += 1;
37
+ };
38
+ const scalarEnd = () => {
39
+ if (text[at] === '"') {
40
+ let i = at + 1;
41
+ while (i < text.length) {
42
+ if (text[i] === "\\") i += 2;
43
+ else if (text[i] === '"') return i + 1;
44
+ else i += 1;
45
+ }
46
+ throw new SyntaxError("Unterminated string");
47
+ }
48
+ const m = /^(?:-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)/.exec(text.slice(at));
49
+ if (!m) throw new SyntaxError(`Unexpected token at ${at}`);
50
+ return at + m[0].length;
51
+ };
52
+ const value = () => {
53
+ skip();
54
+ const start = at;
55
+ if (text[at] === "{") {
56
+ at += 1;
57
+ const node = new Node("object", start, 0, {});
58
+ skip();
59
+ if (text[at] === "}") {
60
+ at += 1;
61
+ node.end = at;
62
+ return node;
63
+ }
64
+ for (;;) {
65
+ skip();
66
+ const keyEnd = scalarEnd();
67
+ if (text[at] !== '"') throw new SyntaxError(`Expected a key at ${at}`);
68
+ const key = JSON.parse(text.slice(at, keyEnd));
69
+ at = keyEnd;
70
+ skip();
71
+ expect(":");
72
+ const child = value();
73
+ node.children.set(key, child);
74
+ node.value[key] = child.value;
75
+ skip();
76
+ if (text[at] === ",") {
77
+ at += 1;
78
+
79
+ continue;
80
+ }
81
+ expect("}");
82
+ node.end = at;
83
+
84
+ return node;
85
+ }
86
+ }
87
+ if (text[at] === "[") {
88
+ at += 1;
89
+ const node = new Node("array", start, 0, []);
90
+ skip();
91
+ if (text[at] === "]") {
92
+ at += 1;
93
+ node.end = at;
94
+ return node;
95
+ }
96
+ for (;;) {
97
+ const child = value();
98
+ node.children.push(child);
99
+ node.value.push(child.value);
100
+ skip();
101
+ if (text[at] === ",") {
102
+ at += 1;
103
+
104
+ continue;
105
+ }
106
+ expect("]");
107
+ node.end = at;
108
+
109
+ return node;
110
+ }
111
+ }
112
+ const end = scalarEnd();
113
+ const node = new Node("scalar", start, end, JSON.parse(text.slice(start, end)));
114
+ at = end;
115
+
116
+ return node;
117
+ };
118
+ const root = value();
119
+ skip();
120
+ if (at !== text.length) throw new SyntaxError(`Trailing content at ${at}`);
121
+
122
+ return root;
123
+ }
124
+
125
+ function same(a, b) {
126
+ if (a === b) return true;
127
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
128
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
129
+ if (Array.isArray(a)) return a.length === b.length && a.every((item, i) => same(item, b[i]));
130
+ const keys = Object.keys(a);
131
+ if (keys.length !== Object.keys(b).length) return false;
132
+
133
+ return keys.every((key) => Object.hasOwn(b, key) && same(a[key], b[key]));
134
+ }
135
+
136
+ function isScalar(value) {
137
+ return value === null || typeof value !== "object";
138
+ }
139
+
140
+ /** The indent step the old text uses, or two spaces. */
141
+ function indentOf(text) {
142
+ const m = /\n([ \t]+)\S/.exec(text);
143
+
144
+ return m ? m[1] : " ";
145
+ }
146
+
147
+ /**
148
+ * Writes `value` in the style of `oldText`. Keys already in the old text
149
+ * keep their order; new keys go after them. A value with no old self is
150
+ * written the default way.
151
+ */
152
+ export function formatLike(oldText, value) {
153
+ let root = null;
154
+ try {
155
+ root = parseWithRanges(oldText);
156
+ } catch {
157
+ return JSON.stringify(value, null, 2);
158
+ }
159
+ const step = indentOf(oldText);
160
+ const slice = (node) => oldText.slice(node.start, node.end);
161
+ const multiline = (node) => slice(node).includes("\n");
162
+
163
+ const write = (node, next, indent) => {
164
+ if (node && same(node.value, next)) return slice(node);
165
+ if (isScalar(next)) return JSON.stringify(next);
166
+ const inner = indent + step;
167
+
168
+ if (Array.isArray(next)) {
169
+ if (next.length === 0) return "[]";
170
+ const old = node && node.type === "array" ? node : null;
171
+ const spread = old ? multiline(old) : !next.every(isScalar);
172
+ const items = next.map((item, i) => write(old?.children[i], item, spread ? inner : indent));
173
+ if (!spread) return "[" + items.join(", ") + "]";
174
+
175
+ return "[\n" + items.map((item) => inner + item).join(",\n") + "\n" + indent + "]";
176
+ }
177
+
178
+ const keys = Object.keys(next);
179
+ if (keys.length === 0) return "{}";
180
+ const old = node && node.type === "object" ? node : null;
181
+ const ordered = old
182
+ ? [...[...old.children.keys()].filter((key) => Object.hasOwn(next, key)), ...keys.filter((key) => !old.children.has(key))]
183
+ : keys;
184
+ const spread = old ? multiline(old) : true;
185
+ const entries = ordered.map((key) => JSON.stringify(key) + ": " + write(old?.children.get(key), next[key], spread ? inner : indent));
186
+ if (!spread) return "{ " + entries.join(", ") + " }";
187
+
188
+ return "{\n" + entries.map((entry) => inner + entry).join(",\n") + "\n" + indent + "}";
189
+ };
190
+
191
+ return write(root, value, "");
192
+ }
@@ -0,0 +1,97 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { formatLike, parseWithRanges } from "./json-format.mjs";
5
+
6
+ const manifest = `{
7
+ "$schema": "./schema/portolan.schema.json",
8
+ "sources": ["portolan/*.json", "examples/*/portolan/*.json"],
9
+ "projects": [
10
+ {
11
+ "id": "auth",
12
+ "root": "examples/auth"
13
+ }
14
+ ],
15
+ "verify": [
16
+ {
17
+ "plugin": "otel",
18
+ "in": "examples/auth",
19
+ "options": {
20
+ "traces": [
21
+ "telemetry/traces.jsonl"
22
+ ],
23
+ "out": "observed.json"
24
+ }
25
+ }
26
+ ],
27
+ "empty": [],
28
+ "number": 1e3
29
+ }`;
30
+
31
+ function lines(a, b) {
32
+ const x = a.split("\n");
33
+ const y = b.split("\n");
34
+ return y.filter((line) => !x.includes(line));
35
+ }
36
+
37
+ describe("writing a value the way the file already writes it", () => {
38
+ it("copies the text back byte for byte when nothing changed", () => {
39
+ expect(formatLike(manifest, JSON.parse(manifest))).toBe(manifest);
40
+ });
41
+
42
+ it("changes only the line that changed, in the style the array had", () => {
43
+ const next = JSON.parse(manifest);
44
+ next.verify[0].options.traces.push("telemetry/recordings/*.jsonl");
45
+ next.sources.push("data/*.json");
46
+ const out = formatLike(manifest, next);
47
+
48
+ expect(JSON.parse(out)).toEqual(next);
49
+ expect(lines(manifest, out)).toEqual([
50
+ ' "sources": ["portolan/*.json", "examples/*/portolan/*.json", "data/*.json"],',
51
+ ' "telemetry/traces.jsonl",',
52
+ ' "telemetry/recordings/*.jsonl"',
53
+ ]);
54
+ });
55
+
56
+ it("keeps the old key order and appends a new key after it", () => {
57
+ const next = { ...JSON.parse(manifest), added: { a: 1 } };
58
+ delete next.empty;
59
+ const out = formatLike(manifest, next);
60
+
61
+ expect(out.indexOf('"$schema"')).toBeLessThan(out.indexOf('"sources"'));
62
+ expect(out).not.toContain('"empty"');
63
+ expect(out.endsWith(' "number": 1e3,\n "added": {\n "a": 1\n }\n}')).toBe(true);
64
+ });
65
+
66
+ it("writes something with no old self the default way: scalars inline, objects spread", () => {
67
+ const next = JSON.parse(manifest);
68
+ next.verify.push({ plugin: "codeowners", in: ".github", out: "data", options: { files: ["CODEOWNERS"], nested: [{ a: 1 }] } });
69
+ const out = formatLike(manifest, next);
70
+
71
+ expect(JSON.parse(out)).toEqual(next);
72
+ expect(out).toContain(' {\n "plugin": "codeowners",\n "in": ".github",\n "out": "data",\n "options": {\n "files": ["CODEOWNERS"],\n "nested": [\n {\n "a": 1\n }\n ]\n }\n }\n ],');
73
+ });
74
+
75
+ it("keeps a spread array spread even when an element is replaced", () => {
76
+ const next = JSON.parse(manifest);
77
+ next.verify[0].options.traces = ["telemetry/other.jsonl"];
78
+ const out = formatLike(manifest, next);
79
+ expect(out).toContain('"traces": [\n "telemetry/other.jsonl"\n ],');
80
+ });
81
+
82
+ it("falls back to plain JSON when the old text is not JSON", () => {
83
+ expect(formatLike("not json", { a: [1, 2] })).toBe('{\n "a": [\n 1,\n 2\n ]\n}');
84
+ });
85
+
86
+ it("reads the repository's own manifest back unchanged", () => {
87
+ const text = readFileSync(new URL("../portolan.json", import.meta.url), "utf8").replace(/\n$/, "");
88
+ expect(parseWithRanges(text).value).toEqual(JSON.parse(text));
89
+ expect(formatLike(text, JSON.parse(text))).toBe(text);
90
+ });
91
+
92
+ it("parses strings with escapes and rejects what JSON.parse rejects", () => {
93
+ expect(parseWithRanges('{"a": "x\\"y", "b": [true, null, -1.5e2]}').value).toEqual({ a: 'x"y', b: [true, null, -150] });
94
+ expect(() => parseWithRanges('{"a": 1,}')).toThrow();
95
+ expect(() => parseWithRanges("[1] 2")).toThrow();
96
+ });
97
+ });