yarramate 1.5.0 → 1.7.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 (49) hide show
  1. package/dist/adapters/visual/session-server.js +27 -1
  2. package/dist/adapters/visual/workspace-model.d.ts +16 -3
  3. package/dist/adapters/visual/workspace-model.js +12 -5
  4. package/dist/ask-command.js +25 -16
  5. package/dist/catalogue-sources.d.ts +20 -0
  6. package/dist/catalogue-sources.js +39 -0
  7. package/dist/check-command.js +38 -9
  8. package/dist/cli-support.d.ts +8 -1
  9. package/dist/cli-support.js +5 -1
  10. package/dist/cli.d.ts +10 -0
  11. package/dist/cli.js +17 -1
  12. package/dist/design-command.js +21 -9
  13. package/dist/export-command.js +41 -5
  14. package/dist/import-command.d.ts +16 -0
  15. package/dist/import-command.js +156 -0
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/interrogate-command.d.ts +82 -1
  19. package/dist/interrogate-command.js +195 -29
  20. package/dist/interrogation-entry.d.ts +1 -1
  21. package/dist/interrogation-entry.js +1 -1
  22. package/dist/projection.d.ts +47 -0
  23. package/dist/projection.js +126 -0
  24. package/dist/visual-app-lib/editor.js +26771 -26713
  25. package/dist/visual-app-lib/types/adapters/visual/workspace-model.d.ts +16 -3
  26. package/dist/visual-app-lib/types/interrogate-command.d.ts +82 -1
  27. package/dist/visual-app-lib/types/projection.d.ts +47 -0
  28. package/dist/visual-app-lib/types/workspace.d.ts +15 -0
  29. package/dist/workbook-entry.d.ts +16 -0
  30. package/dist/workbook-entry.js +16 -0
  31. package/dist/workbook-import-entry.d.ts +30 -0
  32. package/dist/workbook-import-entry.js +30 -0
  33. package/dist/workbook-merge.d.ts +77 -0
  34. package/dist/workbook-merge.js +108 -0
  35. package/dist/workbook-operations.d.ts +9 -0
  36. package/dist/workbook-operations.js +139 -0
  37. package/dist/workbook-read.d.ts +43 -0
  38. package/dist/workbook-read.js +314 -0
  39. package/dist/workbook-xlsx.d.ts +54 -0
  40. package/dist/workbook-xlsx.js +228 -0
  41. package/dist/workbook.d.ts +32 -0
  42. package/dist/workbook.js +308 -0
  43. package/dist/workspace.d.ts +15 -0
  44. package/dist/workspace.js +1 -0
  45. package/docs/CONSUMING-YARRAMATE.md +127 -0
  46. package/package.json +9 -1
  47. package/schema/yarramate-interrogation-report.schema.json +9 -0
  48. package/schema/yarramate-question-catalogue.schema.json +2 -3
  49. package/schema/yarramate-workspace.schema.json +4 -0
@@ -57,10 +57,20 @@ export interface DismissedQuestion {
57
57
  export declare const interrogationOverlayOf: (compiled: {
58
58
  readonly graph: SemanticGraph;
59
59
  readonly profileContext: ResolvedProfileContext;
60
- }, catalogue: {
60
+ },
61
+ /**
62
+ * The catalogue, or the composed SET a workspace carries (#345, ADR 0129).
63
+ * A set rather than one document so the pane asks the same interview the
64
+ * CLI does over the same workspace: an editor showing fewer questions than
65
+ * `design` does over the same files is a disagreement with no symptom.
66
+ */
67
+ catalogue: {
61
68
  readonly path: string;
62
69
  readonly source: string;
63
- },
70
+ } | readonly {
71
+ readonly path: string;
72
+ readonly source: string;
73
+ }[],
64
74
  /**
65
75
  * What the host has already dealt with (#328). Evaluation is unchanged and
66
76
  * the model is untouched: this decides only what the pane draws, because a
@@ -83,7 +93,10 @@ export declare const renderedWorkspaceOf: (compiled: {
83
93
  }, views: readonly VisualViewSummary[], metadata: Omit<VisualRenderedModel, "graph" | "vocabulary" | "interrogation">, catalogue?: {
84
94
  readonly path: string;
85
95
  readonly source: string;
86
- }, dismissed?: readonly DismissedQuestion[]) => {
96
+ } | readonly {
97
+ readonly path: string;
98
+ readonly source: string;
99
+ }[], dismissed?: readonly DismissedQuestion[]) => {
87
100
  readonly model: VisualRenderedModel;
88
101
  readonly views: readonly VisualViewSummary[];
89
102
  };
@@ -188,14 +188,29 @@ export interface InterrogationSummary {
188
188
  export interface InterrogationReport {
189
189
  readonly format: 'yarramate/interrogation-report/v1';
190
190
  readonly workspace: string;
191
+ /** The BASE catalogue, `id@version`. Unchanged in shape by composition. */
191
192
  readonly catalogue: string;
193
+ /**
194
+ * Every contributing catalogue as `id@version`, base first, when more than
195
+ * one contributed (#345, ADR 0129). Optional so that adding it breaks no
196
+ * constructor, and `catalogue` keeps its value shape so it breaks no reader.
197
+ */
198
+ readonly catalogues?: readonly string[];
192
199
  /** {@link INTERROGATION_SEMANTICS_VERSION} at the time of evaluation. */
193
200
  readonly semantics: string;
194
201
  readonly summary: InterrogationSummary;
195
202
  readonly waves: readonly ReportWave[];
196
203
  }
197
204
  export declare const renderQuestion: (template: string, subjectId: string, subjectName: string | undefined, counterparts?: readonly string[]) => string;
198
- export declare function evaluateCatalogue(catalogue: QuestionCatalogue, graph: SemanticGraph, profileContext?: ResolvedProfileContext, evidence?: readonly CatalogueEvidenceObservation[]): Omit<InterrogationReport, 'workspace'>;
205
+ export declare function evaluateCatalogue(catalogue: QuestionCatalogue, graph: SemanticGraph, profileContext?: ResolvedProfileContext, evidence?: readonly CatalogueEvidenceObservation[],
206
+ /**
207
+ * Contributing catalogues, from {@link composeCatalogues}. Composition
208
+ * happens BEFORE evaluation and hands this function an ordinary catalogue,
209
+ * so the only thing evaluation learns about composition is what to name in
210
+ * the report. A fifth optional parameter rather than an options object,
211
+ * because this signature is published and a consumer already calls it.
212
+ */
213
+ catalogues?: readonly string[]): Omit<InterrogationReport, 'workspace'>;
199
214
  export type CatalogueLoadResult = {
200
215
  readonly ok: true;
201
216
  readonly catalogue: QuestionCatalogue;
@@ -203,5 +218,71 @@ export type CatalogueLoadResult = {
203
218
  readonly ok: false;
204
219
  readonly diagnostics: readonly Diagnostic[];
205
220
  };
221
+ /**
222
+ * A catalogue is qualified on the way OUT, never in what an author writes.
223
+ *
224
+ * The authored schema keeps ids local (`^[a-z][a-z0-9-]*$`) and that does not
225
+ * change; a consultant writes `regulator-signoff`, not
226
+ * `consulting#regulator-signoff`. The engine qualifies when it composes, which
227
+ * is the only moment two catalogues can be confused for each other.
228
+ *
229
+ * NO VERSION, and that is the decision rather than an omission (ADR 0129).
230
+ * `core-enrichment` went 1.0 to 1.3 in a single day renaming nothing; a
231
+ * versioned identity would have stranded every stored dismissal in every
232
+ * adopter's database three times that day, for changes that removed no
233
+ * question. Versioned identity is safe for things that are AUTHORED - a
234
+ * document keeps naming the version it was written against and an author
235
+ * updates it deliberately - and unsafe for things that are STORED, because a
236
+ * row in someone's database has no author to update it.
237
+ */
238
+ export declare const qualifiedQuestionId: (catalogueId: string, questionId: string) => string;
239
+ export interface ComposedCatalogue {
240
+ /**
241
+ * The composed catalogue, ready for `evaluateCatalogue`. Structurally a
242
+ * `QuestionCatalogue`, but its question ids are QUALIFIED, so it is a
243
+ * composition result rather than something an author could have written and
244
+ * must never be validated against the authored schema again.
245
+ */
246
+ readonly catalogue: QuestionCatalogue;
247
+ /** Every contributing catalogue as `id@version`, base first. */
248
+ readonly catalogues: readonly string[];
249
+ }
250
+ export type CatalogueCompositionResult = {
251
+ readonly ok: true;
252
+ readonly composed: ComposedCatalogue;
253
+ } | {
254
+ readonly ok: false;
255
+ readonly diagnostics: readonly Diagnostic[];
256
+ };
257
+ /**
258
+ * Compose a base catalogue with the ones a workspace carries (#345, ADR 0129).
259
+ *
260
+ * ADDITIVE. `--catalogue` and `MountOptions.catalogue` replace the base; this
261
+ * adds to it, which is what lets a consultant author a question at any point
262
+ * in an engagement with no product release.
263
+ *
264
+ * A WAVE IS DECLARED EXACTLY ONCE across the resolved set, and any catalogue
265
+ * may contribute questions to a wave it did not declare. That one rule settles
266
+ * three questions composition would otherwise raise. Wave identity: a project
267
+ * catalogue joins a declared wave rather than colliding with it. Ordering:
268
+ * only a declaration places a wave, so the base's order is untouched and new
269
+ * waves append. And the `opensWhen` precedence ADR 0125 made load-bearing does
270
+ * not arise at all, because there is only ever one declarer to ask.
271
+ *
272
+ * QUALIFICATION IS A PROPERTY OF COMPOSITION, NOT OF EVALUATION. A caller
273
+ * that hands `evaluateCatalogue` a catalogue directly still gets local ids,
274
+ * exactly as before this existed. Route through here even for ONE catalogue -
275
+ * which is what every CLI verb does - and ids are qualified from the start, so
276
+ * they do not change later when a workspace first carries a question of its
277
+ * own. That transition is the one thing an adopter keying stored judgments on
278
+ * a question id must not experience, and composing unconditionally is how it
279
+ * is avoided.
280
+ *
281
+ * ID COLLISIONS DISSOLVE rather than being resolved. Two catalogues may both
282
+ * carry `outcome-missing`; qualified, they are two different questions. There
283
+ * is no merge rule, no last-wins and no refusal, because there is nothing to
284
+ * merge.
285
+ */
286
+ export declare function composeCatalogues(sources: readonly WorkspaceSource[], profileContext?: ResolvedProfileContext): CatalogueCompositionResult;
206
287
  export declare function loadQuestionCatalogue(catalogueSource: WorkspaceSource, profileContext?: ResolvedProfileContext): CatalogueLoadResult;
207
288
  export declare function renderInterrogationReport(report: InterrogationReport): string;
@@ -108,6 +108,53 @@ export interface ProjectionExclusion {
108
108
  readonly id: string;
109
109
  readonly facet: ConceptFacet;
110
110
  }
111
+ /** A query facet naming something the model does not have. */
112
+ export interface UnmatchedSelector {
113
+ readonly facet: string;
114
+ readonly value: string;
115
+ /** The closest name the facet does offer, when one is close enough to be a likely typo. */
116
+ readonly nearest?: string;
117
+ }
118
+ /**
119
+ * Every value in a projection query that names nothing.
120
+ *
121
+ * A projection is a DOCUMENT, and a query holds references the same way a
122
+ * relationship does. YarraMate refuses a relationship pointing at a concept
123
+ * that does not exist; it did not refuse a query naming a state that does not
124
+ * exist, and the symptom is silent. `states: [target-stat]` selects no state,
125
+ * which selects no subject, which exports a clean empty artifact with exit 0.
126
+ * Someone hands that to a client.
127
+ *
128
+ * Checked at `check`, not at `export`, because the typo is in a file rather
129
+ * than in an invocation: CI catches it, and every verb over the same
130
+ * projection inherits the guard instead of each growing its own.
131
+ *
132
+ * ONLY FACETS WITH A CLOSED NAMESPACE ARE CHECKED. `statuses` and
133
+ * `excludeStatuses` are schema enums, refused upstream before this runs.
134
+ * Everything else names something: `owners` and `constraints` are REFS to
135
+ * concepts, which the compiler itself proves by refusing an unresolved owner
136
+ * with YM304, so their namespace is the subject list like `subjects`.
137
+ *
138
+ * Each namespace is derived the way the FILTER derives it, so the check cannot
139
+ * drift from what it guards: `documents` reads the same provenance the
140
+ * `documents` facet compares against, and `states` is the same
141
+ * `yarramate/state/type` scan `conceptSelector` runs.
142
+ *
143
+ * A kind whose profile is not loaded is DORMANT rather than wrong, the same
144
+ * distinction #351 drew for question catalogues, so the kind facets are
145
+ * checked only when a profile context is present.
146
+ *
147
+ * An empty RESULT is not reported here and must not be. A query whose every
148
+ * name resolves and which selects nothing is a real answer to a real question:
149
+ * a target state nobody has populated yet is empty, correctly.
150
+ */
151
+ export declare function unmatchedSelectors(graph: SemanticGraph, projection: ProjectionDefinition, profileContext?: ResolvedProfileContext): readonly UnmatchedSelector[];
152
+ /**
153
+ * {@link unmatchedSelectors} as diagnostics, built HERE rather than at each
154
+ * caller so `check`, and anything that adopts this later, refuse in identical
155
+ * words with an identical code.
156
+ */
157
+ export declare function projectionReferenceDiagnostics(source: WorkspaceSource, projection: ProjectionDefinition, graph: SemanticGraph, profileContext?: ResolvedProfileContext): readonly Diagnostic[];
111
158
  /**
112
159
  * Every concept a query leaves out, and the facet that left it out.
113
160
  *
@@ -7,6 +7,7 @@ export interface WorkspaceManifest {
7
7
  readonly projections: readonly string[];
8
8
  readonly adapterMappings: readonly string[];
9
9
  readonly patterns?: readonly string[];
10
+ readonly questions?: readonly string[];
10
11
  readonly evidence?: readonly string[];
11
12
  readonly contracts?: readonly string[];
12
13
  }
@@ -17,6 +18,20 @@ export interface ResolvedWorkspace {
17
18
  readonly projections: readonly string[];
18
19
  readonly adapterMappings: readonly string[];
19
20
  readonly patterns: readonly string[];
21
+ /**
22
+ * Question catalogues the workspace itself carries (#345, ADR 0129).
23
+ * ADDITIVE to the shipped catalogue: `--catalogue` replaces the base, this
24
+ * adds to it, which is what lets a consultant author a question mid
25
+ * engagement without a product release.
26
+ *
27
+ * OPTIONAL in the type although `loadWorkspaceManifest` always populates it,
28
+ * and that is deliberate. `ResolvedWorkspace` is published, and adding
29
+ * `patterns` to it as a required field broke a consumer's production module:
30
+ * a required field is free for readers and a break for CONSTRUCTORS. Six
31
+ * fixtures in this repository construct one, which is the same signal from
32
+ * inside. Read it as `workspace.questions ?? []`.
33
+ */
34
+ readonly questions?: readonly string[];
20
35
  readonly evidence: readonly string[];
21
36
  readonly contracts: readonly string[];
22
37
  }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `yarramate/workbook` — the workbook writer, for a host that has no Node.
3
+ *
4
+ * ApertureX generates these inside a Cloudflare Worker (#355), so this entry
5
+ * carries the same guarantee `./interrogation` does and is held to it by
6
+ * `test/export-purity.test.ts`: no Node builtins, no `ws`, no session server,
7
+ * and no RUNTIME import of the compiler, whose Ajv and YAML weight would
8
+ * otherwise land in a Durable Object that only wants to write a spreadsheet.
9
+ * The compiler is reached for types alone.
10
+ *
11
+ * A caller hands over an already-evaluated `ProjectionResult` — plain data —
12
+ * and gets bytes back. Evaluating the projection is the caller's business,
13
+ * which is what keeps schema validation out of this import graph.
14
+ */
15
+ export { workbookFrom, buildWorkbookSheets, WORKBOOK_FORMAT, type WorkbookProvenance, } from './workbook.js';
16
+ export { writeXlsx, columnName, escapeXml, sheetNameIsLegal, type WorkbookSheet, type SheetState, } from './workbook-xlsx.js';
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `yarramate/workbook` — the workbook writer, for a host that has no Node.
3
+ *
4
+ * ApertureX generates these inside a Cloudflare Worker (#355), so this entry
5
+ * carries the same guarantee `./interrogation` does and is held to it by
6
+ * `test/export-purity.test.ts`: no Node builtins, no `ws`, no session server,
7
+ * and no RUNTIME import of the compiler, whose Ajv and YAML weight would
8
+ * otherwise land in a Durable Object that only wants to write a spreadsheet.
9
+ * The compiler is reached for types alone.
10
+ *
11
+ * A caller hands over an already-evaluated `ProjectionResult` — plain data —
12
+ * and gets bytes back. Evaluating the projection is the caller's business,
13
+ * which is what keeps schema validation out of this import graph.
14
+ */
15
+ export { workbookFrom, buildWorkbookSheets, WORKBOOK_FORMAT, } from './workbook.js';
16
+ export { writeXlsx, columnName, escapeXml, sheetNameIsLegal, } from './workbook-xlsx.js';
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `yarramate/workbook/import` — reading an edited workbook back, for a host
3
+ * that has no Node.
4
+ *
5
+ * A SEPARATE subpath from `yarramate/workbook` rather than more exports on it,
6
+ * because the package declares no `sideEffects` field and a bundler must
7
+ * therefore assume every module might have one. A Worker that only GENERATES
8
+ * workbooks would otherwise carry the reader, the merge and the operations
9
+ * emitter it never calls, and small bundles are the reason the xlsx container
10
+ * is hand-written in the first place (#355).
11
+ *
12
+ * Held to the same bar as `./workbook` and `./interrogation` by
13
+ * `test/export-purity.test.ts`: no Node builtins, no `ws`, no session server,
14
+ * no runtime import of the compiler. `workbook-read.ts` imports nothing at
15
+ * all; inflation uses `DecompressionStream('deflate-raw')`, which Workers,
16
+ * browsers and Node 18+ all have.
17
+ *
18
+ * The reader is ASYNC and the writer is not, deliberately. A workbook this
19
+ * package wrote has stored entries and inline strings, but one a person saved
20
+ * from Excel comes back deflated and shared-stringed, and inflation is only
21
+ * offered as a stream.
22
+ *
23
+ * What a host still owns: reading the bytes, evaluating the projection that
24
+ * says what the workbook SHOULD hold now, and applying the operations. This
25
+ * entry turns bytes into sheets, sheets into a merge report, and a merge
26
+ * report into `yarramate/operations/v1`.
27
+ */
28
+ export { readWorkbook, unzipEntries, columnIndexOf, decodeXmlText, type WorkbookRead, type ZipReadFailure, } from './workbook-read.js';
29
+ export { mergeWorkbook, mergeSheet, keySheet, baselineSheets, isDerivedColumn, type KeyedSheet, type CellChange, type Conflict, type MergeReport, } from './workbook-merge.js';
30
+ export { operationsFrom, operationsDocument, type OperationsResult, } from './workbook-operations.js';
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `yarramate/workbook/import` — reading an edited workbook back, for a host
3
+ * that has no Node.
4
+ *
5
+ * A SEPARATE subpath from `yarramate/workbook` rather than more exports on it,
6
+ * because the package declares no `sideEffects` field and a bundler must
7
+ * therefore assume every module might have one. A Worker that only GENERATES
8
+ * workbooks would otherwise carry the reader, the merge and the operations
9
+ * emitter it never calls, and small bundles are the reason the xlsx container
10
+ * is hand-written in the first place (#355).
11
+ *
12
+ * Held to the same bar as `./workbook` and `./interrogation` by
13
+ * `test/export-purity.test.ts`: no Node builtins, no `ws`, no session server,
14
+ * no runtime import of the compiler. `workbook-read.ts` imports nothing at
15
+ * all; inflation uses `DecompressionStream('deflate-raw')`, which Workers,
16
+ * browsers and Node 18+ all have.
17
+ *
18
+ * The reader is ASYNC and the writer is not, deliberately. A workbook this
19
+ * package wrote has stored entries and inline strings, but one a person saved
20
+ * from Excel comes back deflated and shared-stringed, and inflation is only
21
+ * offered as a stream.
22
+ *
23
+ * What a host still owns: reading the bytes, evaluating the projection that
24
+ * says what the workbook SHOULD hold now, and applying the operations. This
25
+ * entry turns bytes into sheets, sheets into a merge report, and a merge
26
+ * report into `yarramate/operations/v1`.
27
+ */
28
+ export { readWorkbook, unzipEntries, columnIndexOf, decodeXmlText, } from './workbook-read.js';
29
+ export { mergeWorkbook, mergeSheet, keySheet, baselineSheets, isDerivedColumn, } from './workbook-merge.js';
30
+ export { operationsFrom, operationsDocument, } from './workbook-operations.js';
@@ -0,0 +1,77 @@
1
+ import type { WorkbookSheet } from './workbook-xlsx.js';
2
+ /**
3
+ * Turning an edited workbook back into operations (#355, ADR 0127).
4
+ *
5
+ * The workbook carries its own ancestor. `~Baseline` is a copy of the working
6
+ * rows exactly as exported, never edited and never updated, and it is what
7
+ * makes this a THREE-way merge rather than an overwrite: without it, "this
8
+ * cell differs from the model" cannot distinguish *the author changed it* from
9
+ * *the repository moved underneath since the workbook was made*, and the
10
+ * second is silently clobbered.
11
+ *
12
+ * So the comparison is run twice:
13
+ *
14
+ * working vs baseline -> what the author did
15
+ * current vs baseline -> what the repository did since
16
+ *
17
+ * A cell both changed is a conflict and is refused, naming the field and both
18
+ * values. Everything else merges. Nothing is written for a conflict, because
19
+ * `apply` is atomic and a half-applied workbook is worse than a refused one.
20
+ */
21
+ /** A sheet reduced to rows keyed by the id in column A. */
22
+ export interface KeyedSheet {
23
+ readonly header: readonly string[];
24
+ readonly rows: ReadonlyMap<string, readonly string[]>;
25
+ }
26
+ export interface CellChange {
27
+ readonly sheet: string;
28
+ readonly id: string;
29
+ readonly column: string;
30
+ readonly from: string;
31
+ readonly to: string;
32
+ }
33
+ export interface Conflict extends CellChange {
34
+ /** What the repository moved the same field to since the export. */
35
+ readonly theirs: string;
36
+ }
37
+ export interface MergeReport {
38
+ readonly changes: readonly CellChange[];
39
+ readonly added: readonly {
40
+ readonly sheet: string;
41
+ readonly id: string;
42
+ }[];
43
+ readonly conflicts: readonly Conflict[];
44
+ /**
45
+ * Rows the workbook no longer has. Reported, never actioned: a row deleted
46
+ * by accident in a spreadsheet has no symptom, and deletion is not a thing
47
+ * this import does.
48
+ */
49
+ readonly missing: readonly {
50
+ readonly sheet: string;
51
+ readonly id: string;
52
+ }[];
53
+ }
54
+ /** A column whose value is derived for readability and ignored on the way back. */
55
+ export declare const isDerivedColumn: (label: string) => boolean;
56
+ export declare const keySheet: (rows: readonly (readonly string[])[]) => KeyedSheet;
57
+ /**
58
+ * The baseline sheet holds every working row prefixed by its sheet name, so
59
+ * one hidden sheet can carry them all. This puts them back.
60
+ */
61
+ export declare const baselineSheets: (rows: readonly (readonly string[])[]) => ReadonlyMap<string, readonly (readonly string[])[]>;
62
+ /**
63
+ * Three-way merge over one sheet.
64
+ *
65
+ * `current` is the sheet as it would be exported from the model right now, so
66
+ * drift is measured in exactly the terms the author edited in rather than
67
+ * against the YAML.
68
+ */
69
+ export declare const mergeSheet: (sheet: string, working: KeyedSheet, baseline: KeyedSheet, current: KeyedSheet) => MergeReport;
70
+ /**
71
+ * Merge every working sheet the workbook and the model share.
72
+ *
73
+ * A sheet present in one and not the other is skipped rather than guessed at:
74
+ * the machinery sheets are not working data, and a sheet the model no longer
75
+ * produces is not something an import should invent operations for.
76
+ */
77
+ export declare const mergeWorkbook: (working: ReadonlyMap<string, readonly (readonly string[])[]>, baseline: ReadonlyMap<string, readonly (readonly string[])[]>, current: readonly WorkbookSheet[]) => MergeReport;
@@ -0,0 +1,108 @@
1
+ /** A column whose value is derived for readability and ignored on the way back. */
2
+ export const isDerivedColumn = (label) => label.startsWith('↳');
3
+ export const keySheet = (rows) => {
4
+ const header = rows[0] ?? [];
5
+ const keyed = new Map();
6
+ for (const row of rows.slice(1)) {
7
+ const id = row[0] ?? '';
8
+ if (id === '')
9
+ continue;
10
+ keyed.set(id, row);
11
+ }
12
+ return { header, rows: keyed };
13
+ };
14
+ /**
15
+ * The baseline sheet holds every working row prefixed by its sheet name, so
16
+ * one hidden sheet can carry them all. This puts them back.
17
+ */
18
+ export const baselineSheets = (rows) => {
19
+ const sheets = new Map();
20
+ for (const row of rows.slice(1)) {
21
+ const name = row[0] ?? '';
22
+ if (name === '')
23
+ continue;
24
+ const held = sheets.get(name);
25
+ if (held === undefined)
26
+ sheets.set(name, [row.slice(1)]);
27
+ else
28
+ held.push(row.slice(1));
29
+ }
30
+ return sheets;
31
+ };
32
+ const cellsOf = (header, row) => {
33
+ const cells = new Map();
34
+ if (row === undefined)
35
+ return cells;
36
+ header.forEach((label, index) => {
37
+ if (isDerivedColumn(label))
38
+ return;
39
+ cells.set(label, row[index] ?? '');
40
+ });
41
+ return cells;
42
+ };
43
+ /**
44
+ * Three-way merge over one sheet.
45
+ *
46
+ * `current` is the sheet as it would be exported from the model right now, so
47
+ * drift is measured in exactly the terms the author edited in rather than
48
+ * against the YAML.
49
+ */
50
+ export const mergeSheet = (sheet, working, baseline, current) => {
51
+ const changes = [];
52
+ const added = [];
53
+ const conflicts = [];
54
+ const missing = [];
55
+ for (const [id, row] of working.rows) {
56
+ const before = baseline.rows.get(id);
57
+ if (before === undefined) {
58
+ added.push({ sheet, id });
59
+ continue;
60
+ }
61
+ const mine = cellsOf(working.header, row);
62
+ const ancestor = cellsOf(baseline.header, before);
63
+ const theirs = cellsOf(current.header, current.rows.get(id));
64
+ for (const [column, value] of mine) {
65
+ const was = ancestor.get(column) ?? '';
66
+ if (value === was)
67
+ continue;
68
+ const now = theirs.get(column) ?? was;
69
+ if (now !== was && now !== value) {
70
+ // Both moved, and not to the same place.
71
+ conflicts.push({ sheet, id, column, from: was, to: value, theirs: now });
72
+ continue;
73
+ }
74
+ changes.push({ sheet, id, column, from: was, to: value });
75
+ }
76
+ }
77
+ for (const id of baseline.rows.keys()) {
78
+ if (!working.rows.has(id))
79
+ missing.push({ sheet, id });
80
+ }
81
+ return { changes, added, conflicts, missing };
82
+ };
83
+ const EMPTY = { header: [], rows: new Map() };
84
+ /**
85
+ * Merge every working sheet the workbook and the model share.
86
+ *
87
+ * A sheet present in one and not the other is skipped rather than guessed at:
88
+ * the machinery sheets are not working data, and a sheet the model no longer
89
+ * produces is not something an import should invent operations for.
90
+ */
91
+ export const mergeWorkbook = (working, baseline, current) => {
92
+ const currentByName = new Map(current.map((sheet) => [sheet.name, sheet.rows]));
93
+ const reports = [];
94
+ for (const [name, rows] of working) {
95
+ if (name.startsWith('~') || name === '00 Read Me')
96
+ continue;
97
+ const ancestor = baseline.get(name);
98
+ if (ancestor === undefined)
99
+ continue;
100
+ reports.push(mergeSheet(name, keySheet(rows), keySheet(ancestor), currentByName.has(name) ? keySheet(currentByName.get(name)) : EMPTY));
101
+ }
102
+ return {
103
+ changes: reports.flatMap(({ changes }) => changes),
104
+ added: reports.flatMap(({ added }) => added),
105
+ conflicts: reports.flatMap(({ conflicts }) => conflicts),
106
+ missing: reports.flatMap(({ missing }) => missing),
107
+ };
108
+ };
@@ -0,0 +1,9 @@
1
+ import type { MergeReport } from './workbook-merge.js';
2
+ export interface OperationsResult {
3
+ readonly operations: readonly Record<string, unknown>[];
4
+ /** Rows that cannot be turned into an operation, with the reason. */
5
+ readonly refusals: readonly string[];
6
+ }
7
+ export declare const operationsFrom: (report: MergeReport, working: ReadonlyMap<string, readonly (readonly string[])[]>) => OperationsResult;
8
+ /** The operations document `apply` takes. */
9
+ export declare const operationsDocument: (operations: readonly Record<string, unknown>[]) => Record<string, unknown>;
@@ -0,0 +1,139 @@
1
+ import { keySheet } from './workbook-merge.js';
2
+ /**
3
+ * Merged workbook edits, as `yarramate/operations/v1` (#355, ADR 0127).
4
+ *
5
+ * Operations rather than a rewrite, because `applyOperations` edits YAML
6
+ * SURGICALLY: comments, key order and formatting survive an import, and a
7
+ * workbook imported with no edits produces no diff at all. Regenerating the
8
+ * documents would be simpler and would destroy the rationale the models carry.
9
+ *
10
+ * It also means every edit passes the atomic compile gate on the way in. A
11
+ * workbook that would produce an uncompilable model is refused whole, rather
12
+ * than half-written.
13
+ */
14
+ /** Column label to the field name `conceptFields` / `relationshipFields` uses. */
15
+ const CONCEPT_FIELDS = {
16
+ Kind: 'kind',
17
+ Name: 'name',
18
+ Description: 'description',
19
+ Status: 'status',
20
+ Owner: 'owner',
21
+ };
22
+ const RELATIONSHIP_FIELDS = {
23
+ Kind: 'kind',
24
+ From: 'from',
25
+ To: 'to',
26
+ Name: 'name',
27
+ Description: 'description',
28
+ Status: 'status',
29
+ Mode: 'mode',
30
+ Content: 'content',
31
+ };
32
+ const CONCEPT_SHEETS = new Set(['01 Concepts', '03 States']);
33
+ const RELATIONSHIP_SHEET = '02 Relationships';
34
+ /**
35
+ * A kind is written as a qualified identity in the workbook
36
+ * (`yarramate/core@0.1#serving`) because that is what the graph carries, and a
37
+ * document declares the LOCAL name (`serving`). The profile prefix is dropped
38
+ * on the way back rather than written into a document that would then not
39
+ * compile.
40
+ */
41
+ const localKind = (value) => {
42
+ const hash = value.indexOf('#');
43
+ return hash < 0 ? value : value.slice(hash + 1);
44
+ };
45
+ export const operationsFrom = (report, working) => {
46
+ const operations = [];
47
+ const refusals = [];
48
+ const sheets = new Map([...working].map(([name, rows]) => [name, keySheet(rows)]));
49
+ const documentOf = (sheet, id) => {
50
+ const keyed = sheets.get(sheet);
51
+ if (keyed === undefined)
52
+ return '';
53
+ const at = keyed.header.indexOf('Document');
54
+ if (at < 0)
55
+ return '';
56
+ return keyed.rows.get(id)?.[at] ?? '';
57
+ };
58
+ const cell = (sheet, id, column) => {
59
+ const keyed = sheets.get(sheet);
60
+ if (keyed === undefined)
61
+ return '';
62
+ const at = keyed.header.indexOf(column);
63
+ if (at < 0)
64
+ return '';
65
+ return keyed.rows.get(id)?.[at] ?? '';
66
+ };
67
+ // Updates, grouped so one subject with three edited cells is one operation
68
+ // rather than three.
69
+ const updates = new Map();
70
+ for (const change of report.changes) {
71
+ const isConcept = CONCEPT_SHEETS.has(change.sheet);
72
+ const isRelationship = change.sheet === RELATIONSHIP_SHEET;
73
+ if (!isConcept && !isRelationship) {
74
+ // The satellite sheets carry list-valued facts, which are a different
75
+ // shape of edit and are not attempted here.
76
+ refusals.push(`${change.sheet} row "${change.id}": editing ${change.column} on this sheet is not supported yet`);
77
+ continue;
78
+ }
79
+ const fields = isConcept ? CONCEPT_FIELDS : RELATIONSHIP_FIELDS;
80
+ const field = fields[change.column];
81
+ if (field === undefined) {
82
+ if (change.column !== 'Document') {
83
+ refusals.push(`${change.sheet} row "${change.id}": ${change.column} cannot be written back`);
84
+ }
85
+ continue;
86
+ }
87
+ const document = documentOf(change.sheet, change.id);
88
+ if (document === '') {
89
+ refusals.push(`${change.sheet} row "${change.id}": no Document, so there is nowhere to write it`);
90
+ continue;
91
+ }
92
+ const key = `${change.sheet}|${change.id}`;
93
+ const held = updates.get(key) ?? {
94
+ op: isConcept ? 'update-concept' : 'update-relationship',
95
+ document,
96
+ [isConcept ? 'concept' : 'relationship']: { id: change.id },
97
+ };
98
+ const target = held[isConcept ? 'concept' : 'relationship'];
99
+ target[field] = field === 'kind' ? localKind(change.to) : change.to;
100
+ updates.set(key, held);
101
+ }
102
+ operations.push(...updates.values());
103
+ for (const { sheet, id } of report.added) {
104
+ const isConcept = CONCEPT_SHEETS.has(sheet);
105
+ const isRelationship = sheet === RELATIONSHIP_SHEET;
106
+ if (!isConcept && !isRelationship) {
107
+ refusals.push(`${sheet} row "${id}": adding a row to this sheet is not supported yet`);
108
+ continue;
109
+ }
110
+ const document = documentOf(sheet, id);
111
+ if (document === '') {
112
+ refusals.push(`${sheet} row "${id}": a new row needs a Document saying which file it belongs to`);
113
+ continue;
114
+ }
115
+ const fields = isConcept ? CONCEPT_FIELDS : RELATIONSHIP_FIELDS;
116
+ const body = { id };
117
+ for (const [column, field] of Object.entries(fields)) {
118
+ const value = cell(sheet, id, column);
119
+ if (value === '')
120
+ continue;
121
+ body[field] = field === 'kind' ? localKind(value) : value;
122
+ }
123
+ if (body.kind === undefined) {
124
+ refusals.push(`${sheet} row "${id}": a new row needs a Kind`);
125
+ continue;
126
+ }
127
+ operations.push({
128
+ op: isConcept ? 'add-concept' : 'add-relationship',
129
+ document,
130
+ [isConcept ? 'concept' : 'relationship']: body,
131
+ });
132
+ }
133
+ return { operations, refusals };
134
+ };
135
+ /** The operations document `apply` takes. */
136
+ export const operationsDocument = (operations) => ({
137
+ format: 'yarramate/operations/v1',
138
+ operations,
139
+ });