yarramate 1.4.1 → 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.
@@ -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
+ }
@@ -203,5 +203,5 @@ export type CatalogueLoadResult = {
203
203
  readonly ok: false;
204
204
  readonly diagnostics: readonly Diagnostic[];
205
205
  };
206
- export declare function loadQuestionCatalogue(catalogueSource: WorkspaceSource): CatalogueLoadResult;
206
+ export declare function loadQuestionCatalogue(catalogueSource: WorkspaceSource, profileContext?: ResolvedProfileContext): CatalogueLoadResult;
207
207
  export declare function renderInterrogationReport(report: InterrogationReport): string;
@@ -527,9 +527,97 @@ export function evaluateCatalogue(catalogue, graph, profileContext, evidence) {
527
527
  waves,
528
528
  };
529
529
  }
530
+ /**
531
+ * Every qualified kind a catalogue names, from all three fields that carry
532
+ * one.
533
+ *
534
+ * All three die the same way when the kind does not resolve, and two of them
535
+ * are easy to forget. A trigger's kind never matches, so the question never
536
+ * opens. A subject selector's kind selects nothing, so the question is scoped
537
+ * to an empty set. A wave gate's kind never holds, so after #334 the whole
538
+ * wave never opens and carries no questions at all - one typo silently
539
+ * retiring a wave (#351).
540
+ */
541
+ const kindReferencesOf = (catalogue) => {
542
+ const found = [];
543
+ const fromCondition = (condition, path) => {
544
+ if (typeof condition !== 'object' || condition === null)
545
+ return;
546
+ for (const field of ['kinds', 'counterpartKinds']) {
547
+ const value = condition[field];
548
+ if (!Array.isArray(value))
549
+ continue;
550
+ value.forEach((kind, index) => {
551
+ if (typeof kind === 'string')
552
+ found.push({ kind, path: [...path, field, index] });
553
+ });
554
+ }
555
+ };
556
+ catalogue.waves.forEach((wave, waveIndex) => {
557
+ ;
558
+ (wave.opensWhen ?? []).forEach((condition, conditionIndex) => {
559
+ fromCondition(condition, ['waves', waveIndex, 'opensWhen', conditionIndex]);
560
+ });
561
+ });
562
+ catalogue.questions.forEach((question, questionIndex) => {
563
+ ;
564
+ (question.subjects?.kinds ?? []).forEach((kind, index) => {
565
+ found.push({
566
+ kind,
567
+ path: ['questions', questionIndex, 'subjects', 'kinds', index],
568
+ });
569
+ });
570
+ question.trigger.forEach((condition, conditionIndex) => {
571
+ fromCondition(condition, [
572
+ 'questions',
573
+ questionIndex,
574
+ 'trigger',
575
+ conditionIndex,
576
+ ]);
577
+ });
578
+ });
579
+ return found;
580
+ };
581
+ /**
582
+ * Kinds this catalogue names that the profile they belong to does not have.
583
+ *
584
+ * The check is deliberately narrow, and the narrowness is the design (#351).
585
+ * A kind is reported ONLY when its profile is loaded and the kind is absent
586
+ * from it, which is unambiguously a typo. A kind whose profile is not loaded
587
+ * at all is left alone, because that is a legitimately dormant cross-profile
588
+ * question rather than a mistake: `core-enrichment` names four
589
+ * `yarramate/policy@0.1` constraint kinds, and `yarramate/policy@0.1` loads
590
+ * only when a document selects it or a profile extends it. Reporting those
591
+ * four would put four false positives on the catalogue this repository ships,
592
+ * and a check that cries wolf on its own catalogue gets turned off.
593
+ *
594
+ * Resolution is tested against the kind maps rather than a declared-kinds
595
+ * list, so a kind inherited through `extends` counts. A profile that declares
596
+ * no kinds of its own and inherits every one of them is the case a
597
+ * declared-kinds check would call entirely missing.
598
+ */
599
+ const unresolvableKinds = (catalogue, profileContext) => {
600
+ const known = new Set([
601
+ ...profileContext.conceptKindLineages.keys(),
602
+ ...profileContext.relationshipKindLineages.keys(),
603
+ ]);
604
+ const loadedProfiles = new Set();
605
+ for (const identity of known) {
606
+ const hash = identity.indexOf('#');
607
+ if (hash > 0)
608
+ loadedProfiles.add(identity.slice(0, hash));
609
+ }
610
+ return kindReferencesOf(catalogue).filter(({ kind }) => {
611
+ if (known.has(kind))
612
+ return false;
613
+ const hash = kind.indexOf('#');
614
+ // Profile absent entirely: dormant, not wrong.
615
+ return hash > 0 && loadedProfiles.has(kind.slice(0, hash));
616
+ });
617
+ };
530
618
  // Shared by interrogate and design: schema validation plus the YM911
531
619
  // undeclared-wave check, both source-located against the catalogue file.
532
- export function loadQuestionCatalogue(catalogueSource) {
620
+ export function loadQuestionCatalogue(catalogueSource, profileContext) {
533
621
  const loadedCatalogue = loadSourceDocument(catalogueSource, validateCatalogue, 'Question catalogue');
534
622
  if (!loadedCatalogue.ok) {
535
623
  return { ok: false, diagnostics: loadedCatalogue.diagnostics };
@@ -549,6 +637,20 @@ export function loadQuestionCatalogue(catalogueSource) {
549
637
  if (waveDiagnostics.length > 0) {
550
638
  return { ok: false, diagnostics: waveDiagnostics };
551
639
  }
640
+ // Only when a caller has a compiled workspace to check against. Without one
641
+ // there is no way to tell a typo from a kind whose profile simply is not
642
+ // here, and guessing would be the false positive this check exists to avoid.
643
+ const kindDiagnostics = profileContext === undefined
644
+ ? []
645
+ : unresolvableKinds(catalogue, profileContext).map(({ kind, path }) => ({
646
+ severity: 'error',
647
+ code: 'YM914',
648
+ message: `Kind "${kind}" is not declared by profile "${kind.slice(0, kind.indexOf('#'))}", which this workspace loads, so the question can never fire`,
649
+ ...locateSourcePath(catalogueSource.path, loadedCatalogue.document.yaml, loadedCatalogue.document.lineCounter, path, `/${path.join('/')}`),
650
+ }));
651
+ if (kindDiagnostics.length > 0) {
652
+ return { ok: false, diagnostics: kindDiagnostics };
653
+ }
552
654
  return { ok: true, catalogue };
553
655
  }
554
656
  // Shared by interrogate and `ask --open`: the wave-by-wave human report.