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.
- package/dist/adapters/visual/session-server.js +27 -1
- package/dist/adapters/visual/workspace-model.d.ts +16 -3
- package/dist/adapters/visual/workspace-model.js +12 -5
- package/dist/ask-command.js +25 -16
- package/dist/catalogue-sources.d.ts +20 -0
- package/dist/catalogue-sources.js +39 -0
- package/dist/check-command.js +38 -9
- package/dist/cli-support.d.ts +8 -1
- package/dist/cli-support.js +5 -1
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +17 -1
- package/dist/design-command.js +21 -9
- package/dist/export-command.js +41 -5
- package/dist/import-command.d.ts +16 -0
- package/dist/import-command.js +156 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/interrogate-command.d.ts +82 -1
- package/dist/interrogate-command.js +195 -29
- package/dist/interrogation-entry.d.ts +1 -1
- package/dist/interrogation-entry.js +1 -1
- package/dist/projection.d.ts +47 -0
- package/dist/projection.js +126 -0
- package/dist/visual-app-lib/editor.js +26771 -26713
- package/dist/visual-app-lib/types/adapters/visual/workspace-model.d.ts +16 -3
- package/dist/visual-app-lib/types/interrogate-command.d.ts +82 -1
- package/dist/visual-app-lib/types/projection.d.ts +47 -0
- package/dist/visual-app-lib/types/workspace.d.ts +15 -0
- package/dist/workbook-entry.d.ts +16 -0
- package/dist/workbook-entry.js +16 -0
- package/dist/workbook-import-entry.d.ts +30 -0
- package/dist/workbook-import-entry.js +30 -0
- package/dist/workbook-merge.d.ts +77 -0
- package/dist/workbook-merge.js +108 -0
- package/dist/workbook-operations.d.ts +9 -0
- package/dist/workbook-operations.js +139 -0
- package/dist/workbook-read.d.ts +43 -0
- package/dist/workbook-read.js +314 -0
- package/dist/workbook-xlsx.d.ts +54 -0
- package/dist/workbook-xlsx.js +228 -0
- package/dist/workbook.d.ts +32 -0
- package/dist/workbook.js +308 -0
- package/dist/workspace.d.ts +15 -0
- package/dist/workspace.js +1 -0
- package/docs/CONSUMING-YARRAMATE.md +127 -0
- package/package.json +9 -1
- package/schema/yarramate-interrogation-report.schema.json +9 -0
- package/schema/yarramate-question-catalogue.schema.json +2 -3
- 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
|
-
},
|
|
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
|
-
}
|
|
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[]
|
|
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
|
+
});
|