yarramate 1.5.0 → 1.6.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/cli-support.d.ts +1 -1
- package/dist/cli-support.js +1 -1
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +17 -1
- package/dist/export-command.js +41 -5
- package/dist/import-command.d.ts +16 -0
- package/dist/import-command.js +156 -0
- package/dist/workbook-entry.d.ts +16 -0
- package/dist/workbook-entry.js +16 -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 +293 -0
- package/docs/CONSUMING-YARRAMATE.md +64 -0
- package/package.json +5 -1
package/dist/cli-support.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export interface CliResult {
|
|
|
7
7
|
export declare const isMainModule: (moduleUrl: string, entrypoint: string | undefined) => boolean;
|
|
8
8
|
export declare const packageVersion: string;
|
|
9
9
|
export declare const versionResult: (binary: string) => CliResult;
|
|
10
|
-
export declare const usage = "Usage:\n yarramate init <directory> [--no-pointer]\n yarramate design <workspace.yaml> [--subject <subject-id>] [--catalogue <catalogue.yaml>] [--facilitate] [--json]\n yarramate apply <operations.yaml> <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> \"<free text>\" | <subject-id> ... | <projection.yaml> [--budget <tokens>] [--neighbours <n>] [--json]\n yarramate ask <workspace.yaml> --subjects [--kind <term>] [--status <status>] [--json]\n yarramate ask <workspace.yaml> --kinds [--json]\n yarramate ask <workspace.yaml> --advise \"<topic>\" [--budget <tokens>] [--neighbours <n>] [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --where \"<free text>\" | <subject-id> ... [--json]\n yarramate ask <workspace.yaml> --next [--json]\n yarramate ask <workspace.yaml> --open [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --compare <from-state> <to-state> [--json]\n yarramate ask <workspace.yaml> --changed <git-range> [--budget <tokens>] [--neighbours <n>] [--json]\n yarramate check <source.yaml> [source.yaml ...] [--json] [--strict]\n yarramate reconcile <workspace.yaml> [--json]\n yarramate export graph <workspace.yaml> [--out <file>]\n yarramate export markdown <projection.yaml> <workspace.yaml> [--out <file>]\n yarramate export markdown --changed <git-range> <workspace.yaml> [--out <file>]\n yarramate export briefs <projection.yaml> <workspace.yaml> --out <directory> [--budget <tokens>]\n yarramate export briefs --changed <git-range> <workspace.yaml> --out <directory> [--budget <tokens>]\n yarramate export rtm <workspace.yaml> --out <directory>\n yarramate export likec4 <likec4-project.yaml> <output-dir> <workspace.yaml> [--changed <git-range>]\n";
|
|
10
|
+
export declare const usage = "Usage:\n yarramate init <directory> [--no-pointer]\n yarramate design <workspace.yaml> [--subject <subject-id>] [--catalogue <catalogue.yaml>] [--facilitate] [--json]\n yarramate apply <operations.yaml> <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> \"<free text>\" | <subject-id> ... | <projection.yaml> [--budget <tokens>] [--neighbours <n>] [--json]\n yarramate ask <workspace.yaml> --subjects [--kind <term>] [--status <status>] [--json]\n yarramate ask <workspace.yaml> --kinds [--json]\n yarramate ask <workspace.yaml> --advise \"<topic>\" [--budget <tokens>] [--neighbours <n>] [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --where \"<free text>\" | <subject-id> ... [--json]\n yarramate ask <workspace.yaml> --next [--json]\n yarramate ask <workspace.yaml> --open [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --compare <from-state> <to-state> [--json]\n yarramate ask <workspace.yaml> --changed <git-range> [--budget <tokens>] [--neighbours <n>] [--json]\n yarramate check <source.yaml> [source.yaml ...] [--json] [--strict]\n yarramate reconcile <workspace.yaml> [--json]\n yarramate export graph <workspace.yaml> [--out <file>]\n yarramate export markdown <projection.yaml> <workspace.yaml> [--out <file>]\n yarramate export markdown --changed <git-range> <workspace.yaml> [--out <file>]\n yarramate export briefs <projection.yaml> <workspace.yaml> --out <directory> [--budget <tokens>]\n yarramate export briefs --changed <git-range> <workspace.yaml> --out <directory> [--budget <tokens>]\n yarramate export rtm <workspace.yaml> --out <directory>\n yarramate export xlsx <projection.yaml> <workspace.yaml> --out <file>\n yarramate import xlsx <workbook.xlsx> <workspace.yaml> [--json]\n yarramate export likec4 <likec4-project.yaml> <output-dir> <workspace.yaml> [--changed <git-range>]\n";
|
|
11
11
|
export declare const diagnosticJson: (diagnostics: unknown) => string;
|
|
12
12
|
export declare const checkResultJson: (ok: boolean, diagnostics: unknown, counted?: {
|
|
13
13
|
readonly documents: number;
|
package/dist/cli-support.js
CHANGED
|
@@ -22,7 +22,7 @@ export const versionResult = (binary) => ({
|
|
|
22
22
|
stdout: `${binary} ${packageVersion}\n`,
|
|
23
23
|
stderr: '',
|
|
24
24
|
});
|
|
25
|
-
export const usage = 'Usage:\n yarramate init <directory> [--no-pointer]\n yarramate design <workspace.yaml> [--subject <subject-id>] [--catalogue <catalogue.yaml>] [--facilitate] [--json]\n yarramate apply <operations.yaml> <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> "<free text>" | <subject-id> ... | <projection.yaml> [--budget <tokens>] [--neighbours <n>] [--json]\n yarramate ask <workspace.yaml> --subjects [--kind <term>] [--status <status>] [--json]\n yarramate ask <workspace.yaml> --kinds [--json]\n yarramate ask <workspace.yaml> --advise "<topic>" [--budget <tokens>] [--neighbours <n>] [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --where "<free text>" | <subject-id> ... [--json]\n yarramate ask <workspace.yaml> --next [--json]\n yarramate ask <workspace.yaml> --open [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --compare <from-state> <to-state> [--json]\n yarramate ask <workspace.yaml> --changed <git-range> [--budget <tokens>] [--neighbours <n>] [--json]\n yarramate check <source.yaml> [source.yaml ...] [--json] [--strict]\n yarramate reconcile <workspace.yaml> [--json]\n yarramate export graph <workspace.yaml> [--out <file>]\n yarramate export markdown <projection.yaml> <workspace.yaml> [--out <file>]\n yarramate export markdown --changed <git-range> <workspace.yaml> [--out <file>]\n yarramate export briefs <projection.yaml> <workspace.yaml> --out <directory> [--budget <tokens>]\n yarramate export briefs --changed <git-range> <workspace.yaml> --out <directory> [--budget <tokens>]\n yarramate export rtm <workspace.yaml> --out <directory>\n yarramate export likec4 <likec4-project.yaml> <output-dir> <workspace.yaml> [--changed <git-range>]\n';
|
|
25
|
+
export const usage = 'Usage:\n yarramate init <directory> [--no-pointer]\n yarramate design <workspace.yaml> [--subject <subject-id>] [--catalogue <catalogue.yaml>] [--facilitate] [--json]\n yarramate apply <operations.yaml> <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> [--json]\n yarramate ask <workspace.yaml> "<free text>" | <subject-id> ... | <projection.yaml> [--budget <tokens>] [--neighbours <n>] [--json]\n yarramate ask <workspace.yaml> --subjects [--kind <term>] [--status <status>] [--json]\n yarramate ask <workspace.yaml> --kinds [--json]\n yarramate ask <workspace.yaml> --advise "<topic>" [--budget <tokens>] [--neighbours <n>] [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --where "<free text>" | <subject-id> ... [--json]\n yarramate ask <workspace.yaml> --next [--json]\n yarramate ask <workspace.yaml> --open [--catalogue <catalogue.yaml>] [--json]\n yarramate ask <workspace.yaml> --compare <from-state> <to-state> [--json]\n yarramate ask <workspace.yaml> --changed <git-range> [--budget <tokens>] [--neighbours <n>] [--json]\n yarramate check <source.yaml> [source.yaml ...] [--json] [--strict]\n yarramate reconcile <workspace.yaml> [--json]\n yarramate export graph <workspace.yaml> [--out <file>]\n yarramate export markdown <projection.yaml> <workspace.yaml> [--out <file>]\n yarramate export markdown --changed <git-range> <workspace.yaml> [--out <file>]\n yarramate export briefs <projection.yaml> <workspace.yaml> --out <directory> [--budget <tokens>]\n yarramate export briefs --changed <git-range> <workspace.yaml> --out <directory> [--budget <tokens>]\n yarramate export rtm <workspace.yaml> --out <directory>\n yarramate export xlsx <projection.yaml> <workspace.yaml> --out <file>\n yarramate import xlsx <workbook.xlsx> <workspace.yaml> [--json]\n yarramate export likec4 <likec4-project.yaml> <output-dir> <workspace.yaml> [--changed <git-range>]\n';
|
|
26
26
|
export const diagnosticJson = (diagnostics) => `${JSON.stringify({
|
|
27
27
|
format: 'yarramate/diagnostic-result/v1',
|
|
28
28
|
diagnostics,
|
package/dist/cli.d.ts
CHANGED
|
@@ -3,3 +3,13 @@ import { type CliResult } from './cli-support.js';
|
|
|
3
3
|
export type { CliResult } from './cli-support.js';
|
|
4
4
|
export declare const deriveInitId: (directory: string) => string;
|
|
5
5
|
export declare function runCli(args: readonly string[], cwd?: string): CliResult;
|
|
6
|
+
/**
|
|
7
|
+
* Every verb, including the one that cannot be synchronous.
|
|
8
|
+
*
|
|
9
|
+
* `import xlsx` has to inflate a workbook, and the only inflater available
|
|
10
|
+
* everywhere this runs is `DecompressionStream`, which is async. Widening
|
|
11
|
+
* `runCli` to return a promise would change the type every one of its callers
|
|
12
|
+
* reads - the readers half of the rule in CONTRIBUTING.md - so the async verb
|
|
13
|
+
* gets its own entry and `runCli` keeps its signature.
|
|
14
|
+
*/
|
|
15
|
+
export declare function runCliAsync(args: readonly string[], cwd?: string): Promise<CliResult>;
|
package/dist/cli.js
CHANGED
|
@@ -5,6 +5,7 @@ import { compileWorkspace } from './compiler.js';
|
|
|
5
5
|
import { diagnosticJson, isMainModule, usage, versionResult, } from './cli-support.js';
|
|
6
6
|
import { runAskCommand } from './ask-command.js';
|
|
7
7
|
import { runCheckCommand } from './check-command.js';
|
|
8
|
+
import { runImportCommand } from './import-command.js';
|
|
8
9
|
import { runExportCommand } from './export-command.js';
|
|
9
10
|
import { runApplyCommand } from './apply-cli.js';
|
|
10
11
|
import { runDesignCommand } from './design-command.js';
|
|
@@ -227,8 +228,23 @@ export function runCli(args, cwd = process.cwd()) {
|
|
|
227
228
|
}
|
|
228
229
|
return { exitCode: 2, stdout: '', stderr: usage };
|
|
229
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* Every verb, including the one that cannot be synchronous.
|
|
233
|
+
*
|
|
234
|
+
* `import xlsx` has to inflate a workbook, and the only inflater available
|
|
235
|
+
* everywhere this runs is `DecompressionStream`, which is async. Widening
|
|
236
|
+
* `runCli` to return a promise would change the type every one of its callers
|
|
237
|
+
* reads - the readers half of the rule in CONTRIBUTING.md - so the async verb
|
|
238
|
+
* gets its own entry and `runCli` keeps its signature.
|
|
239
|
+
*/
|
|
240
|
+
export async function runCliAsync(args, cwd = process.cwd()) {
|
|
241
|
+
const [command, ...options] = args;
|
|
242
|
+
if (command === 'import')
|
|
243
|
+
return runImportCommand(options, cwd);
|
|
244
|
+
return runCli(args, cwd);
|
|
245
|
+
}
|
|
230
246
|
if (isMainModule(import.meta.url, process.argv[1])) {
|
|
231
|
-
const result =
|
|
247
|
+
const result = await runCliAsync(process.argv.slice(2));
|
|
232
248
|
process.stdout.write(result.stdout);
|
|
233
249
|
process.stderr.write(result.stderr);
|
|
234
250
|
process.exitCode = result.exitCode;
|
package/dist/export-command.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
3
4
|
import { dirname, join, resolve } from 'node:path';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
6
|
import { parseDocument } from 'yaml';
|
|
6
7
|
import { renderBrief } from './brief.js';
|
|
7
8
|
import { deriveChangedSubjects } from './changed.js';
|
|
8
|
-
import { humanDiagnostics, usage } from './cli-support.js';
|
|
9
|
+
import { humanDiagnostics, packageVersion, usage, } from './cli-support.js';
|
|
9
10
|
import { compileWorkspaceWithProfileContext, } from './compiler.js';
|
|
10
11
|
import { serializeSemanticGraph } from './graph.js';
|
|
11
12
|
import { evaluateEvidenceWorkspace, loadEvidence, } from './evidence.js';
|
|
12
13
|
import { evaluateProjection, loadProjection, renderProjectionMarkdown, } from './projection.js';
|
|
13
14
|
import { buildRtm, renderRtmMarkdown } from './rtm.js';
|
|
15
|
+
import { workbookFrom } from './workbook.js';
|
|
14
16
|
import { loadWorkspaceManifest } from './workspace.js';
|
|
15
17
|
// The adapter stays a separate process behind the verb: the core never
|
|
16
18
|
// imports adapter code (the adapter-runtime-dependency exclusion), it
|
|
@@ -74,7 +76,7 @@ const parseExportOptions = (options) => {
|
|
|
74
76
|
export function runExportCommand(options, cwd) {
|
|
75
77
|
const [kind, ...rest] = options;
|
|
76
78
|
if (kind === undefined ||
|
|
77
|
-
!['graph', 'markdown', 'briefs', 'rtm', 'likec4'].includes(kind)) {
|
|
79
|
+
!['graph', 'markdown', 'briefs', 'rtm', 'likec4', 'xlsx'].includes(kind)) {
|
|
78
80
|
return { exitCode: 2, stdout: '', stderr: usage };
|
|
79
81
|
}
|
|
80
82
|
const parsed = parseExportOptions(rest);
|
|
@@ -128,7 +130,8 @@ export function runExportCommand(options, cwd) {
|
|
|
128
130
|
parsed.json ||
|
|
129
131
|
(usesChanged && (kind === 'graph' || kind === 'rtm')) ||
|
|
130
132
|
(parsed.budget !== undefined && kind !== 'briefs') ||
|
|
131
|
-
((kind === 'briefs' || kind === 'rtm'
|
|
133
|
+
((kind === 'briefs' || kind === 'rtm' || kind === 'xlsx') &&
|
|
134
|
+
parsed.out === undefined)) {
|
|
132
135
|
return { exitCode: 2, stdout: '', stderr: usage };
|
|
133
136
|
}
|
|
134
137
|
try {
|
|
@@ -149,14 +152,17 @@ export function runExportCommand(options, cwd) {
|
|
|
149
152
|
if (!loadedWorkspace.ok)
|
|
150
153
|
return failed(loadedWorkspace.diagnostics);
|
|
151
154
|
const workspace = loadedWorkspace.workspace;
|
|
152
|
-
|
|
155
|
+
// Named rather than inlined so the workbook can pin its digests against
|
|
156
|
+
// exactly the bytes that compiled, the way a visual commit does (#355).
|
|
157
|
+
const sources = [
|
|
153
158
|
...workspace.profiles,
|
|
154
159
|
...workspace.patterns,
|
|
155
160
|
...workspace.documents,
|
|
156
161
|
].map((path) => ({
|
|
157
162
|
path,
|
|
158
163
|
source: readFileSync(resolve(cwd, path), 'utf8'),
|
|
159
|
-
}))
|
|
164
|
+
}));
|
|
165
|
+
const compilation = compileWorkspaceWithProfileContext(sources);
|
|
160
166
|
if (!compilation.ok)
|
|
161
167
|
return failed(compilation.diagnostics);
|
|
162
168
|
if (kind === 'rtm') {
|
|
@@ -246,6 +252,36 @@ export function runExportCommand(options, cwd) {
|
|
|
246
252
|
return failed(loadedProjection.diagnostics);
|
|
247
253
|
result = evaluateProjection(compilation.graph, loadedProjection.projection, compilation.profileContext);
|
|
248
254
|
}
|
|
255
|
+
if (kind === 'xlsx') {
|
|
256
|
+
// A workbook an architect can work in (#355). It takes a PROJECTION,
|
|
257
|
+
// like markdown and briefs do, which is what gives it version selection
|
|
258
|
+
// for free: a projection query already has a `states` facet, so
|
|
259
|
+
// "export the target state" is an existing capability rather than a
|
|
260
|
+
// flag competing with it.
|
|
261
|
+
const bytes = workbookFrom(result, {
|
|
262
|
+
workspace: workspace.id,
|
|
263
|
+
yarramateVersion: packageVersion,
|
|
264
|
+
sourceDigests: Object.fromEntries(sources.map(({ path, source }) => [
|
|
265
|
+
path,
|
|
266
|
+
createHash('sha256').update(source, 'utf8').digest('hex'),
|
|
267
|
+
])),
|
|
268
|
+
conceptKinds: [
|
|
269
|
+
...compilation.profileContext.conceptKindLineages.keys(),
|
|
270
|
+
].sort(),
|
|
271
|
+
relationshipKinds: [
|
|
272
|
+
...compilation.profileContext.relationshipKindLineages.keys(),
|
|
273
|
+
].sort(),
|
|
274
|
+
statuses: ['planned', 'current', 'retired'],
|
|
275
|
+
});
|
|
276
|
+
const outPath = resolve(cwd, parsed.out);
|
|
277
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
278
|
+
writeFileSync(outPath, bytes);
|
|
279
|
+
return {
|
|
280
|
+
exitCode: 0,
|
|
281
|
+
stdout: `Wrote workbook to ${parsed.out}\n`,
|
|
282
|
+
stderr: '',
|
|
283
|
+
};
|
|
284
|
+
}
|
|
249
285
|
if (kind === 'markdown') {
|
|
250
286
|
const rendered = renderProjectionMarkdown(result, compilation.profileContext);
|
|
251
287
|
if (parsed.out === undefined) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type CliResult } from './cli-support.js';
|
|
2
|
+
/**
|
|
3
|
+
* `yarramate import xlsx <file> <workspace.yaml>` (#355, ADR 0127).
|
|
4
|
+
*
|
|
5
|
+
* The workbook carries its own ancestor, so this is a three-way merge rather
|
|
6
|
+
* than an overwrite: the author's edits are measured against `~Baseline`, the
|
|
7
|
+
* repository's drift is measured against the same, and only a field both moved
|
|
8
|
+
* is refused. Everything the author changed that the repository left alone
|
|
9
|
+
* merges cleanly, which is what makes a week-long workbook cycle usable.
|
|
10
|
+
*
|
|
11
|
+
* Edits land as `yarramate/operations/v1` through `apply`, so untouched YAML
|
|
12
|
+
* keeps its comments, key order and formatting, and the whole import passes
|
|
13
|
+
* the atomic compile gate. A workbook that would produce an uncompilable model
|
|
14
|
+
* is refused whole rather than half written.
|
|
15
|
+
*/
|
|
16
|
+
export declare function runImportCommand(options: readonly string[], cwd: string): Promise<CliResult>;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { runApplyCommand } from './apply-cli.js';
|
|
4
|
+
import { humanDiagnostics, packageVersion, usage } from './cli-support.js';
|
|
5
|
+
import { compileWorkspaceWithProfileContext } from './compiler.js';
|
|
6
|
+
import { evaluateProjection } from './projection.js';
|
|
7
|
+
import { loadWorkspaceManifest } from './workspace.js';
|
|
8
|
+
import { buildWorkbookSheets } from './workbook.js';
|
|
9
|
+
import { baselineSheets, mergeWorkbook } from './workbook-merge.js';
|
|
10
|
+
import { operationsFrom, operationsDocument } from './workbook-operations.js';
|
|
11
|
+
import { readWorkbook } from './workbook-read.js';
|
|
12
|
+
import { writeFileSync, mkdtempSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { stringify } from 'yaml';
|
|
16
|
+
/**
|
|
17
|
+
* `yarramate import xlsx <file> <workspace.yaml>` (#355, ADR 0127).
|
|
18
|
+
*
|
|
19
|
+
* The workbook carries its own ancestor, so this is a three-way merge rather
|
|
20
|
+
* than an overwrite: the author's edits are measured against `~Baseline`, the
|
|
21
|
+
* repository's drift is measured against the same, and only a field both moved
|
|
22
|
+
* is refused. Everything the author changed that the repository left alone
|
|
23
|
+
* merges cleanly, which is what makes a week-long workbook cycle usable.
|
|
24
|
+
*
|
|
25
|
+
* Edits land as `yarramate/operations/v1` through `apply`, so untouched YAML
|
|
26
|
+
* keeps its comments, key order and formatting, and the whole import passes
|
|
27
|
+
* the atomic compile gate. A workbook that would produce an uncompilable model
|
|
28
|
+
* is refused whole rather than half written.
|
|
29
|
+
*/
|
|
30
|
+
export async function runImportCommand(options, cwd) {
|
|
31
|
+
const [kind, ...rest] = options;
|
|
32
|
+
if (kind !== 'xlsx')
|
|
33
|
+
return { exitCode: 2, stdout: '', stderr: usage };
|
|
34
|
+
const json = rest.includes('--json');
|
|
35
|
+
const positionals = rest.filter((option) => option !== '--json');
|
|
36
|
+
const [workbookPath, workspacePath] = positionals;
|
|
37
|
+
if (positionals.length !== 2 ||
|
|
38
|
+
workbookPath === undefined ||
|
|
39
|
+
workspacePath === undefined) {
|
|
40
|
+
return { exitCode: 2, stdout: '', stderr: usage };
|
|
41
|
+
}
|
|
42
|
+
const manifestPath = resolve(cwd, workspacePath);
|
|
43
|
+
let manifestSource;
|
|
44
|
+
let workbookBytes;
|
|
45
|
+
try {
|
|
46
|
+
manifestSource = readFileSync(manifestPath, 'utf8');
|
|
47
|
+
workbookBytes = new Uint8Array(readFileSync(resolve(cwd, workbookPath)));
|
|
48
|
+
}
|
|
49
|
+
catch (cause) {
|
|
50
|
+
return {
|
|
51
|
+
exitCode: 1,
|
|
52
|
+
stdout: '',
|
|
53
|
+
stderr: `${cause instanceof Error ? cause.message : String(cause)}\n`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const loaded = loadWorkspaceManifest({ path: workspacePath, source: manifestSource }, cwd);
|
|
57
|
+
if (!loaded.ok) {
|
|
58
|
+
return {
|
|
59
|
+
exitCode: 1,
|
|
60
|
+
stdout: '',
|
|
61
|
+
stderr: humanDiagnostics(loaded.diagnostics),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const workspace = loaded.workspace;
|
|
65
|
+
const sources = [
|
|
66
|
+
...workspace.profiles,
|
|
67
|
+
...workspace.patterns,
|
|
68
|
+
...workspace.documents,
|
|
69
|
+
].map((path) => ({ path, source: readFileSync(resolve(cwd, path), 'utf8') }));
|
|
70
|
+
const compilation = compileWorkspaceWithProfileContext(sources);
|
|
71
|
+
if (!compilation.ok) {
|
|
72
|
+
return {
|
|
73
|
+
exitCode: 1,
|
|
74
|
+
stdout: '',
|
|
75
|
+
stderr: humanDiagnostics(compilation.diagnostics),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const read = await readWorkbook(workbookBytes);
|
|
79
|
+
if (!read.ok) {
|
|
80
|
+
return { exitCode: 1, stdout: '', stderr: `${read.reason}\n` };
|
|
81
|
+
}
|
|
82
|
+
const baselineRows = read.sheets.get('~Baseline');
|
|
83
|
+
if (baselineRows === undefined) {
|
|
84
|
+
// Without the ancestor there is no way to tell the author's edit from the
|
|
85
|
+
// repository's drift, and guessing would silently discard one of them.
|
|
86
|
+
return {
|
|
87
|
+
exitCode: 1,
|
|
88
|
+
stdout: '',
|
|
89
|
+
stderr: 'This workbook carries no ~Baseline sheet, so it was not produced by ' +
|
|
90
|
+
'`yarramate export xlsx` and there is no ancestor to merge against.\n',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const projectionId = read.sheets.get('~Meta')?.find((row) => row[0] === 'Projection')?.[1];
|
|
94
|
+
const result = evaluateProjection(compilation.graph, {
|
|
95
|
+
format: 'yarramate/projection/v1',
|
|
96
|
+
id: projectionId ?? 'imported',
|
|
97
|
+
version: '1.0',
|
|
98
|
+
query: {},
|
|
99
|
+
}, compilation.profileContext);
|
|
100
|
+
const current = buildWorkbookSheets(result, {
|
|
101
|
+
workspace: workspace.id,
|
|
102
|
+
yarramateVersion: packageVersion,
|
|
103
|
+
sourceDigests: {},
|
|
104
|
+
conceptKinds: [],
|
|
105
|
+
relationshipKinds: [],
|
|
106
|
+
statuses: [],
|
|
107
|
+
});
|
|
108
|
+
const report = mergeWorkbook(read.sheets, baselineSheets(baselineRows), current);
|
|
109
|
+
const planned = operationsFrom(report, read.sheets);
|
|
110
|
+
if (report.conflicts.length > 0) {
|
|
111
|
+
const lines = report.conflicts.map(({ sheet, id, column, from, to, theirs }) => ` ${sheet} "${id}" ${column}: you wrote ${JSON.stringify(to)}, ` +
|
|
112
|
+
`the workspace now has ${JSON.stringify(theirs)}, both from ${JSON.stringify(from)}`);
|
|
113
|
+
return {
|
|
114
|
+
exitCode: 1,
|
|
115
|
+
stdout: '',
|
|
116
|
+
stderr: `${report.conflicts.length} field${report.conflicts.length === 1 ? '' : 's'} changed in the workbook and in the workspace since the workbook was made. ` +
|
|
117
|
+
'Nothing was written.\n' +
|
|
118
|
+
`${lines.join('\n')}\n`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const notes = [
|
|
122
|
+
...planned.refusals.map((reason) => ` not imported: ${reason}`),
|
|
123
|
+
...report.missing.map(({ sheet, id }) => ` not imported: ${sheet} "${id}" is absent from the workbook; ` +
|
|
124
|
+
'a missing row is never a deletion'),
|
|
125
|
+
];
|
|
126
|
+
if (planned.operations.length === 0) {
|
|
127
|
+
const summary = `Nothing to import: the workbook matches the workspace.\n`;
|
|
128
|
+
return {
|
|
129
|
+
exitCode: 0,
|
|
130
|
+
stdout: json
|
|
131
|
+
? `${JSON.stringify({ format: 'yarramate/import-result/v1', operations: 0, notes }, null, 2)}\n`
|
|
132
|
+
: summary + (notes.length > 0 ? `${notes.join('\n')}\n` : ''),
|
|
133
|
+
stderr: '',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// Handed to `apply` rather than written directly: one mutation path, one
|
|
137
|
+
// atomic gate, and the surgical YAML editing that keeps comments.
|
|
138
|
+
const scratch = mkdtempSync(join(tmpdir(), 'yarramate-import-'));
|
|
139
|
+
const operationsPath = join(scratch, 'operations.yaml');
|
|
140
|
+
writeFileSync(operationsPath, stringify(operationsDocument(planned.operations)), 'utf8');
|
|
141
|
+
const applied = runApplyCommand([operationsPath, workspacePath], cwd);
|
|
142
|
+
if (applied.exitCode !== 0)
|
|
143
|
+
return applied;
|
|
144
|
+
const summary = `Imported ${planned.operations.length} change${planned.operations.length === 1 ? '' : 's'} from ${workbookPath}\n`;
|
|
145
|
+
return {
|
|
146
|
+
exitCode: 0,
|
|
147
|
+
stdout: json
|
|
148
|
+
? `${JSON.stringify({
|
|
149
|
+
format: 'yarramate/import-result/v1',
|
|
150
|
+
operations: planned.operations.length,
|
|
151
|
+
notes,
|
|
152
|
+
}, null, 2)}\n`
|
|
153
|
+
: summary + (notes.length > 0 ? `${notes.join('\n')}\n` : ''),
|
|
154
|
+
stderr: '',
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -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,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>;
|