@h1v35/hivex 0.2.1 → 0.3.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 (31) hide show
  1. package/README.md +53 -13
  2. package/docs/CONTEXT.md +8 -0
  3. package/docs/README.md +5 -2
  4. package/docs/adr/0011-shared-knowledge-and-selective-history.md +4 -0
  5. package/docs/adr/0012-project-foundation-and-workflow.md +21 -0
  6. package/docs/{engineering.md → guidelines/engineering.md} +13 -11
  7. package/docs/guidelines/triage-labels.md +29 -0
  8. package/docs/procedures/issue-tracker.md +21 -0
  9. package/docs/procedures/self-hosted-runner.md +7 -0
  10. package/package.json +6 -2
  11. package/skills/hivex/SKILL.md +22 -42
  12. package/skills/hivex/assets/project/AGENTS.md +11 -0
  13. package/skills/hivex/assets/project/docs/CONTEXT.md +8 -0
  14. package/skills/hivex/assets/project/docs/PRD.md +16 -0
  15. package/skills/hivex/assets/project/docs/README.md +18 -0
  16. package/skills/hivex/assets/project/docs/adr/README.md +5 -0
  17. package/skills/hivex/assets/project/docs/guidelines/engineering.md +37 -0
  18. package/skills/hivex/assets/project/docs/guidelines/triage-labels.md +29 -0
  19. package/skills/hivex/assets/project/docs/procedures/issue-tracker.md +21 -0
  20. package/skills/hivex/assets/project/hivex.json +8 -0
  21. package/skills/hivex/references/markdown.md +13 -7
  22. package/skills/hivex-design/SKILL.md +26 -0
  23. package/skills/hivex-document/SKILL.md +36 -0
  24. package/skills/hivex-git/SKILL.md +42 -0
  25. package/skills/hivex-git/assets/labels.json +152 -0
  26. package/skills/hivex-implement/SKILL.md +28 -0
  27. package/skills/hivex-review/SKILL.md +26 -0
  28. package/src/cli.ts +6 -1
  29. package/src/project-initialization.ts +319 -0
  30. package/src/snapshot-command.ts +30 -5
  31. package/src/source-relocation.ts +222 -0
@@ -0,0 +1,319 @@
1
+ import { lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseArgs } from 'node:util';
4
+ import { HivexError } from './errors.ts';
5
+
6
+ interface TemplateFile {
7
+ bytes: Buffer;
8
+ path: string;
9
+ }
10
+
11
+ interface FileOperation {
12
+ absolutePath: string;
13
+ bytes: Buffer;
14
+ path: string;
15
+ state: 'created' | 'preserved' | 'updated';
16
+ }
17
+
18
+ interface InitReport {
19
+ command: 'init';
20
+ created: string[];
21
+ modelCalls: 0;
22
+ preserved: string[];
23
+ updated: string[];
24
+ }
25
+
26
+ const ignoreRules = ['!/.hivex/', '/.hivex/*', '!/.hivex/graph.json'];
27
+ const assetsPath = path.join(import.meta.dirname, '../skills/hivex/assets/project');
28
+ const assetsUnavailableCode = 'INIT_ASSETS_UNAVAILABLE';
29
+ const assetsInvalidCode = 'INIT_ASSETS_INVALID';
30
+
31
+ const fail = function fail(code: string, message: string): never {
32
+ throw new HivexError({ code, message });
33
+ };
34
+
35
+ const safeRelativePath = function safeRelativePath(relativePath: string) {
36
+ const normalized = relativePath.replaceAll('\\', '/');
37
+ const parts = normalized.split('/');
38
+ const hasInvalidPath = [
39
+ !relativePath,
40
+ path.isAbsolute(relativePath),
41
+ normalized.startsWith('/'),
42
+ normalized.includes('\u{0}'),
43
+ relativePath.includes('\\'),
44
+ ].includes(true);
45
+ if (hasInvalidPath) {
46
+ return fail('INVALID_DESTINATION', 'Initialization paths must be relative project files');
47
+ }
48
+ if (parts.some((part) => ['', '.', '..'].includes(part))) {
49
+ return fail('INVALID_DESTINATION', 'Initialization paths must be relative project files');
50
+ }
51
+ return normalized;
52
+ };
53
+
54
+ const errorMessage = function errorMessage(error: unknown) {
55
+ return Error.isError(error) ? error.message : 'unknown error';
56
+ };
57
+
58
+ const templateEntries = function templateEntries(directory: string) {
59
+ try {
60
+ return readdirSync(directory, { withFileTypes: true }).toSorted((left, right) =>
61
+ left.name.localeCompare(right.name)
62
+ );
63
+ } catch (error) {
64
+ return fail(assetsUnavailableCode, `Unable to read project templates: ${errorMessage(error)}`);
65
+ }
66
+ };
67
+
68
+ const readTemplateFile = function readTemplateFile(
69
+ absolutePath: string,
70
+ relativePath: string
71
+ ): TemplateFile {
72
+ try {
73
+ return { bytes: readFileSync(absolutePath), path: relativePath };
74
+ } catch (error) {
75
+ return fail(
76
+ assetsUnavailableCode,
77
+ `Unable to read project template ${relativePath}: ${errorMessage(error)}`
78
+ );
79
+ }
80
+ };
81
+
82
+ const readTemplateFiles = function readTemplateFiles(
83
+ directory: string,
84
+ relativeDirectory = ''
85
+ ): TemplateFile[] {
86
+ const files: TemplateFile[] = [];
87
+ const visit = function visit(current: string, currentRelativeDirectory: string) {
88
+ for (const entry of templateEntries(current)) {
89
+ const relativePath = safeRelativePath(
90
+ currentRelativeDirectory ? `${currentRelativeDirectory}/${entry.name}` : entry.name
91
+ );
92
+ const absolutePath = path.join(current, entry.name);
93
+ if (entry.isSymbolicLink()) {
94
+ fail(assetsInvalidCode, `Project template must not be a symlink: ${relativePath}`);
95
+ } else if (entry.isDirectory()) {
96
+ visit(absolutePath, relativePath);
97
+ } else if (entry.isFile()) {
98
+ files.push(readTemplateFile(absolutePath, relativePath));
99
+ } else {
100
+ fail(assetsInvalidCode, `Project template is not a regular file: ${relativePath}`);
101
+ }
102
+ }
103
+ };
104
+ visit(directory, relativeDirectory);
105
+ return files;
106
+ };
107
+
108
+ const projectRoot = function projectRoot(requested: string) {
109
+ if (!requested.trim()) {
110
+ return fail('INVALID_ROOT', 'Project root must be a non-empty path');
111
+ }
112
+ const root = path.resolve(requested);
113
+ let stat;
114
+ try {
115
+ stat = lstatSync(root);
116
+ } catch (error) {
117
+ return fail('INVALID_ROOT', `Project root is not readable: ${errorMessage(error)}`);
118
+ }
119
+ if (stat.isSymbolicLink()) {
120
+ return fail('INVALID_ROOT', 'Project root must not be a symlink');
121
+ }
122
+ if (!stat.isDirectory()) {
123
+ return fail('INVALID_ROOT', 'Project root must be a directory');
124
+ }
125
+ return root;
126
+ };
127
+
128
+ const destination = function destination(root: string, relativePath: string) {
129
+ const absolutePath = path.resolve(root, relativePath);
130
+ const relative = path.relative(root, absolutePath);
131
+ if (
132
+ !relative ||
133
+ relative === '..' ||
134
+ relative.startsWith(`..${path.sep}`) ||
135
+ path.isAbsolute(relative)
136
+ ) {
137
+ return fail(
138
+ 'INVALID_DESTINATION',
139
+ `Initialization path escapes the project root: ${relativePath}`
140
+ );
141
+ }
142
+
143
+ let current = root;
144
+ const parts = relative.split(path.sep);
145
+ for (const [index, part] of parts.entries()) {
146
+ current = path.join(current, part);
147
+ let stat;
148
+ try {
149
+ stat = lstatSync(current, { throwIfNoEntry: false });
150
+ } catch (error) {
151
+ return fail(
152
+ 'INVALID_DESTINATION',
153
+ `Unable to inspect initialization path ${relativePath}: ${errorMessage(error)}`
154
+ );
155
+ }
156
+ if (stat === undefined) {
157
+ return { absolutePath, exists: false };
158
+ }
159
+ if (stat.isSymbolicLink()) {
160
+ return fail(
161
+ 'INVALID_DESTINATION',
162
+ `Initialization path must not use symlinks: ${relativePath}`
163
+ );
164
+ }
165
+ const isFinal = index === parts.length - 1;
166
+ if ((!isFinal && !stat.isDirectory()) || (isFinal && !stat.isFile())) {
167
+ return fail(
168
+ 'INVALID_DESTINATION',
169
+ `Initialization path is not a regular file: ${relativePath}`
170
+ );
171
+ }
172
+ }
173
+ return { absolutePath, exists: true };
174
+ };
175
+
176
+ const validateNestedIgnore = function validateNestedIgnore(root: string) {
177
+ const relativePath = '.hivex/.gitignore';
178
+ const target = destination(root, relativePath);
179
+ if (!target.exists) {
180
+ return;
181
+ }
182
+ let text: string;
183
+ try {
184
+ text = readFileSync(target.absolutePath, 'utf-8');
185
+ } catch (error) {
186
+ throw new HivexError({
187
+ code: 'INIT_READ_FAILED',
188
+ message: `Unable to read ${relativePath}: ${errorMessage(error)}`,
189
+ });
190
+ }
191
+ if (text.split(/\r?\n/u).some((line) => line.trim() !== '' && !line.startsWith('#'))) {
192
+ fail(
193
+ 'INIT_IGNORE_CONFLICT',
194
+ '.hivex/.gitignore contains rules that can override snapshot visibility or local state privacy.'
195
+ );
196
+ }
197
+ };
198
+
199
+ const hasFinalIgnoreRules = function hasFinalIgnoreRules(text: string) {
200
+ const lines = text.split(/\r?\n/u);
201
+ while (lines.at(-1) === '') {
202
+ lines.pop();
203
+ }
204
+ const start = lines.length - ignoreRules.length;
205
+ return start >= 0 && ignoreRules.every((rule, index) => lines[start + index] === rule);
206
+ };
207
+
208
+ const ignoreUpdate = function ignoreUpdate(existing: Buffer | null) {
209
+ const block = `${ignoreRules.join('\n')}\n`;
210
+ if (existing === null) {
211
+ return Buffer.from(block);
212
+ }
213
+ const text = existing.toString('utf-8');
214
+ if (hasFinalIgnoreRules(text)) {
215
+ return null;
216
+ }
217
+ const separator = text.length === 0 || text.endsWith('\n') ? '' : '\n';
218
+ return Buffer.concat([existing, Buffer.from(`${separator}${block}`)]);
219
+ };
220
+
221
+ const templateOperations = function templateOperations(root: string, templates: TemplateFile[]) {
222
+ return templates.map(({ bytes, path: relativePath }) => {
223
+ const target = destination(root, relativePath);
224
+ return {
225
+ absolutePath: target.absolutePath,
226
+ bytes,
227
+ path: relativePath,
228
+ state: target.exists ? 'preserved' : 'created',
229
+ } satisfies FileOperation;
230
+ });
231
+ };
232
+
233
+ const ignoreOperation = function ignoreOperation(root: string) {
234
+ const relativePath = '.gitignore';
235
+ const target = destination(root, relativePath);
236
+ let existing: Buffer | null = null;
237
+ if (target.exists) {
238
+ try {
239
+ existing = readFileSync(target.absolutePath);
240
+ } catch (error) {
241
+ return fail('INIT_READ_FAILED', `Unable to read ${relativePath}: ${errorMessage(error)}`);
242
+ }
243
+ }
244
+ const bytes = ignoreUpdate(existing);
245
+ let state: FileOperation['state'] = 'created';
246
+ if (target.exists) {
247
+ state = bytes === null ? 'preserved' : 'updated';
248
+ }
249
+ return {
250
+ absolutePath: target.absolutePath,
251
+ bytes: bytes ?? existing ?? Buffer.alloc(0),
252
+ path: relativePath,
253
+ state,
254
+ } satisfies FileOperation;
255
+ };
256
+
257
+ const writeOperations = function writeOperations(operations: FileOperation[]) {
258
+ for (const operation of operations) {
259
+ if (operation.state === 'preserved') {
260
+ continue;
261
+ }
262
+ mkdirSync(path.dirname(operation.absolutePath), { recursive: true });
263
+ if (operation.state === 'created') {
264
+ writeFileSync(operation.absolutePath, operation.bytes, { flag: 'wx', mode: 0o644 });
265
+ } else {
266
+ writeFileSync(operation.absolutePath, operation.bytes);
267
+ }
268
+ }
269
+ };
270
+
271
+ const report = function report(operations: FileOperation[]): InitReport {
272
+ const paths = function paths(state: FileOperation['state']) {
273
+ return operations
274
+ .filter((operation) => operation.state === state)
275
+ .map((operation) => operation.path)
276
+ .toSorted((left, right) => left.localeCompare(right));
277
+ };
278
+ return {
279
+ command: 'init',
280
+ created: paths('created'),
281
+ modelCalls: 0,
282
+ preserved: paths('preserved'),
283
+ updated: paths('updated'),
284
+ };
285
+ };
286
+
287
+ const parseInitArguments = function parseInitArguments(argumentsList: string[]) {
288
+ try {
289
+ return parseArgs({
290
+ allowPositionals: true,
291
+ args: argumentsList,
292
+ options: { root: { type: 'string' } },
293
+ strict: true,
294
+ });
295
+ } catch (error) {
296
+ return fail(
297
+ 'INVALID_ARGUMENT',
298
+ Error.isError(error) ? error.message : 'Invalid init arguments'
299
+ );
300
+ }
301
+ };
302
+
303
+ export const projectInitializationCommand = function projectInitializationCommand(
304
+ argumentsList: string[]
305
+ ) {
306
+ const parsed = parseInitArguments(argumentsList);
307
+ if (parsed.positionals.length !== 1 || parsed.positionals[0] !== 'init') {
308
+ return fail('INVALID_ARGUMENT', 'Use init [--root <project>]');
309
+ }
310
+ const root = projectRoot(parsed.values.root ?? process.cwd());
311
+ validateNestedIgnore(root);
312
+ const templates = readTemplateFiles(assetsPath);
313
+ if (!templates.length) {
314
+ return fail('INIT_ASSETS_UNAVAILABLE', 'No project templates are available');
315
+ }
316
+ const operations = [...templateOperations(root, templates), ignoreOperation(root)];
317
+ writeOperations(operations);
318
+ return report(operations);
319
+ };
@@ -5,19 +5,26 @@ import { ingestionUnits } from './ingestion-units.ts';
5
5
  import { KnowledgeStore } from './knowledge-store.ts';
6
6
  import { readKnowledgeSnapshot, writeKnowledgeSnapshot } from './knowledge-snapshot.ts';
7
7
  import { loadProject } from './documents.ts';
8
+ import { relocateSource } from './source-relocation.ts';
8
9
  import type { Graph } from './knowledge-model.ts';
9
10
  import type { Project } from './documents.ts';
11
+ import type { SourceRelocation } from './source-relocation.ts';
10
12
 
11
13
  const sourceVersion = function sourceVersion([document, version]: [string, string]) {
12
14
  return { document, version };
13
15
  };
14
16
 
17
+ const warningScopes = function warningScopes(warning: Graph['warnings'][number]) {
18
+ return typeof warning === 'string' ? [] : warning.scope;
19
+ };
20
+
15
21
  const sourceVersions = function sourceVersions(project: Project, graph: Graph) {
16
22
  const references = [
17
23
  ...Object.entries(graph.documents).map(sourceVersion),
18
24
  ...Object.values(graph.units),
19
25
  ...graph.decisions,
20
26
  ...graph.relationships.flatMap((edge) => edge.evidence),
27
+ ...graph.warnings.flatMap(warningScopes),
21
28
  ];
22
29
  const current = new Set<string>();
23
30
  const stale = new Set<string>();
@@ -68,6 +75,15 @@ const snapshotReport = function snapshotReport(project: Project, graph: Graph, o
68
75
  };
69
76
  };
70
77
 
78
+ const relocationReport = function relocationReport(project: Project, relocation: SourceRelocation) {
79
+ return {
80
+ ...snapshotReport(project, relocation.graph, 'relocate'),
81
+ from: { document: relocation.from, versions: relocation.fromVersions },
82
+ reused: relocation.reused,
83
+ to: { document: relocation.to, version: relocation.destinationVersion },
84
+ };
85
+ };
86
+
71
87
  export const snapshotCommand = function snapshotCommand(argumentsList: string[]) {
72
88
  const { positionals, values } = parseArgs({
73
89
  allowPositionals: true,
@@ -75,18 +91,27 @@ export const snapshotCommand = function snapshotCommand(argumentsList: string[])
75
91
  options: { root: { type: 'string' } },
76
92
  strict: true,
77
93
  });
78
- const [, operation] = positionals;
94
+ const [, operation, from, to] = positionals;
95
+ const isRelocate = operation === 'relocate';
96
+ const expectedPositionals = isRelocate ? 4 : 2;
97
+ const isValidOperation = operation === 'export' || operation === 'import' || isRelocate;
79
98
  if (
80
- positionals.length !== 2 ||
81
- positionals[0] !== 'snapshot' ||
82
- (operation !== 'export' && operation !== 'import')
99
+ !isValidOperation ||
100
+ positionals.length !== expectedPositionals ||
101
+ positionals[0] !== 'snapshot'
83
102
  ) {
84
103
  throw new HivexError({
85
104
  code: 'INVALID_ARGUMENT',
86
- message: 'Use snapshot export | import [--root <project>]',
105
+ message: 'Use snapshot export | import | relocate <from> <to> [--root <project>]',
87
106
  });
88
107
  }
89
108
  const project = loadProject(values.root ?? process.cwd());
109
+ if (isRelocate) {
110
+ using store = new KnowledgeStore(project.root, { update: true });
111
+ const relocation = relocateSource(store.graph(), project, from ?? '', to ?? '');
112
+ store.importGraph(relocation.graph);
113
+ return relocationReport(project, relocation);
114
+ }
90
115
  const incoming = operation === 'import' ? readKnowledgeSnapshot(project.root) : null;
91
116
  if (operation === 'import' && incoming === null) {
92
117
  throw new HivexError({
@@ -0,0 +1,222 @@
1
+ import { compareSerializedStrings } from './ordering.ts';
2
+ import { HivexError } from './errors.ts';
3
+ import { isMarkdownPath } from './markdown.ts';
4
+ import type { Project } from './documents.ts';
5
+ import type { Graph } from './knowledge-model.ts';
6
+
7
+ const protectedParts = new Set(['', '.', '..', '.git', '.hivex', 'node_modules']);
8
+
9
+ const isPortablePath = function isPortablePath(value: string) {
10
+ if (!isMarkdownPath(value) || value.startsWith('/')) {
11
+ return false;
12
+ }
13
+ if (value.includes('\\') || value.includes('\0')) {
14
+ return false;
15
+ }
16
+ return value.split('/').every((part) => !protectedParts.has(part));
17
+ };
18
+
19
+ const fail = function fail(code: string, message: string): never {
20
+ throw new HivexError({ code, message });
21
+ };
22
+
23
+ const relationshipVersions = function relationshipVersions(
24
+ relationship: Graph['relationships'][number],
25
+ document: string
26
+ ) {
27
+ return relationship.evidence
28
+ .filter((evidence) => evidence.document === document)
29
+ .flatMap((evidence) => (evidence.version === undefined ? [] : [evidence.version]));
30
+ };
31
+
32
+ const warningVersions = function warningVersions(
33
+ warning: Graph['warnings'][number],
34
+ document: string
35
+ ) {
36
+ if (typeof warning === 'string') {
37
+ return [];
38
+ }
39
+ return warning.scope.filter((scope) => scope.document === document).map((scope) => scope.version);
40
+ };
41
+
42
+ const sourceVersions = function sourceVersions(graph: Graph, document: string) {
43
+ const versions = [
44
+ graph.documents[document],
45
+ ...Object.values(graph.units)
46
+ .filter((unit) => unit.document === document)
47
+ .map((unit) => unit.version),
48
+ ...graph.decisions
49
+ .filter((decision) => decision.document === document)
50
+ .map((decision) => decision.version),
51
+ ...graph.relationships.flatMap((relationship) => relationshipVersions(relationship, document)),
52
+ ...graph.warnings.flatMap((warning) => warningVersions(warning, document)),
53
+ ].filter((version): version is string => version !== undefined);
54
+ return [...new Set(versions)].toSorted(compareSerializedStrings);
55
+ };
56
+
57
+ const hasKnowledge = function hasKnowledge(graph: Graph, document: string) {
58
+ if (graph.documents[document] !== undefined) {
59
+ return true;
60
+ }
61
+ if (Object.values(graph.units).some((unit) => unit.document === document)) {
62
+ return true;
63
+ }
64
+ if (graph.decisions.some((decision) => decision.document === document)) {
65
+ return true;
66
+ }
67
+ if (
68
+ graph.relationships.some((relationship) =>
69
+ relationship.evidence.some((evidence) => evidence.document === document)
70
+ )
71
+ ) {
72
+ return true;
73
+ }
74
+ return graph.warnings.some(
75
+ (warning) =>
76
+ typeof warning !== 'string' && warning.scope.some((scope) => scope.document === document)
77
+ );
78
+ };
79
+
80
+ const selectedDocument = function selectedDocument(project: Project, id: string) {
81
+ return project.documents.find((document) => document.id === id);
82
+ };
83
+
84
+ const mapCitation = function mapCitation<T extends { document: string }>(
85
+ citation: T,
86
+ from: string,
87
+ to: string
88
+ ) {
89
+ return citation.document === from ? { ...citation, document: to } : citation;
90
+ };
91
+
92
+ const mapUnitId = function mapUnitId(id: string, from: string, to: string) {
93
+ const prefix = `${from}:`;
94
+ return id.startsWith(prefix) ? `${to}${id.slice(from.length)}` : id;
95
+ };
96
+
97
+ const mapWarning = function mapWarning(
98
+ warning: Graph['warnings'][number],
99
+ from: string,
100
+ to: string
101
+ ): Graph['warnings'][number] {
102
+ return typeof warning === 'string'
103
+ ? warning
104
+ : { ...warning, scope: warning.scope.map((scope) => mapCitation(scope, from, to)) };
105
+ };
106
+
107
+ const mapDecision = function mapDecision(
108
+ decision: Graph['decisions'][number],
109
+ from: string,
110
+ to: string
111
+ ) {
112
+ return { ...decision, document: decision.document === from ? to : decision.document };
113
+ };
114
+
115
+ const mapRelationship = function mapRelationship(
116
+ relationship: Graph['relationships'][number],
117
+ from: string,
118
+ to: string
119
+ ) {
120
+ return {
121
+ ...relationship,
122
+ evidence: relationship.evidence.map((evidence) => mapCitation(evidence, from, to)),
123
+ };
124
+ };
125
+
126
+ const mapUnits = function mapUnits(
127
+ graph: Graph,
128
+ from: string,
129
+ to: string,
130
+ isCoverageRelocated: boolean
131
+ ) {
132
+ const entries = Object.entries(graph.units).flatMap(([id, unit]) => {
133
+ if (!isCoverageRelocated && (unit.document === from || unit.document === to)) {
134
+ return [];
135
+ }
136
+ if (unit.document !== from) {
137
+ return [[id, unit] as const];
138
+ }
139
+ const relocatedId = mapUnitId(id, from, to);
140
+ return [[relocatedId, { ...unit, document: to }] as const];
141
+ });
142
+ return Object.fromEntries(entries);
143
+ };
144
+
145
+ const mapDocuments = function mapDocuments(
146
+ graph: Graph,
147
+ from: string,
148
+ to: string,
149
+ options: { isCoverageRelocated: boolean; sourceVersion: string | undefined }
150
+ ) {
151
+ const entries = Object.entries(graph.documents).filter(([id]) => id !== from && id !== to);
152
+ if (options.isCoverageRelocated && options.sourceVersion !== undefined) {
153
+ entries.push([to, options.sourceVersion]);
154
+ }
155
+ return Object.fromEntries(entries);
156
+ };
157
+
158
+ export interface SourceRelocation {
159
+ destinationVersion: string;
160
+ from: string;
161
+ fromVersions: string[];
162
+ graph: Graph;
163
+ reused: boolean;
164
+ to: string;
165
+ }
166
+
167
+ export const relocateSource = function relocateSource(
168
+ graph: Graph,
169
+ project: Project,
170
+ from: string,
171
+ to: string
172
+ ): SourceRelocation {
173
+ if (from === to || !isPortablePath(from) || !isPortablePath(to)) {
174
+ fail(
175
+ 'INVALID_ARGUMENT',
176
+ 'Source relocation paths must be distinct project-local Markdown files'
177
+ );
178
+ }
179
+ if (!hasKnowledge(graph, from)) {
180
+ fail('SOURCE_NOT_FOUND', `Source is not present in knowledge: ${from}`);
181
+ }
182
+ if (selectedDocument(project, from) !== undefined) {
183
+ fail('INVALID_ARGUMENT', `Source must no longer be selected: ${from}`);
184
+ }
185
+ const destination = project.currentDocuments.find((document) => document.id === to);
186
+ if (destination === undefined) {
187
+ return fail('SOURCE_NOT_FOUND', `Destination is not a selected current Markdown source: ${to}`);
188
+ }
189
+ const versions = sourceVersions(graph, from);
190
+ const hasDestinationKnowledge = hasKnowledge(graph, to);
191
+ const hasUnversionedEvidence = graph.relationships
192
+ .flatMap((relationship) => relationship.evidence)
193
+ .some((evidence) => evidence.document === from && evidence.version === undefined);
194
+ const isReused =
195
+ !hasDestinationKnowledge &&
196
+ !hasUnversionedEvidence &&
197
+ versions.length > 0 &&
198
+ versions.every((version) => version === destination.hash);
199
+ const documents = mapDocuments(graph, from, to, {
200
+ isCoverageRelocated: isReused,
201
+ sourceVersion: graph.documents[from],
202
+ });
203
+ const units = mapUnits(graph, from, to, isReused);
204
+ const relocated: Graph = {
205
+ ...graph,
206
+ decisions: graph.decisions.map((decision) => mapDecision(decision, from, to)),
207
+ documents,
208
+ relationships: graph.relationships.map((relationship) =>
209
+ mapRelationship(relationship, from, to)
210
+ ),
211
+ units,
212
+ warnings: graph.warnings.map((warning) => mapWarning(warning, from, to)),
213
+ };
214
+ return {
215
+ destinationVersion: destination.hash,
216
+ from,
217
+ fromVersions: versions,
218
+ graph: relocated,
219
+ reused: isReused,
220
+ to,
221
+ };
222
+ };