yarramate 0.19.0 → 0.21.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 (32) hide show
  1. package/README.md +7 -0
  2. package/dist/adapters/likec4-export.d.ts +1 -1
  3. package/dist/adapters/likec4-export.js +32 -0
  4. package/dist/adapters/visual/protocol-contract.d.ts +13 -1
  5. package/dist/adapters/visual/protocol-contract.js +1 -1
  6. package/dist/adapters/visual/protocol.d.ts +11 -0
  7. package/dist/adapters/visual/protocol.js +12 -0
  8. package/dist/adapters/visual/request.js +1 -3
  9. package/dist/adapters/visual/session-server.js +55 -1
  10. package/dist/adapters/visual/session-store.d.ts +3 -1
  11. package/dist/adapters/visual/session-store.js +66 -5
  12. package/dist/adapters/visual/wire.d.ts +7 -0
  13. package/dist/apply-command.js +132 -13
  14. package/dist/compiler.d.ts +53 -0
  15. package/dist/compiler.js +173 -87
  16. package/dist/index.d.ts +2 -2
  17. package/dist/index.js +1 -1
  18. package/dist/operations.d.ts +18 -0
  19. package/dist/subject-references.d.ts +92 -0
  20. package/dist/subject-references.js +226 -0
  21. package/dist/visual-app/assets/index-CAnZh3Hx.js +354 -0
  22. package/dist/visual-app/assets/{index-dwA9AOr5.css → index-tZSHlU-a.css} +1 -1
  23. package/dist/visual-app/index.html +2 -2
  24. package/package.json +4 -4
  25. package/schema/yarramate-apply-result.schema.json +4 -0
  26. package/schema/yarramate-likec4-diagnostic-result.schema.json +1 -1
  27. package/schema/yarramate-operations.schema.json +54 -3
  28. package/schema/yarramate-visual-event.schema.json +11 -1
  29. package/schema/yarramate-visual-session-descriptor.schema.json +1 -1
  30. package/schema/yarramate-visual-session-started.schema.json +1 -1
  31. package/schema/yarramate-visual-status.schema.json +1 -1
  32. package/dist/visual-app/assets/index-6oSR9RGM.js +0 -354
package/README.md CHANGED
@@ -252,6 +252,13 @@ const result = compileWorkspace([
252
252
  lineage for operations that explicitly require kind ancestry. Graph v2
253
253
  remains the stable, graph-only interchange result.
254
254
 
255
+ `compileWorkspaceIncremental(sources, previous)` is the entry point for a
256
+ consumer that recompiles a whole workspace on every write. It returns the
257
+ same result plus an opaque `cache` to hand back on the next call, and
258
+ re-parses only the sources whose text changed; `incremental: false` reports
259
+ that it fell back to a full compile. Reuse is decided by source-text
260
+ equality, so a stale cache costs work but never changes output.
261
+
255
262
  Normative schemas are available through package exports such as
256
263
  `yarramate/schema/document`, `yarramate/schema/workspace`,
257
264
  `yarramate/schema/graph-v2`, `yarramate/schema/projection`,
@@ -4,7 +4,7 @@ import type { ProjectionResult } from '../projection.js';
4
4
  import type { LikeC4KindMapping } from './likec4-kind-mapping.js';
5
5
  export interface LikeC4ExportDiagnostic {
6
6
  readonly severity: 'error';
7
- readonly code: 'YMLC101' | 'YMLC102' | 'YMLC103' | 'YMLC104' | 'YMLC105' | 'YMLC106' | 'YMLC107' | 'YMLC108' | 'YMLC109' | 'YMLC111';
7
+ readonly code: 'YMLC101' | 'YMLC102' | 'YMLC103' | 'YMLC104' | 'YMLC105' | 'YMLC106' | 'YMLC107' | 'YMLC108' | 'YMLC109' | 'YMLC111' | 'YMLC112';
8
8
  readonly message: string;
9
9
  readonly subject?: string;
10
10
  readonly path: string;
@@ -155,6 +155,38 @@ export function exportLikeC4(projection, mapping, kindMapping, options = {}) {
155
155
  : [` description ${quote(description)}`]), ...metadata, ' }');
156
156
  }
157
157
  }
158
+ // `likec4 validate` accepts an empty model as valid, so it can never notice
159
+ // that selected concepts stopped reaching the emitted text. The shortfall is
160
+ // only visible here, and it is measured on the assembled lines rather than on
161
+ // loop iterations so that a dropped definition is what fails, not a skipped
162
+ // turn of the loop.
163
+ const rendered = new Set(lines.flatMap((line) => {
164
+ if (!line.startsWith(' ') || line.startsWith(' '))
165
+ return [];
166
+ const [head, ...rest] = line.slice(2).split(' = ');
167
+ return rest.length > 0 ? [head] : [];
168
+ }));
169
+ const unrendered = concepts.filter(({ id }) => !rendered.has(externalByNative.get(id)));
170
+ const missing = unrendered[0];
171
+ if (missing !== undefined) {
172
+ const source = sourceForConcept(projection.claims, missing.id) ??
173
+ adapterMappingLocation(mapping, 'adapter');
174
+ return {
175
+ ok: false,
176
+ diagnostics: [
177
+ {
178
+ severity: 'error',
179
+ code: 'YMLC112',
180
+ message: `Rendering coverage: ${concepts.length - unrendered.length} of ${concepts.length} projected concepts reached the LikeC4 model`,
181
+ subject: missing.id,
182
+ path: source.path,
183
+ pointer: source.pointer,
184
+ line: source.line,
185
+ column: source.column,
186
+ },
187
+ ],
188
+ };
189
+ }
158
190
  const relationships = projection.subjects
159
191
  .filter(({ type }) => type === 'relationship')
160
192
  .sort((left, right) => left.id.localeCompare(right.id));
@@ -11,7 +11,7 @@
11
11
  import type { CanvasGraph } from "../../graph-projection.js";
12
12
  import type { YarramateApplyResult, YarramateOperation } from "../../operations.js";
13
13
  import type { ProjectionDefinition, ProjectionQuery } from "../../projection.js";
14
- export declare const VISUAL_PROTOCOL_VERSION: "yarramate/visual-protocol/v2";
14
+ export declare const VISUAL_PROTOCOL_VERSION: "yarramate/visual-protocol/v3";
15
15
  export declare const VISUAL_LIMITS: {
16
16
  readonly messageBytes: number;
17
17
  readonly modelBytes: number;
@@ -124,6 +124,18 @@ export interface VisualLayoutPositions {
124
124
  }
125
125
  export interface VisualChangesetCommitPayload {
126
126
  readonly operations: readonly YarramateOperation[];
127
+ /**
128
+ * What the browser believed each targeted document held when the rows were
129
+ * staged — sha256 keyed by manifest-relative path, pinned at staging time and
130
+ * never refreshed while rows remain staged. The runtime refuses the batch when
131
+ * a pin no longer matches the file, so a same-field overwrite of a write the
132
+ * reviewer never saw cannot land silently (ADR 0093).
133
+ *
134
+ * Required, not optional: a browser that omits it is exactly the browser that
135
+ * cannot detect the conflict, which is why this field is what makes the
136
+ * protocol `v3`.
137
+ */
138
+ readonly sourceDigests: Readonly<Record<string, string>>;
127
139
  }
128
140
  export interface VisualLayoutSavePayload {
129
141
  readonly projectionId: string;
@@ -1,4 +1,4 @@
1
- export const VISUAL_PROTOCOL_VERSION = "yarramate/visual-protocol/v2";
1
+ export const VISUAL_PROTOCOL_VERSION = "yarramate/visual-protocol/v3";
2
2
  export const VISUAL_LIMITS = {
3
3
  messageBytes: 64 * 1024,
4
4
  modelBytes: 5 * 1024 * 1024,
@@ -1,5 +1,16 @@
1
1
  import { type ParseResult, type VisualBrowserInput, type VisualDiagnosticResult, type VisualEvent, type VisualHandoff, type VisualModel, type VisualResponse, type VisualSessionDescriptor, type VisualSessionRequest, type VisualSessionStarted, type VisualStatus } from './protocol-contract.js';
2
2
  export * from './protocol-contract.js';
3
+ /**
4
+ * The one way this adapter mints a source digest.
5
+ *
6
+ * `visual-model/v1` requires a canonical model to record the digests it was
7
+ * derived from (`YMVS112`) and pins their shape to 64 lowercase hex characters,
8
+ * so the value lives beside the validator that enforces it: the request builder
9
+ * mints them for the initial model, the session server re-mints them on every
10
+ * recompile and checks a commit's pins against the files on disk, and all three
11
+ * are the same hash by construction rather than by three matching literals.
12
+ */
13
+ export declare const digestOf: (source: string) => string;
3
14
  export declare const parseVisualModel: (input: unknown) => ParseResult<VisualModel>;
4
15
  export declare const parseVisualSessionRequest: (input: unknown) => ParseResult<VisualSessionRequest>;
5
16
  export declare const parseVisualSessionStarted: (input: unknown) => ParseResult<VisualSessionStarted>;
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { posix } from 'node:path';
2
3
  import Ajv2020Module from 'ajv/dist/2020.js';
3
4
  import { describeSchemaViolation, readableSchemaErrors, } from '../../source-document.js';
@@ -57,6 +58,17 @@ ajv.addSchema([
57
58
  operationsSchema,
58
59
  applyResultSchema,
59
60
  ]);
61
+ /**
62
+ * The one way this adapter mints a source digest.
63
+ *
64
+ * `visual-model/v1` requires a canonical model to record the digests it was
65
+ * derived from (`YMVS112`) and pins their shape to 64 lowercase hex characters,
66
+ * so the value lives beside the validator that enforces it: the request builder
67
+ * mints them for the initial model, the session server re-mints them on every
68
+ * recompile and checks a commit's pins against the files on disk, and all three
69
+ * are the same hash by construction rather than by three matching literals.
70
+ */
71
+ export const digestOf = (source) => createHash('sha256').update(source).digest('hex');
60
72
  // Diagnostics report the document they came from rather than a source file,
61
73
  // because visual protocol documents arrive as parsed JSON over the wire.
62
74
  const documentPaths = new WeakMap();
@@ -1,9 +1,8 @@
1
- import { createHash } from 'node:crypto';
2
1
  import { readFileSync } from 'node:fs';
3
2
  import { resolve } from 'node:path';
4
3
  import { loadProjection } from '../../projection.js';
5
4
  import { loadWorkspaceManifest } from '../../workspace.js';
6
- import { parseVisualSessionRequest } from './protocol.js';
5
+ import { digestOf, parseVisualSessionRequest } from './protocol.js';
7
6
  import { buildVisualModelGraph } from './session-store.js';
8
7
  /**
9
8
  * The one manifest a session can serve. `startVisualServer` resolves exactly
@@ -21,7 +20,6 @@ const requestDiagnostic = (code, message, path = MANIFEST_PATH) => ({
21
20
  line: 1,
22
21
  column: 1,
23
22
  });
24
- const digestOf = (source) => createHash('sha256').update(source).digest('hex');
25
23
  /**
26
24
  * Builds the `yarramate/visual-session-request/v1` document that `start`
27
25
  * consumes, from the workspace on disk.
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  import Ajv2020Module from "ajv/dist/2020.js";
8
8
  import { WebSocketServer } from "ws";
9
9
  import { parse, stringify } from "yaml";
10
- import { VISUAL_LIMITS, VISUAL_PROTOCOL_VERSION, parseVisualBrowserInput, parseVisualResponse, parseVisualSessionStarted, parseVisualStatus, visualBrowserInputType, } from "./protocol.js";
10
+ import { VISUAL_LIMITS, VISUAL_PROTOCOL_VERSION, digestOf, parseVisualBrowserInput, parseVisualResponse, parseVisualSessionStarted, parseVisualStatus, visualBrowserInputType, } from "./protocol.js";
11
11
  import { appendTerminalEvent, appendVisualEvent, appendVisualResponse, createVisualSession, isActionableVisualEvent, recoverVisualSession, removeVisualSession, writeVisualSessionDescriptor, } from "./session-store.js";
12
12
  import { loadProjection, evaluateProjection, } from "../../projection.js";
13
13
  import { compileWorkspaceWithProfileContext, } from "../../compiler.js";
@@ -415,6 +415,7 @@ export const startVisualServer = async (options) => {
415
415
  documents: [],
416
416
  vocabulary: { conceptKinds: [], relationshipKinds: [] },
417
417
  layouts,
418
+ sourceDigests: request.initialModel.sourceDigests,
418
419
  };
419
420
  const capabilities = {
420
421
  chat: request.chatEnabled,
@@ -470,6 +471,9 @@ export const startVisualServer = async (options) => {
470
471
  documents: resolvedWorkspace.documents,
471
472
  vocabulary: { conceptKinds, relationshipKinds },
472
473
  layouts: rendered.layouts,
474
+ // Minted from the bytes this compile just read, so what the browser
475
+ // renders and what it can later claim it rendered are the same read.
476
+ sourceDigests: Object.fromEntries(sources.map(({ path, source }) => [path, digestOf(source)])),
473
477
  };
474
478
  return true;
475
479
  }
@@ -1055,6 +1059,56 @@ export const startVisualServer = async (options) => {
1055
1059
  // the agent anything, so it is answered here directly rather than
1056
1060
  // through the pending queue a poll would drain. This never runs
1057
1061
  // `git commit` - the user reverts a landed batch with `git revert`.
1062
+ // A batch states what it expected each document it touches to hold, and
1063
+ // that expectation is checked against the files before anything is
1064
+ // written. Without it `applyOperations` below would read the workspace
1065
+ // at commit time and do exactly as told, so a row staged against a
1066
+ // value some other writer has since replaced overwrites that writer
1067
+ // silently - the one path left where this adapter loses a write it
1068
+ // reports as landed (ADR 0093).
1069
+ //
1070
+ // Every targeted document that exists is checked, not just every pin
1071
+ // sent: a batch that vouches for nothing would otherwise buy back the
1072
+ // unconditional write by omission, and a precondition nobody has to
1073
+ // state is decoration.
1074
+ const pins = event.payload.sourceDigests;
1075
+ const refused = [];
1076
+ for (const path of new Set(event.payload.operations.map((operation) => operation.document))) {
1077
+ let held;
1078
+ try {
1079
+ held = digestOf(readFileSync(resolve(options.cwd, path), "utf8"));
1080
+ }
1081
+ catch {
1082
+ // Not there to read: `apply` creates it, or something removed it.
1083
+ held = undefined;
1084
+ }
1085
+ const pinned = pins[path];
1086
+ if (held === undefined) {
1087
+ if (pinned !== undefined) {
1088
+ refused.push(serverDiagnostic("YMVS312", `Document "${path}" no longer exists; these edits were staged against it`));
1089
+ }
1090
+ continue;
1091
+ }
1092
+ if (pinned === undefined) {
1093
+ refused.push(serverDiagnostic("YMVS313", `Document "${path}" is edited without stating what it held when the edit was staged`));
1094
+ continue;
1095
+ }
1096
+ if (pinned !== held) {
1097
+ refused.push(serverDiagnostic("YMVS312", `Document "${path}" changed after these edits were staged`));
1098
+ }
1099
+ }
1100
+ if (refused.length > 0) {
1101
+ // Preserve-and-refresh: the rows stay staged in the browser exactly as
1102
+ // a refused apply already leaves them, and the fresh model follows so
1103
+ // the reviewer re-reads the value before deciding what to do with it.
1104
+ sendFrame(socket, {
1105
+ kind: "apply-result",
1106
+ result: { ok: false, diagnostics: refused },
1107
+ });
1108
+ if (recompileWorkspace())
1109
+ broadcast({ kind: "model", model: rendered });
1110
+ return;
1111
+ }
1058
1112
  const operationsSource = stringify({
1059
1113
  format: "yarramate/operations/v1",
1060
1114
  operations: event.payload.operations,
@@ -110,7 +110,9 @@ export declare const buildVisualModelGraph: (sources: readonly WorkspaceSource[]
110
110
  /**
111
111
  * Recovers the handoff and only then deletes the session, so cleanup can never
112
112
  * be the step that loses confirmed state. Returns `undefined` when the session
113
- * is already gone, which makes a repeated stop idempotent.
113
+ * is already gone, which makes a repeated stop idempotent, and when a previous
114
+ * cleanup already took the journal: there is no handoff left to recover, and
115
+ * refusing would strand a marked directory that only this call will remove.
114
116
  */
115
117
  export declare const removeVisualSession: (paths: VisualSessionPaths, includeTranscript?: boolean) => Promise<VisualHandoff | undefined>;
116
118
  /**
@@ -582,10 +582,66 @@ export const buildVisualModelGraph = (sources) => {
582
582
  graph: projectGraphForCanvas(compiled.graph, compiled.profileContext),
583
583
  };
584
584
  };
585
+ /**
586
+ * Whether a journal is still on disk. A cleanup that failed partway can leave
587
+ * a marked session without one, and that is a question about the filesystem
588
+ * rather than a corruption to report.
589
+ */
590
+ const journalExists = async (paths) => {
591
+ try {
592
+ await lstat(paths.journal);
593
+ return true;
594
+ }
595
+ catch {
596
+ return false;
597
+ }
598
+ };
599
+ /**
600
+ * Deletes a session directory one entry at a time, journal and marker last.
601
+ *
602
+ * `rm(..., { recursive: true })` rejects the moment one entry fails while the
603
+ * sibling removals it already started are still in flight; those stragglers go
604
+ * on deleting after the caller has already seen the rejection. A cleanup that
605
+ * failed could therefore still take the journal with it, leaving the retry that
606
+ * follows nothing to recover from. Removing entries in a decided order, and
607
+ * awaiting each one, keeps the two files a retry depends on - the journal it
608
+ * recovers from, and the marker that authorises deleting this directory at all
609
+ * - on disk for as long as any other trace of the session is. The marker goes
610
+ * last of the two: a directory that outlives its marker is one no later pass
611
+ * would agree to remove.
612
+ */
613
+ const removeSessionDirectory = async (paths) => {
614
+ let entries;
615
+ try {
616
+ entries = await readdir(paths.root, { withFileTypes: true });
617
+ }
618
+ catch (cause) {
619
+ // A session already gone is a completed cleanup, not a failed one.
620
+ if (typeof cause === 'object' &&
621
+ cause !== null &&
622
+ 'code' in cause &&
623
+ cause.code === 'ENOENT') {
624
+ return;
625
+ }
626
+ throw cause;
627
+ }
628
+ const journal = basename(paths.journal);
629
+ const marker = basename(paths.marker);
630
+ for (const entry of entries) {
631
+ if (entry.name === journal || entry.name === marker)
632
+ continue;
633
+ await rm(join(paths.root, entry.name), { recursive: true, force: true });
634
+ }
635
+ await rm(paths.journal, { force: true });
636
+ await rm(paths.marker, { force: true });
637
+ await rm(paths.root, { recursive: true, force: true });
638
+ };
585
639
  /**
586
640
  * Recovers the handoff and only then deletes the session, so cleanup can never
587
641
  * be the step that loses confirmed state. Returns `undefined` when the session
588
- * is already gone, which makes a repeated stop idempotent.
642
+ * is already gone, which makes a repeated stop idempotent, and when a previous
643
+ * cleanup already took the journal: there is no handoff left to recover, and
644
+ * refusing would strand a marked directory that only this call will remove.
589
645
  */
590
646
  export const removeVisualSession = async (paths, includeTranscript = false) => {
591
647
  let entry;
@@ -599,8 +655,12 @@ export const removeVisualSession = async (paths, includeTranscript = false) => {
599
655
  if (!entry.isDirectory()) {
600
656
  throw storeError('YMVS125', `Session root "${paths.root}" is not a directory`);
601
657
  }
602
- const handoff = await recoverVisualSession(paths, includeTranscript);
603
- await rm(paths.root, { recursive: true, force: true });
658
+ // The marker authorises the deletion whether or not a journal survived it.
659
+ await readSessionMarker(paths);
660
+ const handoff = (await journalExists(paths))
661
+ ? await recoverVisualSession(paths, includeTranscript)
662
+ : undefined;
663
+ await removeSessionDirectory(paths);
604
664
  await syncDirectory(dirname(paths.root));
605
665
  forget(paths);
606
666
  return handoff;
@@ -683,8 +743,9 @@ export const pruneStaleVisualSessions = async (baseDir, now, limit = VISUAL_SESS
683
743
  stale.sort((left, right) => left.activeAt - right.activeAt || left.root.localeCompare(right.root));
684
744
  const removed = [];
685
745
  for (const candidate of stale.slice(0, Math.max(0, limit))) {
686
- await rm(candidate.root, { recursive: true, force: true });
687
- forget(visualSessionPaths(candidate.root));
746
+ const paths = visualSessionPaths(candidate.root);
747
+ await removeSessionDirectory(paths);
748
+ forget(paths);
688
749
  removed.push(candidate.root);
689
750
  }
690
751
  if (removed.length > 0) {
@@ -25,6 +25,13 @@ export interface VisualRenderedModel {
25
25
  readonly layouts: {
26
26
  readonly [projectionId: string]: VisualLayoutPositions;
27
27
  };
28
+ /**
29
+ * The sha256 of every workspace source this graph was compiled from, keyed by
30
+ * manifest-relative path — the same map `visual-model/v1` already requires of
31
+ * a canonical model (`YMVS112`), forwarded rather than dropped so the browser
32
+ * can state what it rendered when it asks for a commit.
33
+ */
34
+ readonly sourceDigests: Readonly<Record<string, string>>;
28
35
  }
29
36
  /**
30
37
  * One line of the conversation, as plain text.
@@ -2,16 +2,19 @@ import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
3
  import { isMap, isScalar, isSeq, parseDocument, stringify, } from 'yaml';
4
4
  import Ajv2020Module from 'ajv/dist/2020.js';
5
+ import { loadAdapterMapping } from './adapter-mapping.js';
5
6
  import { diagnosticJson, humanDiagnostics, usage, } from './cli-support.js';
6
7
  import { compileWorkspace } from './compiler.js';
7
8
  import { evaluateEvidence, loadEvidence } from './evidence.js';
9
+ import { loadProjection } from './projection.js';
8
10
  import { loadSourceDocument, locateSourcePath, } from './source-document.js';
11
+ import { declaredStateIds, rewriteSubjectReferences, scanSubjectReferences, } from './subject-references.js';
9
12
  import { loadWorkspaceManifest } from './workspace.js';
10
13
  import operationsSchema from '../schema/yarramate-operations.schema.json' with { type: 'json'
11
14
  };
12
15
  const Ajv2020 = Ajv2020Module.default;
13
16
  // `discriminator` routes a batch entry to the single branch its `op` names, so
14
- // one malformed operation reports one fault instead of nine near-misses.
17
+ // one malformed operation reports one fault instead of ten near-misses.
15
18
  const validateOperations = new Ajv2020({
16
19
  allErrors: true,
17
20
  discriminator: true,
@@ -302,6 +305,18 @@ export const applyOperations = (operations, workspace, cwd) => {
302
305
  // overlay — or an observation aimed at a compiler document — is rejected
303
306
  // before anything is touched.
304
307
  const workspaceEvidence = new Map(resolvedWorkspace.evidence.map((path) => [resolve(cwd, path), path]));
308
+ // A rename re-points references, and references live in four kinds of file,
309
+ // so the write set is wider than the two above. Projections and adapter
310
+ // mappings are never an operation's own target — they are only ever carried
311
+ // along by a rename — but they are written, so they carry their manifest
312
+ // path for the touched-document list and their group for the walker.
313
+ const referenceFiles = [
314
+ ['document', resolvedWorkspace.documents],
315
+ ['projection', resolvedWorkspace.projections],
316
+ ['evidence', resolvedWorkspace.evidence],
317
+ ['adapter-mapping', resolvedWorkspace.adapterMappings],
318
+ ].flatMap(([group, paths]) => paths.map((path) => ({ absolute: resolve(cwd, path), path, group })));
319
+ const referenceFileOf = new Map(referenceFiles.map((file) => [file.absolute, file]));
305
320
  const candidates = new Map();
306
321
  const counts = {
307
322
  addedConcepts: 0,
@@ -310,14 +325,19 @@ export const applyOperations = (operations, workspace, cwd) => {
310
325
  updatedRelationships: 0,
311
326
  deletedConcepts: 0,
312
327
  deletedRelationships: 0,
328
+ renamedConcepts: 0,
329
+ renamedRelationships: 0,
313
330
  addedObservations: 0,
314
331
  updatedObservations: 0,
315
332
  deletedObservations: 0,
316
333
  };
317
334
  const deletions = [];
318
- const locateOperation = (index, message) => ({
335
+ // Addresses this batch moved off, so the residue walk below can prove none of
336
+ // them survived anywhere.
337
+ const renames = [];
338
+ const locateOperation = (index, message, code = 'YM912') => ({
319
339
  severity: 'error',
320
- code: 'YM912',
340
+ code,
321
341
  message,
322
342
  ...locateSourcePath(operationsPath, yaml, lineCounter, ['operations', index, 'document'], `/operations/${index}/document`),
323
343
  });
@@ -365,6 +385,72 @@ export const applyOperations = (operations, workspace, cwd) => {
365
385
  counts.deletedRelationships += 1;
366
386
  }
367
387
  }
388
+ else if (operation.op === 'rename-concept' ||
389
+ operation.op === 'rename-relationship') {
390
+ const collection = operation.op === 'rename-concept' ? 'concepts' : 'relationships';
391
+ const id = operation.op === 'rename-concept'
392
+ ? operation.concept.id
393
+ : operation.relationship.id;
394
+ if (itemMap(source, collection, id) === undefined) {
395
+ return failed([
396
+ locate(`Operation ${index} renames "${id}", which does not exist in ${operation.document}`),
397
+ ]);
398
+ }
399
+ // A rename that does not move the address would report every reference to
400
+ // it as residue below, which reads as a rewrite fault rather than what it
401
+ // is. Nothing would be written either, so `renamedConcepts: 1` over an
402
+ // empty document list would be a false receipt.
403
+ if (operation.to === id) {
404
+ return failed([
405
+ locate(`Operation ${index} renames "${id}" to itself, so no address moves`),
406
+ ]);
407
+ }
408
+ // A state shares the `document#local` spelling with a subject but not the
409
+ // id space. A collision on either end would make one address name two
410
+ // things, so it is refused rather than re-pointed by guess.
411
+ const states = declaredStateIds(source);
412
+ const collision = states.includes(id)
413
+ ? id
414
+ : states.includes(operation.to)
415
+ ? operation.to
416
+ : undefined;
417
+ if (collision !== undefined) {
418
+ return failed([
419
+ locate(`Operation ${index} renames "${id}" to "${operation.to}", but ${operation.document} declares a state "${collision}" — one address would name two things`),
420
+ ]);
421
+ }
422
+ const { documentId } = scanSubjectReferences(source, 'document');
423
+ const rename = {
424
+ from: `${documentId}#${id}`,
425
+ to: `${documentId}#${operation.to}`,
426
+ };
427
+ // Total within the workspace: the declaration and every declarative
428
+ // reference to it move in this one batch, so nothing is left addressing an
429
+ // id that stopped existing. Staged text is the input, so a second rename
430
+ // in the same batch reads the first one's result.
431
+ for (const file of referenceFiles) {
432
+ const before = candidates.get(file.absolute) ?? readFileSync(file.absolute, 'utf8');
433
+ const rewrite = rewriteSubjectReferences(before, file.group, rename);
434
+ if (!rewrite.ok) {
435
+ return failed([
436
+ locate(`Operation ${index} cannot move "${rename.from}": ${file.path} holds ${rewrite.aliases.length === 1 ? 'an alias' : 'aliases'} at ${rewrite.aliases.join(', ')}, which the rewrite cannot re-point`),
437
+ ]);
438
+ }
439
+ if (rewrite.source !== before) {
440
+ candidates.set(file.absolute, rewrite.source);
441
+ }
442
+ }
443
+ // The target document's own declaration moved in that same walk, so the
444
+ // staged text is the authority from here on.
445
+ source = candidates.get(absolute) ?? source;
446
+ renames.push({ index, from: rename.from });
447
+ if (operation.op === 'rename-concept') {
448
+ counts.renamedConcepts += 1;
449
+ }
450
+ else {
451
+ counts.renamedRelationships += 1;
452
+ }
453
+ }
368
454
  else if (operation.op === 'add-observation') {
369
455
  const address = observationAddress(operation.observation);
370
456
  const matches = byObservation(operation.observation);
@@ -604,11 +690,50 @@ export const applyOperations = (operations, workspace, cwd) => {
604
690
  if (!evaluation.ok)
605
691
  return failed(evaluation.diagnostics);
606
692
  }
693
+ // Totality is checked, not trusted: no file this batch touched may still name
694
+ // an address a rename moved off. A splice that landed text re-parsing to the
695
+ // old value refuses here rather than shipping a reference to an id that
696
+ // stopped existing. A position the enumeration omits is invisible to this
697
+ // walk - the schema-derived completeness test is what covers that.
698
+ if (renames.length > 0) {
699
+ const movedFrom = new Map(renames.map(({ from, index }) => [from, index]));
700
+ const residue = referenceFiles.flatMap((file) => {
701
+ const source = candidates.get(file.absolute);
702
+ if (source === undefined)
703
+ return [];
704
+ return scanSubjectReferences(source, file.group)
705
+ .hits.filter((hit) => movedFrom.has(hit.address))
706
+ .map((hit) => {
707
+ const index = movedFrom.get(hit.address);
708
+ return locateOperation(index, `Operation ${index} moved "${hit.address}", but ${file.path} still names it at ${hit.pointer}`, 'YM913');
709
+ });
710
+ });
711
+ if (residue.length > 0)
712
+ return failed(residue);
713
+ }
714
+ // Projections and adapter mappings are not `compileWorkspace` input, so a
715
+ // rewrite that produced an unreadable address is caught here rather than by
716
+ // the next command to read the file.
717
+ for (const file of referenceFiles) {
718
+ const source = candidates.get(file.absolute);
719
+ if (source === undefined)
720
+ continue;
721
+ if (file.group === 'projection') {
722
+ const loaded = loadProjection({ path: file.path, source });
723
+ if (!loaded.ok)
724
+ return failed(loaded.diagnostics);
725
+ }
726
+ else if (file.group === 'adapter-mapping') {
727
+ const loaded = loadAdapterMapping({ path: file.path, source });
728
+ if (!loaded.ok)
729
+ return failed(loaded.diagnostics);
730
+ }
731
+ }
607
732
  for (const [absolute, source] of candidates) {
608
733
  writeFileSync(absolute, source, 'utf8');
609
734
  }
610
735
  const touched = [...candidates.keys()]
611
- .map((absolute) => workspaceDocuments.get(absolute) ?? workspaceEvidence.get(absolute))
736
+ .map((absolute) => referenceFileOf.get(absolute).path)
612
737
  .sort();
613
738
  return {
614
739
  ok: true,
@@ -658,15 +783,9 @@ export function runApplyCommand(options, cwd) {
658
783
  stderr: '',
659
784
  };
660
785
  }
661
- const applied = result.applied.addedConcepts +
662
- result.applied.addedRelationships +
663
- result.applied.updatedConcepts +
664
- result.applied.updatedRelationships +
665
- result.applied.deletedConcepts +
666
- result.applied.deletedRelationships +
667
- result.applied.addedObservations +
668
- result.applied.updatedObservations +
669
- result.applied.deletedObservations;
786
+ // Every counter, summed by iteration rather than by hand, so a new
787
+ // operation kind cannot silently report zero work.
788
+ const applied = Object.values(result.applied).reduce((total, count) => total + count, 0);
670
789
  return {
671
790
  exitCode: 0,
672
791
  stdout: `Applied ${applied} operation${applied === 1 ? '' : 's'} to ${result.documents.join(', ')}\n`,
@@ -70,6 +70,44 @@ export type ContextualCompilationResult = {
70
70
  readonly ok: false;
71
71
  readonly diagnostics: readonly Diagnostic[];
72
72
  };
73
+ /**
74
+ * One parsed workspace source, retained by a {@link CompilationCache}. Hold it
75
+ * and hand it back; never construct one. `value` is the composed YAML of
76
+ * `source` and nothing else, so an entry is a pure function of its text.
77
+ */
78
+ export interface ParsedWorkspaceSource {
79
+ readonly source: string;
80
+ readonly kind: 'profile' | 'document';
81
+ readonly value: unknown;
82
+ readonly schemaDiagnostics: readonly Diagnostic[];
83
+ /**
84
+ * Line/column already resolved for this text, keyed by YAML path. An
85
+ * internal memo of the compiler, filled as positions are asked for; a
86
+ * consumer that mutates it corrupts the `source` of later claims.
87
+ */
88
+ readonly positions: Map<string, ResolvedPosition>;
89
+ }
90
+ /**
91
+ * Opaque parse cache returned by {@link compileWorkspaceIncremental} and
92
+ * accepted by its next call. Reuse is decided by exact source-text equality,
93
+ * not by a caller-declared change set and not by a digest, so a stale cache
94
+ * cannot change the compiled output - it can only fail to save work.
95
+ */
96
+ export interface CompilationCache {
97
+ readonly sources: ReadonlyMap<string, ParsedWorkspaceSource>;
98
+ }
99
+ export type IncrementalCompilationResult = ({
100
+ readonly ok: true;
101
+ readonly graph: SemanticGraph;
102
+ readonly profileContext: ResolvedProfileContext;
103
+ } | {
104
+ readonly ok: false;
105
+ readonly diagnostics: readonly Diagnostic[];
106
+ }) & {
107
+ /** False when every source had to be parsed, e.g. the first call. */
108
+ readonly incremental: boolean;
109
+ readonly cache: CompilationCache;
110
+ };
73
111
  export declare const ATTESTATION_PREDICATE_PREFIX = "yarramate/attestation/";
74
112
  export declare const attestationClaimValue: (attestation: {
75
113
  readonly by: string;
@@ -88,5 +126,20 @@ export interface ConstraintExpectsParts {
88
126
  readonly value: string;
89
127
  }
90
128
  export declare const parseConstraintExpectsValue: (value: string) => ConstraintExpectsParts | undefined;
129
+ interface ResolvedPosition {
130
+ readonly line: number;
131
+ readonly col: number;
132
+ }
91
133
  export declare function compileWorkspace(sources: readonly WorkspaceSource[]): CompilationResult;
92
134
  export declare const compileWorkspaceWithProfileContext: (sources: readonly WorkspaceSource[]) => ContextualCompilationResult;
135
+ /**
136
+ * Compiles the whole workspace, reusing the YAML parse of every source whose
137
+ * text is unchanged since `previous`. The compiled output is byte-identical to
138
+ * {@link compileWorkspaceWithProfileContext} for the same sources: the cache
139
+ * holds parse results only, and every cross-document decision is re-derived.
140
+ *
141
+ * Hold the returned `cache` and pass it to the next call. It retains one
142
+ * composed value per current source and drops sources that left the workspace.
143
+ */
144
+ export declare const compileWorkspaceIncremental: (sources: readonly WorkspaceSource[], previous?: CompilationCache) => IncrementalCompilationResult;
145
+ export {};