@goodbones/browser 0.1.0-beta.1
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/LICENSE +21 -0
- package/build/dts/index.d.ts +12 -0
- package/build/dts/index.d.ts.map +1 -0
- package/build/dts/main.d.ts +3 -0
- package/build/dts/main.d.ts.map +1 -0
- package/build/dts/model/atlas.d.ts +157 -0
- package/build/dts/model/atlas.d.ts.map +1 -0
- package/build/dts/model/campaigns.d.ts +141 -0
- package/build/dts/model/campaigns.d.ts.map +1 -0
- package/build/dts/server/collect.d.ts +16 -0
- package/build/dts/server/collect.d.ts.map +1 -0
- package/build/dts/server/compose.d.ts +13 -0
- package/build/dts/server/compose.d.ts.map +1 -0
- package/build/dts/server/export.d.ts +3 -0
- package/build/dts/server/export.d.ts.map +1 -0
- package/build/dts/server/handler.d.ts +6 -0
- package/build/dts/server/handler.d.ts.map +1 -0
- package/build/dts/server/serve.d.ts +15 -0
- package/build/dts/server/serve.d.ts.map +1 -0
- package/build/dts/server/site.d.ts +2 -0
- package/build/dts/server/site.d.ts.map +1 -0
- package/build/dts/server/source.d.ts +19 -0
- package/build/dts/server/source.d.ts.map +1 -0
- package/build/dts/server/vite-plugin.d.ts +8 -0
- package/build/dts/server/vite-plugin.d.ts.map +1 -0
- package/build/dts/server/watch.d.ts +10 -0
- package/build/dts/server/watch.d.ts.map +1 -0
- package/build/esm/index.js +14 -0
- package/build/esm/index.js.map +1 -0
- package/build/esm/main.js +134 -0
- package/build/esm/main.js.map +1 -0
- package/build/esm/model/atlas.js +431 -0
- package/build/esm/model/atlas.js.map +1 -0
- package/build/esm/model/campaigns.js +181 -0
- package/build/esm/model/campaigns.js.map +1 -0
- package/build/esm/server/collect.js +83 -0
- package/build/esm/server/collect.js.map +1 -0
- package/build/esm/server/compose.js +50 -0
- package/build/esm/server/compose.js.map +1 -0
- package/build/esm/server/export.js +19 -0
- package/build/esm/server/export.js.map +1 -0
- package/build/esm/server/handler.js +67 -0
- package/build/esm/server/handler.js.map +1 -0
- package/build/esm/server/serve.js +80 -0
- package/build/esm/server/serve.js.map +1 -0
- package/build/esm/server/site.js +6 -0
- package/build/esm/server/site.js.map +1 -0
- package/build/esm/server/source.js +67 -0
- package/build/esm/server/source.js.map +1 -0
- package/build/esm/server/vite-plugin.js +21 -0
- package/build/esm/server/vite-plugin.js.map +1 -0
- package/build/esm/server/watch.js +56 -0
- package/build/esm/server/watch.js.map +1 -0
- package/build/site/_astro/Browser.yxldcnQ3.js +3 -0
- package/build/site/_astro/client.Buyw3Q1S.js +9 -0
- package/build/site/_astro/index.DQrWs1cb.css +1 -0
- package/build/site/_astro/react.DJY1zw8Z.js +1 -0
- package/build/site/favicon.svg +1 -0
- package/build/site/index.html +8 -0
- package/package.json +78 -0
- package/src/app/components/Browser.tsx +153 -0
- package/src/app/components/architecture/ArchitectureBrowser.tsx +240 -0
- package/src/app/components/architecture/Detail.tsx +655 -0
- package/src/app/components/architecture/ManifestPane.tsx +178 -0
- package/src/app/components/architecture/Tree.tsx +205 -0
- package/src/app/components/architecture/rows.ts +181 -0
- package/src/app/components/architecture/selection.ts +6 -0
- package/src/app/components/campaigns/CampaignBrowser.tsx +146 -0
- package/src/app/components/campaigns/Detail.tsx +653 -0
- package/src/app/components/campaigns/Ladder.tsx +326 -0
- package/src/app/components/campaigns/pick.ts +7 -0
- package/src/app/components/lib/data.ts +132 -0
- package/src/app/pages/index.astro +34 -0
- package/src/app/public/favicon.svg +1 -0
- package/src/app/styles/theme.css +993 -0
- package/src/index.ts +37 -0
- package/src/main.ts +155 -0
- package/src/model/atlas.ts +751 -0
- package/src/model/campaigns.ts +376 -0
- package/src/server/collect.ts +132 -0
- package/src/server/compose.ts +79 -0
- package/src/server/export.ts +24 -0
- package/src/server/handler.ts +77 -0
- package/src/server/serve.ts +100 -0
- package/src/server/site.ts +6 -0
- package/src/server/source.ts +89 -0
- package/src/server/vite-plugin.ts +32 -0
- package/src/server/watch.ts +65 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Attestation,
|
|
3
|
+
CampaignEvaluation,
|
|
4
|
+
CampaignReport,
|
|
5
|
+
Note,
|
|
6
|
+
Nudge,
|
|
7
|
+
} from "@goodbones/campaigns";
|
|
8
|
+
import {
|
|
9
|
+
campaignsOf,
|
|
10
|
+
isDefinedPhase,
|
|
11
|
+
ledgerKeyOf,
|
|
12
|
+
LEGACY_SECTOR,
|
|
13
|
+
snapshotCampaignsOf,
|
|
14
|
+
} from "@goodbones/campaigns";
|
|
15
|
+
import type { LoadedPolicy, ManifestLocator, ManifestPath } from "@goodbones/core";
|
|
16
|
+
|
|
17
|
+
import type { AtlasPosition } from "./atlas.js";
|
|
18
|
+
|
|
19
|
+
// The Campaign Browser's data: every campaign the policy declares, as the
|
|
20
|
+
// ladder the docs draw — phases in order, each recognized by its objectives;
|
|
21
|
+
// sectors standing at the first phase with residue for them; the end state
|
|
22
|
+
// last — with everything `architecture campaigns` and `check` say about
|
|
23
|
+
// each: the ledgers' counts, the holdouts by sector, what is new and what is
|
|
24
|
+
// stale, attestations, notes, stalls, and the nudge for the working tree.
|
|
25
|
+
|
|
26
|
+
export type ObjectiveSectorCard = {
|
|
27
|
+
readonly sector: string;
|
|
28
|
+
// Holdouts firing now, in window.
|
|
29
|
+
readonly count: number;
|
|
30
|
+
// The ledger's entries for this sector, relative to its root.
|
|
31
|
+
readonly holdouts: ReadonlyArray<string>;
|
|
32
|
+
// Firing and not in the ledger: unrecorded growth.
|
|
33
|
+
readonly new: ReadonlyArray<string>;
|
|
34
|
+
// In the ledger and no longer firing: cleared, waiting for `clear`.
|
|
35
|
+
readonly stale: ReadonlyArray<string>;
|
|
36
|
+
readonly drifted: number;
|
|
37
|
+
readonly unrecorded: boolean;
|
|
38
|
+
readonly initial: number | null;
|
|
39
|
+
readonly cleared: number | null;
|
|
40
|
+
readonly closed: number | null;
|
|
41
|
+
readonly lastCleared: string | null;
|
|
42
|
+
readonly measure: {
|
|
43
|
+
readonly value: number | null;
|
|
44
|
+
readonly recorded: number | null;
|
|
45
|
+
readonly standing: "within" | "breached" | "surpassed" | "unrecorded" | "unmeasured";
|
|
46
|
+
} | null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type ObjectiveCard = {
|
|
50
|
+
readonly id: string;
|
|
51
|
+
readonly message: string;
|
|
52
|
+
readonly why: string | null;
|
|
53
|
+
// `match`, `file`, `declaration`, `sector` — or `measure` for a scalar.
|
|
54
|
+
readonly holdout: string;
|
|
55
|
+
readonly phase: string | null;
|
|
56
|
+
readonly position: AtlasPosition | null;
|
|
57
|
+
readonly count: number;
|
|
58
|
+
readonly initial: number;
|
|
59
|
+
readonly allowed: number;
|
|
60
|
+
readonly cleared: number;
|
|
61
|
+
readonly closed: number;
|
|
62
|
+
readonly progress: number;
|
|
63
|
+
readonly lastCleared: string | null;
|
|
64
|
+
readonly concessions: number;
|
|
65
|
+
readonly complete: boolean;
|
|
66
|
+
readonly ledgered: boolean;
|
|
67
|
+
readonly measure: {
|
|
68
|
+
readonly direction: "down" | "up";
|
|
69
|
+
readonly value: number | null;
|
|
70
|
+
readonly recorded: number | null;
|
|
71
|
+
readonly target: number | null;
|
|
72
|
+
readonly tolerance: number;
|
|
73
|
+
} | null;
|
|
74
|
+
readonly sectors: ReadonlyArray<ObjectiveSectorCard>;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type PhaseCard = {
|
|
78
|
+
readonly id: string;
|
|
79
|
+
readonly index: number;
|
|
80
|
+
readonly defined: boolean;
|
|
81
|
+
readonly intent: string | null;
|
|
82
|
+
readonly attested: boolean;
|
|
83
|
+
readonly objectives: ReadonlyArray<string>;
|
|
84
|
+
// Sectors derived to stand here.
|
|
85
|
+
readonly sectors: number;
|
|
86
|
+
readonly concessions: number;
|
|
87
|
+
readonly position: AtlasPosition | null;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export type HitCard = {
|
|
91
|
+
readonly objective: string;
|
|
92
|
+
readonly file: string;
|
|
93
|
+
readonly subject: string | null;
|
|
94
|
+
readonly line: number | null;
|
|
95
|
+
readonly entry: string;
|
|
96
|
+
readonly message: string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export type SectorCard = {
|
|
100
|
+
readonly name: string;
|
|
101
|
+
readonly legacy: boolean;
|
|
102
|
+
// Index into `phases`; `phases.length` when every phase is done. For a
|
|
103
|
+
// campaign with no phases, `0` and `done` says whether residue is nil.
|
|
104
|
+
readonly phase: number;
|
|
105
|
+
readonly phaseId: string | null;
|
|
106
|
+
readonly done: boolean;
|
|
107
|
+
readonly reached: string | null;
|
|
108
|
+
readonly since: string | null;
|
|
109
|
+
readonly roots: ReadonlyArray<string>;
|
|
110
|
+
readonly marker: string | null;
|
|
111
|
+
readonly files: ReadonlyArray<string>;
|
|
112
|
+
// Holdouts per objective in window, and the raw counts for every objective.
|
|
113
|
+
readonly residue: Readonly<Record<string, number>>;
|
|
114
|
+
readonly counts: Readonly<Record<string, number>>;
|
|
115
|
+
readonly values: Readonly<Record<string, number>>;
|
|
116
|
+
readonly stalled: boolean;
|
|
117
|
+
readonly attested: ReadonlyArray<Attestation>;
|
|
118
|
+
readonly notes: ReadonlyArray<Note>;
|
|
119
|
+
readonly hits: ReadonlyArray<HitCard>;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export type CampaignCard = {
|
|
123
|
+
readonly id: string;
|
|
124
|
+
readonly title: string | null;
|
|
125
|
+
readonly why: string | null;
|
|
126
|
+
readonly how: string | null;
|
|
127
|
+
readonly owner: string | null;
|
|
128
|
+
readonly scope: ReadonlyArray<string>;
|
|
129
|
+
readonly perimeter: string | null;
|
|
130
|
+
readonly staleAfterDays: number | null;
|
|
131
|
+
readonly onComplete: "keep" | "remove";
|
|
132
|
+
readonly position: AtlasPosition | null;
|
|
133
|
+
readonly count: number;
|
|
134
|
+
readonly progress: number;
|
|
135
|
+
readonly stalled: boolean;
|
|
136
|
+
readonly complete: boolean;
|
|
137
|
+
readonly ledgered: boolean;
|
|
138
|
+
readonly missingLedger: boolean;
|
|
139
|
+
readonly arithmetic: boolean;
|
|
140
|
+
readonly phases: ReadonlyArray<PhaseCard>;
|
|
141
|
+
readonly objectives: ReadonlyArray<ObjectiveCard>;
|
|
142
|
+
readonly sectors: ReadonlyArray<SectorCard>;
|
|
143
|
+
readonly legacy: { readonly files: number; readonly holdouts: number };
|
|
144
|
+
readonly drift: ReadonlyArray<{ readonly file: string; readonly sectors: ReadonlyArray<string> }>;
|
|
145
|
+
readonly plan: {
|
|
146
|
+
readonly refined: ReadonlyArray<string>;
|
|
147
|
+
readonly changed: ReadonlyArray<string>;
|
|
148
|
+
readonly unreceipted: ReadonlyArray<string>;
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export type CampaignView = {
|
|
153
|
+
readonly version: 1;
|
|
154
|
+
readonly name: string;
|
|
155
|
+
readonly generatedAt: string;
|
|
156
|
+
readonly ledgerDir: string;
|
|
157
|
+
readonly campaigns: ReadonlyArray<CampaignCard>;
|
|
158
|
+
// `campaigns status --changed` over the working tree, when git answered.
|
|
159
|
+
readonly nudge: Nudge | null;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export type CampaignViewInput = {
|
|
163
|
+
readonly policy: LoadedPolicy;
|
|
164
|
+
readonly name: string;
|
|
165
|
+
readonly evaluations: ReadonlyArray<CampaignEvaluation>;
|
|
166
|
+
readonly reports: ReadonlyArray<CampaignReport>;
|
|
167
|
+
// The manifest as read, for the fields lowering does not keep (`how`).
|
|
168
|
+
readonly manifest: unknown;
|
|
169
|
+
readonly locate: ManifestLocator | undefined;
|
|
170
|
+
readonly manifestPath: string;
|
|
171
|
+
readonly nudge: Nudge | null;
|
|
172
|
+
readonly now: number;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const dirnameOf = (file: string): string => {
|
|
176
|
+
const at = file.lastIndexOf("/");
|
|
177
|
+
return at === -1 ? "" : file.slice(0, at);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const positionAt = (
|
|
181
|
+
locate: ManifestLocator | undefined,
|
|
182
|
+
manifestPath: string,
|
|
183
|
+
path: ManifestPath,
|
|
184
|
+
): AtlasPosition | null => {
|
|
185
|
+
const found = locate?.(path);
|
|
186
|
+
if (found === null || found === undefined) return null;
|
|
187
|
+
const directory = dirnameOf(manifestPath);
|
|
188
|
+
const file =
|
|
189
|
+
found.file === undefined
|
|
190
|
+
? manifestPath
|
|
191
|
+
: directory === ""
|
|
192
|
+
? found.file
|
|
193
|
+
: `${directory}/${found.file}`;
|
|
194
|
+
return { file, line: found.line, column: found.column };
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
198
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
199
|
+
|
|
200
|
+
const stringAt = (value: unknown, ...keys: ReadonlyArray<string>): string | null => {
|
|
201
|
+
let cursor: unknown = value;
|
|
202
|
+
for (const key of keys) {
|
|
203
|
+
if (!isRecord(cursor)) return null;
|
|
204
|
+
cursor = cursor[key];
|
|
205
|
+
}
|
|
206
|
+
return typeof cursor === "string" ? cursor : null;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
210
|
+
|
|
211
|
+
export const campaignViewOf = (input: CampaignViewInput): CampaignView => {
|
|
212
|
+
const { policy } = input;
|
|
213
|
+
const state = campaignsOf(policy);
|
|
214
|
+
const snapshots = snapshotCampaignsOf(policy, input.evaluations);
|
|
215
|
+
const position = (path: ManifestPath) => positionAt(input.locate, input.manifestPath, path);
|
|
216
|
+
|
|
217
|
+
const campaigns = input.evaluations.map((evaluation, index): CampaignCard => {
|
|
218
|
+
const { rule } = evaluation;
|
|
219
|
+
const report = input.reports[index];
|
|
220
|
+
const snapshot = snapshots[index];
|
|
221
|
+
if (report === undefined || snapshot === undefined) {
|
|
222
|
+
throw new Error(`campaign ${rule.id} was evaluated without a report`);
|
|
223
|
+
}
|
|
224
|
+
const scalar = new Set(
|
|
225
|
+
rule.objectives.filter((one) => one.measure !== null).map((one) => one.id),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const objectives = rule.objectives.map((objective, j): ObjectiveCard => {
|
|
229
|
+
const own = report.objectives[j];
|
|
230
|
+
const summary = snapshot.objectives[j];
|
|
231
|
+
const ledger = state.ledgers.get(ledgerKeyOf(rule.id, objective.id));
|
|
232
|
+
const sectors = (own?.sectors ?? []).map((sector): ObjectiveSectorCard => {
|
|
233
|
+
const held = ledger?.sectors[sector.sector];
|
|
234
|
+
return {
|
|
235
|
+
sector: sector.sector,
|
|
236
|
+
count: sector.count,
|
|
237
|
+
holdouts: held?.holdouts ?? [],
|
|
238
|
+
new: sector.new,
|
|
239
|
+
stale: sector.stale,
|
|
240
|
+
drifted: sector.drifted,
|
|
241
|
+
unrecorded: sector.unrecorded,
|
|
242
|
+
initial: held?.initial ?? null,
|
|
243
|
+
cleared: held?.cleared ?? null,
|
|
244
|
+
closed: held?.closed ?? null,
|
|
245
|
+
lastCleared: held?.lastCleared ?? null,
|
|
246
|
+
measure: sector.measure ?? null,
|
|
247
|
+
};
|
|
248
|
+
});
|
|
249
|
+
return {
|
|
250
|
+
id: objective.id,
|
|
251
|
+
message: objective.message,
|
|
252
|
+
why: objective.why,
|
|
253
|
+
holdout: objective.measure !== null ? "measure" : (objective.holdout ?? "match"),
|
|
254
|
+
phase: summary?.phase ?? null,
|
|
255
|
+
position: position(["campaigns", rule.id, "objectives", objective.id]),
|
|
256
|
+
count: summary?.count ?? own?.count ?? 0,
|
|
257
|
+
initial: summary?.initial ?? 0,
|
|
258
|
+
allowed: summary?.allowed ?? 0,
|
|
259
|
+
cleared: summary?.cleared ?? 0,
|
|
260
|
+
closed: summary?.closed ?? 0,
|
|
261
|
+
progress: summary?.progress ?? 0,
|
|
262
|
+
lastCleared: summary?.lastCleared ?? null,
|
|
263
|
+
concessions: summary?.concessions ?? 0,
|
|
264
|
+
complete: summary?.complete ?? false,
|
|
265
|
+
ledgered: summary?.ledgered ?? false,
|
|
266
|
+
measure:
|
|
267
|
+
own?.measure === undefined
|
|
268
|
+
? null
|
|
269
|
+
: {
|
|
270
|
+
direction: own.measure.direction,
|
|
271
|
+
value: own.measure.value,
|
|
272
|
+
recorded: own.measure.recorded,
|
|
273
|
+
target: own.measure.target,
|
|
274
|
+
tolerance: own.measure.tolerance,
|
|
275
|
+
},
|
|
276
|
+
sectors,
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const phases = rule.phases.map((phase, i): PhaseCard => ({
|
|
281
|
+
id: phase.id,
|
|
282
|
+
index: i,
|
|
283
|
+
defined: isDefinedPhase(phase),
|
|
284
|
+
intent: phase.intent ?? null,
|
|
285
|
+
attested: phase.attested,
|
|
286
|
+
objectives: [...phase.objectives],
|
|
287
|
+
sectors: snapshot.phases[i]?.sectors ?? 0,
|
|
288
|
+
concessions: phase.concessions.length,
|
|
289
|
+
position: position(["campaigns", rule.id, "phases", i]),
|
|
290
|
+
}));
|
|
291
|
+
|
|
292
|
+
const hitsOf = (sector: string): ReadonlyArray<HitCard> =>
|
|
293
|
+
evaluation.hits
|
|
294
|
+
.filter((hit) => hit.sector === sector)
|
|
295
|
+
.map((hit) => ({
|
|
296
|
+
objective: hit.objective,
|
|
297
|
+
file: hit.violation.file,
|
|
298
|
+
subject: hit.violation.subject,
|
|
299
|
+
line: hit.range === undefined ? null : hit.range.start.line + 1,
|
|
300
|
+
entry: hit.entry,
|
|
301
|
+
message: hit.violation.message,
|
|
302
|
+
}))
|
|
303
|
+
.sort((a, b) => {
|
|
304
|
+
const byFile = a.file.localeCompare(b.file);
|
|
305
|
+
return byFile !== 0 ? byFile : a.entry.localeCompare(b.entry);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const sectors = [...evaluation.sectors.values()].map((sectorState): SectorCard => {
|
|
309
|
+
const record = state.sectorRecords.get(ledgerKeyOf(rule.id, sectorState.name));
|
|
310
|
+
const summary = snapshot.sectors.find((one) => one.name === sectorState.name);
|
|
311
|
+
const residueTotal = Object.entries(sectorState.residue)
|
|
312
|
+
.filter(([id]) => !scalar.has(id))
|
|
313
|
+
.reduce((sum, [, n]) => sum + n, 0);
|
|
314
|
+
const scalarOpen = Object.entries(sectorState.counts).some(
|
|
315
|
+
([id, n]) => scalar.has(id) && n > 0,
|
|
316
|
+
);
|
|
317
|
+
return {
|
|
318
|
+
name: sectorState.name,
|
|
319
|
+
legacy: sectorState.name === LEGACY_SECTOR,
|
|
320
|
+
phase: sectorState.phase,
|
|
321
|
+
phaseId: rule.phases[sectorState.phase]?.id ?? null,
|
|
322
|
+
done:
|
|
323
|
+
rule.phases.length === 0
|
|
324
|
+
? residueTotal === 0 && !scalarOpen
|
|
325
|
+
: sectorState.phase >= rule.phases.length,
|
|
326
|
+
reached: record?.reached ?? null,
|
|
327
|
+
since: record?.since ?? null,
|
|
328
|
+
roots: sectorState.sector.roots,
|
|
329
|
+
marker: sectorState.sector.marker,
|
|
330
|
+
files: [...sectorState.sector.files].sort(),
|
|
331
|
+
residue: sectorState.residue,
|
|
332
|
+
counts: sectorState.counts,
|
|
333
|
+
values: sectorState.values,
|
|
334
|
+
stalled: summary?.stalled ?? false,
|
|
335
|
+
attested: record?.attested ?? [],
|
|
336
|
+
notes: record?.notes ?? [],
|
|
337
|
+
hits: hitsOf(sectorState.name),
|
|
338
|
+
};
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
id: rule.id,
|
|
343
|
+
title: rule.title,
|
|
344
|
+
why: rule.why,
|
|
345
|
+
how: stringAt(input.manifest, "campaigns", rule.id, "how"),
|
|
346
|
+
owner: rule.owner,
|
|
347
|
+
scope: rule.scope.map((one) => one.source),
|
|
348
|
+
perimeter: rule.perimeter?.kind ?? null,
|
|
349
|
+
staleAfterDays: rule.staleAfter === null ? null : Math.round(rule.staleAfter / DAY),
|
|
350
|
+
onComplete: rule.onComplete,
|
|
351
|
+
position: position(["campaigns", rule.id]),
|
|
352
|
+
count: snapshot.count,
|
|
353
|
+
progress: snapshot.progress,
|
|
354
|
+
stalled: report.stalled,
|
|
355
|
+
complete: report.complete,
|
|
356
|
+
ledgered: snapshot.ledgered,
|
|
357
|
+
missingLedger: report.missingLedger,
|
|
358
|
+
arithmetic: report.arithmetic,
|
|
359
|
+
phases,
|
|
360
|
+
objectives,
|
|
361
|
+
sectors,
|
|
362
|
+
legacy: snapshot.legacy,
|
|
363
|
+
drift: report.drift,
|
|
364
|
+
plan: report.plan,
|
|
365
|
+
};
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
version: 1,
|
|
370
|
+
name: input.name,
|
|
371
|
+
generatedAt: new Date(input.now).toISOString(),
|
|
372
|
+
ledgerDir: state.ledgerDir,
|
|
373
|
+
campaigns,
|
|
374
|
+
nudge: input.nudge,
|
|
375
|
+
};
|
|
376
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
campaignReportsOf,
|
|
6
|
+
campaignsOf,
|
|
7
|
+
evaluateCampaigns,
|
|
8
|
+
type Nudge,
|
|
9
|
+
nudgeOf,
|
|
10
|
+
readDiff,
|
|
11
|
+
reportSpecsOf,
|
|
12
|
+
widenedExtensions,
|
|
13
|
+
} from "@goodbones/campaigns";
|
|
14
|
+
import { listSourceFiles, type SourceFacts } from "@goodbones/core";
|
|
15
|
+
|
|
16
|
+
import { type Atlas, buildAtlas } from "../model/atlas.js";
|
|
17
|
+
import { type CampaignView, campaignViewOf } from "../model/campaigns.js";
|
|
18
|
+
import { type LoadedManifest, loadPolicyFromFile } from "./compose.js";
|
|
19
|
+
|
|
20
|
+
// One pass over the repository for both browsers: the policy loaded, every
|
|
21
|
+
// file read and parsed once, the atlas built from the architecture families
|
|
22
|
+
// and the campaign view from the campaigns family. What the CLI's `check`
|
|
23
|
+
// does, minus the printing, plus the positions the browsers link through.
|
|
24
|
+
|
|
25
|
+
export type Collected = {
|
|
26
|
+
readonly atlas: Atlas;
|
|
27
|
+
readonly campaigns: CampaignView;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type CollectOptions = {
|
|
31
|
+
readonly repoRoot: string;
|
|
32
|
+
readonly roots: ReadonlyArray<string>;
|
|
33
|
+
readonly configFilename?: string | undefined;
|
|
34
|
+
// Whether to run git for the working-tree nudge. Off for a static export.
|
|
35
|
+
readonly nudge?: boolean | undefined;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const toPosix = (file: string): string => file.split(path.sep).join("/");
|
|
39
|
+
|
|
40
|
+
export const collect = async (options: CollectOptions): Promise<Collected> => {
|
|
41
|
+
const loaded = await loadPolicyFromFile(options.repoRoot, options.configFilename);
|
|
42
|
+
return collectWith(loaded, options);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const collectWith = async (
|
|
46
|
+
loaded: LoadedManifest,
|
|
47
|
+
options: CollectOptions,
|
|
48
|
+
): Promise<Collected> => {
|
|
49
|
+
const { policy } = loaded;
|
|
50
|
+
const name = path.basename(options.repoRoot);
|
|
51
|
+
const manifestPath = toPosix(path.relative(options.repoRoot, loaded.configPath));
|
|
52
|
+
|
|
53
|
+
// Every `report` a campaign names is read now, before any file asks.
|
|
54
|
+
await Promise.all(
|
|
55
|
+
reportSpecsOf(campaignsOf(policy).campaignRules).map((spec) =>
|
|
56
|
+
campaignsOf(policy).reports.read?.(spec),
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const walked = listSourceFiles(
|
|
61
|
+
options.repoRoot,
|
|
62
|
+
options.roots,
|
|
63
|
+
policy.languages,
|
|
64
|
+
widenedExtensions(policy),
|
|
65
|
+
);
|
|
66
|
+
const known = new Set(policy.languages.flatMap((one) => one.extensions));
|
|
67
|
+
const files = walked.filter((file) => known.has(path.extname(file)));
|
|
68
|
+
|
|
69
|
+
const texts = new Map<string, string>();
|
|
70
|
+
const textOf = (file: string): string => {
|
|
71
|
+
const cached = texts.get(file);
|
|
72
|
+
if (cached !== undefined) return cached;
|
|
73
|
+
const text = readFileSync(path.join(options.repoRoot, file), "utf8");
|
|
74
|
+
texts.set(file, text);
|
|
75
|
+
return text;
|
|
76
|
+
};
|
|
77
|
+
const parsed = new Map<string, SourceFacts>();
|
|
78
|
+
const factsOf = (file: string): SourceFacts => {
|
|
79
|
+
const cached = parsed.get(file);
|
|
80
|
+
if (cached !== undefined) return cached;
|
|
81
|
+
const facts = policy.extractor.factsOf(file, textOf(file));
|
|
82
|
+
parsed.set(file, facts);
|
|
83
|
+
return facts;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const atlas = buildAtlas({
|
|
87
|
+
policy,
|
|
88
|
+
name,
|
|
89
|
+
roots: options.roots,
|
|
90
|
+
files,
|
|
91
|
+
factsOf,
|
|
92
|
+
locate: loaded.locate,
|
|
93
|
+
manifestPath,
|
|
94
|
+
manifestFiles: loaded.files.map((file) => ({
|
|
95
|
+
path: toPosix(path.relative(options.repoRoot, file)),
|
|
96
|
+
text: readFileSync(file, "utf8"),
|
|
97
|
+
})),
|
|
98
|
+
now: policy.now,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const evaluations = evaluateCampaigns(policy, options.roots, walked, { textOf, factsOf });
|
|
102
|
+
const reports = campaignReportsOf(policy, evaluations);
|
|
103
|
+
const nudge = options.nudge === false ? null : nudgeFor(loaded, evaluations);
|
|
104
|
+
const campaigns = campaignViewOf({
|
|
105
|
+
policy,
|
|
106
|
+
name,
|
|
107
|
+
evaluations,
|
|
108
|
+
reports,
|
|
109
|
+
manifest: loaded.manifest,
|
|
110
|
+
locate: loaded.locate,
|
|
111
|
+
manifestPath,
|
|
112
|
+
nudge,
|
|
113
|
+
now: policy.now,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return { atlas, campaigns };
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// The nudge for the working tree against HEAD, or nothing where git does
|
|
120
|
+
// not answer — a repository with no commits, or none at all.
|
|
121
|
+
const nudgeFor = (
|
|
122
|
+
loaded: LoadedManifest,
|
|
123
|
+
evaluations: Parameters<typeof nudgeOf>[1],
|
|
124
|
+
): Nudge | null => {
|
|
125
|
+
if (campaignsOf(loaded.policy).campaignRules.length === 0) return null;
|
|
126
|
+
try {
|
|
127
|
+
const diff = readDiff(loaded.policy.repoRoot, null);
|
|
128
|
+
return nudgeOf(loaded.policy, evaluations, diff, null, null, null);
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { astGrepMatcher } from "@goodbones/ast-grep";
|
|
4
|
+
import {
|
|
5
|
+
campaignsExtension,
|
|
6
|
+
loadCampaignFunctions,
|
|
7
|
+
makeReportSourceLive,
|
|
8
|
+
} from "@goodbones/campaigns";
|
|
9
|
+
import {
|
|
10
|
+
findManifestFile,
|
|
11
|
+
type Language,
|
|
12
|
+
type LoadedPolicy,
|
|
13
|
+
loadPolicy,
|
|
14
|
+
makeFileSystemLive,
|
|
15
|
+
type ManifestLocator,
|
|
16
|
+
readManifestFile,
|
|
17
|
+
} from "@goodbones/core";
|
|
18
|
+
import { typescriptLanguage } from "@goodbones/typescript";
|
|
19
|
+
import * as Result from "effect/Result";
|
|
20
|
+
|
|
21
|
+
// This host's composition root: the same three lines the CLI and the plugin
|
|
22
|
+
// each have, on purpose — the three hosts share the core and never each
|
|
23
|
+
// other. Nothing below this file names TypeScript or ast-grep.
|
|
24
|
+
|
|
25
|
+
export const hostLanguages = (): ReadonlyArray<Language> => [
|
|
26
|
+
typescriptLanguage({ syntax: astGrepMatcher() }),
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export const hostNow = (): number => {
|
|
30
|
+
const pinned = process.env.ARCHITECTURE_NOW;
|
|
31
|
+
if (pinned === undefined || pinned === "") return Date.now();
|
|
32
|
+
const parsed = /^\d+$/.test(pinned) ? Number(pinned) : Date.parse(pinned);
|
|
33
|
+
if (Number.isNaN(parsed)) {
|
|
34
|
+
throw new Error(`ARCHITECTURE_NOW is ${JSON.stringify(pinned)}, which is not a date.`);
|
|
35
|
+
}
|
|
36
|
+
return parsed;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const manifestPathOf = (repoRoot: string, configFilename?: string): string =>
|
|
40
|
+
configFilename === undefined
|
|
41
|
+
? findManifestFile(repoRoot)
|
|
42
|
+
: path.resolve(repoRoot, configFilename);
|
|
43
|
+
|
|
44
|
+
export type LoadedManifest = {
|
|
45
|
+
readonly policy: LoadedPolicy;
|
|
46
|
+
// The manifest as read, before decoding.
|
|
47
|
+
readonly manifest: unknown;
|
|
48
|
+
readonly locate: ManifestLocator | undefined;
|
|
49
|
+
// Absolute; the root manifest first, then every file `include` pulled in.
|
|
50
|
+
readonly configPath: string;
|
|
51
|
+
readonly files: ReadonlyArray<string>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const loadPolicyFromFile = async (
|
|
55
|
+
repoRoot: string,
|
|
56
|
+
configFilename?: string,
|
|
57
|
+
): Promise<LoadedManifest> => {
|
|
58
|
+
const configPath = manifestPathOf(repoRoot, configFilename);
|
|
59
|
+
const read = await readManifestFile(configPath);
|
|
60
|
+
const { functions, manifest } = await loadCampaignFunctions(configPath, read.manifest);
|
|
61
|
+
const loaded = loadPolicy({
|
|
62
|
+
repoRoot,
|
|
63
|
+
configPath,
|
|
64
|
+
manifest,
|
|
65
|
+
locate: read.locate,
|
|
66
|
+
languages: hostLanguages(),
|
|
67
|
+
fileSystem: makeFileSystemLive(repoRoot),
|
|
68
|
+
extensions: [campaignsExtension({ functions, reports: makeReportSourceLive(repoRoot) })],
|
|
69
|
+
now: hostNow(),
|
|
70
|
+
});
|
|
71
|
+
if (Result.isFailure(loaded)) throw loaded.failure;
|
|
72
|
+
return {
|
|
73
|
+
policy: loaded.success,
|
|
74
|
+
manifest: read.manifest,
|
|
75
|
+
locate: read.locate,
|
|
76
|
+
configPath,
|
|
77
|
+
files: read.files,
|
|
78
|
+
};
|
|
79
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { cpSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { Collected } from "./collect.js";
|
|
5
|
+
|
|
6
|
+
// A static export: the page as built, with the two models written where the
|
|
7
|
+
// page fetches them. Serve the folder from the root of a host; the page
|
|
8
|
+
// finds no feed there and says it is a snapshot.
|
|
9
|
+
export const exportSite = (siteDir: string, out: string, collected: Collected): void => {
|
|
10
|
+
mkdirSync(out, { recursive: true });
|
|
11
|
+
cpSync(siteDir, out, { recursive: true });
|
|
12
|
+
const data = path.join(out, "__goodbones");
|
|
13
|
+
mkdirSync(data, { recursive: true });
|
|
14
|
+
writeFileSync(path.join(data, "atlas.json"), JSON.stringify(collected.atlas));
|
|
15
|
+
writeFileSync(path.join(data, "campaigns.json"), JSON.stringify(collected.campaigns));
|
|
16
|
+
writeFileSync(
|
|
17
|
+
path.join(data, "status.json"),
|
|
18
|
+
JSON.stringify({
|
|
19
|
+
name: collected.atlas.name,
|
|
20
|
+
generatedAt: collected.atlas.generatedAt,
|
|
21
|
+
live: false,
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
|
|
3
|
+
import type { Source } from "./source.js";
|
|
4
|
+
|
|
5
|
+
// The routes the page reads, mounted under `/__goodbones/` by the bin's
|
|
6
|
+
// server and by the Vite plugin alike, so the page is the same in both:
|
|
7
|
+
//
|
|
8
|
+
// atlas.json the Architecture Browser's model
|
|
9
|
+
// campaigns.json the Campaign Browser's model
|
|
10
|
+
// status.json the repository's name and whether the feed is live
|
|
11
|
+
// events server-sent events: `changed` when the repository does
|
|
12
|
+
//
|
|
13
|
+
// A static export writes the first three as files and has no feed.
|
|
14
|
+
|
|
15
|
+
export const PREFIX = "/__goodbones/";
|
|
16
|
+
|
|
17
|
+
export type Handler = (request: IncomingMessage, response: ServerResponse) => Promise<boolean>;
|
|
18
|
+
|
|
19
|
+
const HEARTBEAT_MS = 25_000;
|
|
20
|
+
|
|
21
|
+
const json = (response: ServerResponse, status: number, body: unknown): void => {
|
|
22
|
+
response.statusCode = status;
|
|
23
|
+
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
24
|
+
response.setHeader("cache-control", "no-store");
|
|
25
|
+
response.end(JSON.stringify(body));
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const messageOf = (cause: unknown): string =>
|
|
29
|
+
typeof cause === "object" && cause !== null && "message" in cause
|
|
30
|
+
? String(cause.message)
|
|
31
|
+
: String(cause);
|
|
32
|
+
|
|
33
|
+
export const makeHandler = (source: Source): Handler => {
|
|
34
|
+
return async (request, response) => {
|
|
35
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
36
|
+
if (!url.pathname.startsWith(PREFIX)) return false;
|
|
37
|
+
const route = url.pathname.slice(PREFIX.length);
|
|
38
|
+
|
|
39
|
+
if (route === "events") {
|
|
40
|
+
response.statusCode = 200;
|
|
41
|
+
response.setHeader("content-type", "text/event-stream");
|
|
42
|
+
response.setHeader("cache-control", "no-store");
|
|
43
|
+
response.setHeader("connection", "keep-alive");
|
|
44
|
+
response.write(`event: hello\ndata: ${JSON.stringify({ live: source.live })}\n\n`);
|
|
45
|
+
const unsubscribe = source.subscribe((change) => {
|
|
46
|
+
response.write(`event: changed\ndata: ${JSON.stringify(change)}\n\n`);
|
|
47
|
+
});
|
|
48
|
+
const heartbeat = setInterval(() => response.write(": keep-alive\n\n"), HEARTBEAT_MS);
|
|
49
|
+
request.on("close", () => {
|
|
50
|
+
clearInterval(heartbeat);
|
|
51
|
+
unsubscribe();
|
|
52
|
+
});
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (route === "atlas.json" || route === "campaigns.json" || route === "status.json") {
|
|
57
|
+
try {
|
|
58
|
+
const collected = await source.current();
|
|
59
|
+
if (route === "atlas.json") json(response, 200, collected.atlas);
|
|
60
|
+
else if (route === "campaigns.json") json(response, 200, collected.campaigns);
|
|
61
|
+
else {
|
|
62
|
+
json(response, 200, {
|
|
63
|
+
name: collected.atlas.name,
|
|
64
|
+
generatedAt: collected.atlas.generatedAt,
|
|
65
|
+
live: source.live,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
} catch (cause) {
|
|
69
|
+
json(response, 500, { error: messageOf(cause) });
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
json(response, 404, { error: `no such route: ${url.pathname}` });
|
|
75
|
+
return true;
|
|
76
|
+
};
|
|
77
|
+
};
|