sortie-dogs 0.1.1

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,371 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, mkdir, open, readFile, rm, rmdir } from "node:fs/promises";
4
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
5
+ const assets = await import(`../runtime-assets.${import.meta.url.endsWith(".ts") ? "ts" : "js"}`);
6
+ const { runtimeAssets } = assets;
7
+ const OPEN_CODE_DIRECTORY = ".opencode";
8
+ const VERSION_MARKER = `${OPEN_CODE_DIRECTORY}/sortie-dogs.version`;
9
+ const LEGACY_RUNTIME_ASSETS = [
10
+ {
11
+ relativePath: ".opencode/agent/coordinator-mk2a2.md",
12
+ markerVersions: ["0.2.0-card04"],
13
+ sha256: "464e58c4973073937493d6a2205dc8594236b38d83cf63a8bba2965afe7c011c",
14
+ },
15
+ {
16
+ relativePath: ".opencode/agent/sol-worker-mk2a2.md",
17
+ markerVersions: ["0.2.0-card04"],
18
+ sha256: "32391b899a2b1a39bcd03653adfcfe9e5d7343e1494cab020b16e5784b8bc0ba",
19
+ },
20
+ ];
21
+ export class ProjectInitializationError extends Error {
22
+ code;
23
+ constructor(code, message, options) {
24
+ super(message, options);
25
+ this.name = "ProjectInitializationError";
26
+ this.code = code;
27
+ }
28
+ }
29
+ function assetVersion() {
30
+ const versions = new Set(runtimeAssets.map(({ version }) => version));
31
+ if (versions.size !== 1) {
32
+ throw new ProjectInitializationError("write-failed", "Runtime assets do not share one version.");
33
+ }
34
+ return versions.values().next().value;
35
+ }
36
+ function safeAssetPath(installPath) {
37
+ const unified = installPath.replaceAll("\\", "/");
38
+ const segments = unified.split("/");
39
+ if (isAbsolute(installPath) || /^[A-Za-z]:/u.test(unified) ||
40
+ segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
41
+ throw new ProjectInitializationError("unsafe-path", "A runtime asset has an unsafe install path.");
42
+ }
43
+ return `${OPEN_CODE_DIRECTORY}/${unified}`;
44
+ }
45
+ function parseMarker(content) {
46
+ const match = /^([^\r\n]+)\r?\n$/u.exec(content);
47
+ if (match === null || parseVersion(match[1]) === undefined) {
48
+ throw new ProjectInitializationError("conflict", "The Sortie-dogs version marker is invalid.");
49
+ }
50
+ return match[1];
51
+ }
52
+ function parseVersion(value) {
53
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(value);
54
+ if (match === null)
55
+ return undefined;
56
+ return {
57
+ major: Number(match[1]),
58
+ minor: Number(match[2]),
59
+ patch: Number(match[3]),
60
+ prerelease: match[4]?.split(".") ?? [],
61
+ };
62
+ }
63
+ function comparePrerelease(left, right) {
64
+ if (left.length === 0 || right.length === 0)
65
+ return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
66
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
67
+ const leftPart = left[index];
68
+ const rightPart = right[index];
69
+ if (leftPart === undefined || rightPart === undefined)
70
+ return leftPart === undefined ? -1 : 1;
71
+ if (leftPart === rightPart)
72
+ continue;
73
+ const leftNumber = /^\d+$/u.test(leftPart) ? Number(leftPart) : undefined;
74
+ const rightNumber = /^\d+$/u.test(rightPart) ? Number(rightPart) : undefined;
75
+ if (leftNumber !== undefined && rightNumber !== undefined)
76
+ return leftNumber < rightNumber ? -1 : 1;
77
+ if (leftNumber !== undefined || rightNumber !== undefined)
78
+ return leftNumber !== undefined ? -1 : 1;
79
+ return leftPart < rightPart ? -1 : 1;
80
+ }
81
+ return 0;
82
+ }
83
+ function compareVersions(left, right) {
84
+ for (const key of ["major", "minor", "patch"]) {
85
+ if (left[key] !== right[key])
86
+ return left[key] < right[key] ? -1 : 1;
87
+ }
88
+ return comparePrerelease(left.prerelease, right.prerelease);
89
+ }
90
+ function classifyVersionTransition(installedValue, currentValue) {
91
+ const installed = parseVersion(installedValue);
92
+ const current = parseVersion(currentValue);
93
+ if (installed === undefined || current === undefined)
94
+ return "incompatible";
95
+ const order = compareVersions(installed, current);
96
+ if (order === 0)
97
+ return "same";
98
+ if (order > 0)
99
+ return "incompatible";
100
+ // SemVer-compatible update line: stable releases share a major; 0.x releases also share a minor.
101
+ const sameLine = installed.major === current.major &&
102
+ (installed.major !== 0 || installed.minor === current.minor);
103
+ return sameLine ? "compatible-update" : "incompatible";
104
+ }
105
+ async function metadata(path) {
106
+ try {
107
+ return await lstat(path);
108
+ }
109
+ catch (error) {
110
+ if (error.code === "ENOENT")
111
+ return undefined;
112
+ throw error;
113
+ }
114
+ }
115
+ function insideRoot(root, candidate) {
116
+ const path = relative(root, candidate);
117
+ return path === "" || (!path.startsWith(`..${sep}`) && path !== ".." && !isAbsolute(path));
118
+ }
119
+ async function assertSafeExistingPath(root, relativePath, file) {
120
+ const segments = relativePath.split("/");
121
+ let candidate = root;
122
+ for (let index = 0; index < segments.length; index += 1) {
123
+ candidate = resolve(candidate, segments[index]);
124
+ if (!insideRoot(root, candidate)) {
125
+ throw new ProjectInitializationError("unsafe-path", "An initialization path escapes the project root.");
126
+ }
127
+ const info = await metadata(candidate);
128
+ if (info === undefined)
129
+ return false;
130
+ if (info.isSymbolicLink()) {
131
+ throw new ProjectInitializationError("unsafe-path", "Initialization paths must not contain symbolic links.");
132
+ }
133
+ const isLast = index === segments.length - 1;
134
+ if (isLast && file) {
135
+ if (!info.isFile()) {
136
+ throw new ProjectInitializationError("conflict", "An initialization file path is not a regular file.");
137
+ }
138
+ }
139
+ else if (!info.isDirectory()) {
140
+ throw new ProjectInitializationError("conflict", "An initialization directory path is not a directory.");
141
+ }
142
+ }
143
+ return true;
144
+ }
145
+ async function readableOwnedLegacyFile(root, asset) {
146
+ let candidate = root;
147
+ const segments = asset.relativePath.split("/");
148
+ for (let index = 0; index < segments.length; index += 1) {
149
+ candidate = resolve(candidate, segments[index]);
150
+ if (!insideRoot(root, candidate))
151
+ return "preserve";
152
+ const info = await metadata(candidate);
153
+ if (info === undefined)
154
+ return "absent";
155
+ if (info.isSymbolicLink())
156
+ return "preserve";
157
+ const isLast = index === segments.length - 1;
158
+ if (isLast ? !info.isFile() : !info.isDirectory())
159
+ return "preserve";
160
+ }
161
+ const content = await readFile(candidate);
162
+ return createHash("sha256").update(content).digest("hex") === asset.sha256 ? content : "preserve";
163
+ }
164
+ async function ensureDirectory(root, relativePath, createdDirectories) {
165
+ let candidate = root;
166
+ for (const segment of relativePath.split("/")) {
167
+ candidate = resolve(candidate, segment);
168
+ const info = await metadata(candidate);
169
+ if (info !== undefined) {
170
+ if (info.isSymbolicLink()) {
171
+ throw new ProjectInitializationError("unsafe-path", "Initialization paths must not contain symbolic links.");
172
+ }
173
+ if (!info.isDirectory()) {
174
+ throw new ProjectInitializationError("conflict", "An initialization directory path is not a directory.");
175
+ }
176
+ continue;
177
+ }
178
+ try {
179
+ await mkdir(candidate);
180
+ createdDirectories.push(candidate);
181
+ }
182
+ catch (error) {
183
+ if (error.code !== "EEXIST")
184
+ throw error;
185
+ const raced = await lstat(candidate);
186
+ if (raced.isSymbolicLink() || !raced.isDirectory()) {
187
+ throw new ProjectInitializationError("unsafe-path", "An initialization directory changed during init.");
188
+ }
189
+ }
190
+ }
191
+ }
192
+ async function rollback(root, createdFiles, modifiedFiles, removedFiles, createdDirectories) {
193
+ const failures = [];
194
+ for (const file of [...createdFiles].reverse()) {
195
+ await rm(file, { force: true }).catch((error) => failures.push(error));
196
+ }
197
+ for (const file of [...modifiedFiles].reverse()) {
198
+ await overwriteFileSafely(root, file.relativePath, file.content).catch((error) => failures.push(error));
199
+ }
200
+ for (const file of [...removedFiles].reverse()) {
201
+ const path = resolve(root, file.relativePath);
202
+ let handle;
203
+ try {
204
+ handle = await open(path, "wx");
205
+ await handle.writeFile(file.content);
206
+ }
207
+ catch (error) {
208
+ failures.push(error);
209
+ }
210
+ finally {
211
+ await handle?.close().catch((error) => failures.push(error));
212
+ }
213
+ }
214
+ for (const directory of [...createdDirectories].reverse()) {
215
+ await rmdir(directory).catch((error) => {
216
+ if (error.code !== "ENOENT")
217
+ failures.push(error);
218
+ });
219
+ }
220
+ if (failures.length > 0)
221
+ throw new AggregateError(failures, "Initialization rollback failed.");
222
+ }
223
+ async function createFile(path, content, createdFiles) {
224
+ let handle;
225
+ try {
226
+ handle = await open(path, "wx");
227
+ createdFiles.push(path);
228
+ await handle.writeFile(content, "utf8");
229
+ }
230
+ finally {
231
+ await handle?.close();
232
+ }
233
+ }
234
+ async function overwriteFileSafely(root, relativePath, content, beforeMutation) {
235
+ await assertSafeExistingPath(root, relativePath, true);
236
+ const path = resolve(root, relativePath);
237
+ let handle;
238
+ try {
239
+ // O_NOFOLLOW closes the race between the final lstat above and opening the destination.
240
+ handle = await open(path, constants.O_WRONLY | constants.O_NOFOLLOW);
241
+ if (!(await handle.stat()).isFile()) {
242
+ throw new ProjectInitializationError("conflict", "An initialization file path is not a regular file.");
243
+ }
244
+ beforeMutation?.();
245
+ await handle.truncate(0);
246
+ await handle.writeFile(content);
247
+ }
248
+ catch (error) {
249
+ if (error.code === "ELOOP") {
250
+ throw new ProjectInitializationError("unsafe-path", "Initialization paths must not contain symbolic links.", {
251
+ cause: error,
252
+ });
253
+ }
254
+ throw error;
255
+ }
256
+ finally {
257
+ await handle?.close();
258
+ }
259
+ }
260
+ /** Installs the packaged runtime into one existing project without changing user settings. */
261
+ export async function initializeProject(projectRoot = process.cwd()) {
262
+ const root = resolve(projectRoot);
263
+ const rootInfo = await metadata(root);
264
+ if (rootInfo === undefined || !rootInfo.isDirectory() || rootInfo.isSymbolicLink()) {
265
+ throw new ProjectInitializationError("invalid-project", "Project root must be an existing non-symlink directory.");
266
+ }
267
+ const version = assetVersion();
268
+ const assetEntries = runtimeAssets.map(({ installPath, content }) => ({
269
+ relativePath: safeAssetPath(installPath),
270
+ content,
271
+ }));
272
+ const entries = [
273
+ ...assetEntries,
274
+ { relativePath: VERSION_MARKER, content: `${version}\n` },
275
+ ];
276
+ const existing = await Promise.all(entries.map(async (entry) => {
277
+ const present = await assertSafeExistingPath(root, entry.relativePath, true);
278
+ return present ? await readFile(resolve(root, entry.relativePath)) : undefined;
279
+ }));
280
+ const markerIndex = entries.length - 1;
281
+ const markerText = existing[markerIndex];
282
+ const matches = (entry, index) => existing[index]?.equals(Buffer.from(entry.content)) ?? false;
283
+ const assetsMatch = assetEntries.every(matches);
284
+ if (markerText !== undefined && parseMarker(markerText.toString("utf8")) === version && assetsMatch) {
285
+ return {
286
+ status: "unchanged",
287
+ version,
288
+ installedPaths: entries.map(({ relativePath }) => relativePath),
289
+ preservedLegacyPaths: [],
290
+ };
291
+ }
292
+ if (markerText === undefined) {
293
+ if (existing.some((content) => content !== undefined)) {
294
+ throw new ProjectInitializationError("conflict", "Existing Sortie-dogs runtime files have unknown ownership.");
295
+ }
296
+ }
297
+ else {
298
+ const installedVersion = parseMarker(markerText.toString("utf8"));
299
+ if (classifyVersionTransition(installedVersion, version) === "incompatible") {
300
+ throw new ProjectInitializationError("incompatible-version", `Installed Sortie-dogs ${installedVersion} cannot be updated to ${version}.`);
301
+ }
302
+ }
303
+ const installedVersion = markerText === undefined ? undefined : parseMarker(markerText.toString("utf8"));
304
+ const removableLegacyFiles = [];
305
+ const preservedLegacyPaths = [];
306
+ if (installedVersion !== undefined) {
307
+ for (const asset of LEGACY_RUNTIME_ASSETS) {
308
+ if (!asset.markerVersions.includes(installedVersion))
309
+ continue;
310
+ const state = await readableOwnedLegacyFile(root, asset);
311
+ if (Buffer.isBuffer(state))
312
+ removableLegacyFiles.push({ asset, content: state });
313
+ else if (state === "preserve")
314
+ preservedLegacyPaths.push(asset.relativePath);
315
+ }
316
+ }
317
+ const createdFiles = [];
318
+ const modifiedFiles = [];
319
+ const removedFiles = [];
320
+ const createdDirectories = [];
321
+ try {
322
+ for (let index = 0; index < entries.length; index += 1) {
323
+ const entry = entries[index];
324
+ if (matches(entry, index))
325
+ continue;
326
+ const parent = dirname(entry.relativePath).replaceAll("\\", "/");
327
+ await ensureDirectory(root, parent, createdDirectories);
328
+ await assertSafeExistingPath(root, parent, false);
329
+ const target = resolve(root, entry.relativePath);
330
+ if (existing[index] === undefined) {
331
+ await createFile(target, entry.content, createdFiles);
332
+ }
333
+ else {
334
+ await overwriteFileSafely(root, entry.relativePath, entry.content, () => {
335
+ modifiedFiles.push({ relativePath: entry.relativePath, content: existing[index] });
336
+ });
337
+ }
338
+ }
339
+ for (const { asset } of removableLegacyFiles) {
340
+ const state = await readableOwnedLegacyFile(root, asset);
341
+ if (!Buffer.isBuffer(state)) {
342
+ if (state === "preserve" && !preservedLegacyPaths.includes(asset.relativePath)) {
343
+ preservedLegacyPaths.push(asset.relativePath);
344
+ }
345
+ continue;
346
+ }
347
+ await rm(resolve(root, asset.relativePath));
348
+ removedFiles.push({ relativePath: asset.relativePath, content: state });
349
+ }
350
+ }
351
+ catch (error) {
352
+ try {
353
+ await rollback(root, createdFiles, modifiedFiles, removedFiles, createdDirectories);
354
+ }
355
+ catch (rollbackError) {
356
+ throw new ProjectInitializationError("write-failed", "Initialization failed and rollback was incomplete.", {
357
+ cause: new AggregateError([error, rollbackError]),
358
+ });
359
+ }
360
+ if (error instanceof ProjectInitializationError)
361
+ throw error;
362
+ const code = error.code === "EEXIST" ? "conflict" : "write-failed";
363
+ throw new ProjectInitializationError(code, "Initialization failed without changing the project.", { cause: error });
364
+ }
365
+ return {
366
+ status: "installed",
367
+ version,
368
+ installedPaths: entries.map(({ relativePath }) => relativePath),
369
+ preservedLegacyPaths,
370
+ };
371
+ }
@@ -0,0 +1,10 @@
1
+ export type RelativePathErrorReason = "empty" | "absolute" | "traversal";
2
+ export declare class RelativePathError extends Error {
3
+ readonly reason: RelativePathErrorReason;
4
+ constructor(reason: RelativePathErrorReason);
5
+ }
6
+ /**
7
+ * Produces a platform-independent repository-relative path without resolving
8
+ * filesystem links. Input values are intentionally omitted from errors.
9
+ */
10
+ export declare function normalizeRelativePath(input: string): string;
@@ -0,0 +1,27 @@
1
+ export class RelativePathError extends Error {
2
+ reason;
3
+ constructor(reason) {
4
+ super("Path must be a non-empty repository-relative path without traversal.");
5
+ this.name = "RelativePathError";
6
+ this.reason = reason;
7
+ }
8
+ }
9
+ /**
10
+ * Produces a platform-independent repository-relative path without resolving
11
+ * filesystem links. Input values are intentionally omitted from errors.
12
+ */
13
+ export function normalizeRelativePath(input) {
14
+ const unified = input.replaceAll("\\", "/");
15
+ if (unified.startsWith("/") || /^[A-Za-z]:/.test(unified)) {
16
+ throw new RelativePathError("absolute");
17
+ }
18
+ const segments = unified.split("/");
19
+ if (segments.includes("..")) {
20
+ throw new RelativePathError("traversal");
21
+ }
22
+ const normalized = segments.filter((segment) => segment !== "" && segment !== ".").join("/");
23
+ if (normalized === "") {
24
+ throw new RelativePathError("empty");
25
+ }
26
+ return normalized;
27
+ }
@@ -0,0 +1,114 @@
1
+ export type HandoffVersion = "0.1.0";
2
+ export type HandoffProfile = "minimal" | "full";
3
+ export interface HandoffTask {
4
+ title: string;
5
+ objective: string;
6
+ }
7
+ export interface HandoffScope {
8
+ paths: string[];
9
+ excludes?: string[];
10
+ }
11
+ export interface HandoffBlocker {
12
+ reason: string;
13
+ needed: string;
14
+ }
15
+ export interface HandoffState {
16
+ done: string[];
17
+ next: string[];
18
+ blocked: HandoffBlocker[];
19
+ }
20
+ export interface HandoffSource {
21
+ path: string;
22
+ rev: string;
23
+ hash?: string;
24
+ }
25
+ export type RiskSeverity = "low" | "medium" | "high";
26
+ export interface HandoffRisk {
27
+ severity: RiskSeverity;
28
+ description: string;
29
+ mitigation?: string;
30
+ }
31
+ export type VerificationStatus = "pass" | "fail" | "not_run";
32
+ export interface HandoffVerification {
33
+ check: string;
34
+ status: VerificationStatus;
35
+ exit_code?: number | null;
36
+ summary: string;
37
+ }
38
+ export interface Handoff {
39
+ version: HandoffVersion;
40
+ profile: HandoffProfile;
41
+ ext?: Record<string, unknown>;
42
+ id: string;
43
+ created_at: string;
44
+ task: HandoffTask;
45
+ scope?: HandoffScope;
46
+ state: HandoffState;
47
+ sources?: HandoffSource[];
48
+ risks: HandoffRisk[];
49
+ verification: HandoffVerification[];
50
+ }
51
+ export type OperationManifestVersion = "0.1.0";
52
+ export interface OperationManifest {
53
+ version: OperationManifestVersion;
54
+ task_id: string;
55
+ read: string[];
56
+ write: string[];
57
+ validation: string[];
58
+ }
59
+ export type SchemaKind = "handoff" | "operation-manifest";
60
+ export type SchemaDiagnosticCode = `schema_${string}`;
61
+ /** A structural validation diagnostic, intentionally separate from semantic lint. */
62
+ export interface SchemaDiagnostic {
63
+ code: SchemaDiagnosticCode;
64
+ severity: "error";
65
+ pointer: string;
66
+ message: string;
67
+ }
68
+ export interface SchemaValidationSuccess<T> {
69
+ ok: true;
70
+ value: T;
71
+ diagnostics: [];
72
+ }
73
+ export interface SchemaValidationFailure {
74
+ ok: false;
75
+ value: unknown;
76
+ diagnostics: SchemaDiagnostic[];
77
+ }
78
+ export type SchemaValidationResult<T> = SchemaValidationSuccess<T> | SchemaValidationFailure;
79
+ export type SemanticIssueCode = "H001" | "H002" | "H003" | "H004" | "H005" | "H006" | "H007" | "H008" | "H009" | "H010";
80
+ export type ManifestDiagnosticCode = "H001_PATH_RELATIVE" | "H011_VALIDATION_MISSING" | "M002_SCOPE_NOT_ALLOWED" | "M003_SOURCE_NOT_DECLARED" | "M004_VERIFICATION_NOT_DECLARED" | "M005_CHANGED_PATH_NOT_WRITABLE" | "M007_CHANGED_PATHS_MISSING";
81
+ export type DiagnosticCode = SemanticIssueCode | ManifestDiagnosticCode;
82
+ export interface SemanticIssue {
83
+ code: SemanticIssueCode;
84
+ path: string;
85
+ message: string;
86
+ }
87
+ export type Severity = "error" | "warning" | "info";
88
+ export interface Diagnostic {
89
+ code: DiagnosticCode;
90
+ severity: Severity;
91
+ pointer: string;
92
+ message: string;
93
+ }
94
+ export interface ManifestDiagnostic extends Diagnostic {
95
+ code: ManifestDiagnosticCode;
96
+ severity: "error" | "warning";
97
+ }
98
+ export interface LintOptions {
99
+ codes?: readonly SemanticIssueCode[];
100
+ severity?: Partial<Record<SemanticIssueCode, Severity>>;
101
+ }
102
+ export interface LintResult {
103
+ diagnostics: Diagnostic[];
104
+ counts: Record<Severity, number>;
105
+ ok: boolean;
106
+ }
107
+ /** A diagnostic after the CLI has associated it with an input document. */
108
+ export interface CliDiagnostic {
109
+ file: string;
110
+ code: string;
111
+ severity: Severity;
112
+ pointer: string;
113
+ message: string;
114
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { Handoff, ManifestDiagnostic, OperationManifest } from "./types.js";
2
+ /** Compare one schema-valid handoff with one schema-valid operation manifest. */
3
+ export declare function validateManifest(handoff: Handoff, manifest: OperationManifest, changedPaths: readonly string[] | undefined, changedPathsProvided: boolean): ManifestDiagnostic[];
@@ -0,0 +1,139 @@
1
+ const pathUtils = await import(`./path.${import.meta.url.endsWith(".ts") ? "ts" : "js"}`);
2
+ const MESSAGES = {
3
+ H001_PATH_RELATIVE: "Manifest path must be a valid repository-relative path.",
4
+ H011_VALIDATION_MISSING: "A required manifest validation has not passed.",
5
+ M002_SCOPE_NOT_ALLOWED: "Scope path is not declared in the operation manifest.",
6
+ M003_SOURCE_NOT_DECLARED: "Source path is not declared in the operation manifest.",
7
+ M004_VERIFICATION_NOT_DECLARED: "Verification check is not declared in the operation manifest.",
8
+ M005_CHANGED_PATH_NOT_WRITABLE: "Changed path is not writable according to the operation manifest.",
9
+ M007_CHANGED_PATHS_MISSING: "Changed paths were not provided.",
10
+ };
11
+ function normalizePath(path) {
12
+ try {
13
+ return pathUtils.normalizeRelativePath(path);
14
+ }
15
+ catch (error) {
16
+ if (error instanceof pathUtils.RelativePathError)
17
+ return undefined;
18
+ throw error;
19
+ }
20
+ }
21
+ function collectManifestPaths(paths, pointer, diagnostics) {
22
+ const normalizedPaths = new Set();
23
+ paths.forEach((path, index) => {
24
+ const normalized = normalizePath(path);
25
+ if (normalized === undefined) {
26
+ diagnostics.push({
27
+ code: "H001_PATH_RELATIVE",
28
+ severity: "error",
29
+ pointer: `/manifest/${pointer}/${index}`,
30
+ message: MESSAGES.H001_PATH_RELATIVE,
31
+ });
32
+ }
33
+ else {
34
+ normalizedPaths.add(normalized);
35
+ }
36
+ });
37
+ return normalizedPaths;
38
+ }
39
+ function comparePointers(left, right) {
40
+ const leftSegments = left.split("/");
41
+ const rightSegments = right.split("/");
42
+ const length = Math.min(leftSegments.length, rightSegments.length);
43
+ for (let index = 0; index < length; index += 1) {
44
+ const leftSegment = leftSegments[index];
45
+ const rightSegment = rightSegments[index];
46
+ if (leftSegment === rightSegment)
47
+ continue;
48
+ if (/^\d+$/.test(leftSegment) && /^\d+$/.test(rightSegment)) {
49
+ return BigInt(leftSegment) < BigInt(rightSegment) ? -1 : 1;
50
+ }
51
+ return leftSegment < rightSegment ? -1 : 1;
52
+ }
53
+ return leftSegments.length - rightSegments.length;
54
+ }
55
+ function compareDiagnostics(left, right) {
56
+ const pointerOrder = comparePointers(left.pointer, right.pointer);
57
+ if (pointerOrder !== 0)
58
+ return pointerOrder;
59
+ return left.code < right.code ? -1 : left.code > right.code ? 1 : 0;
60
+ }
61
+ function addPathDiagnostics(diagnostics, paths, allowed, code, pointer) {
62
+ const reported = new Set();
63
+ paths.forEach((path, index) => {
64
+ const normalized = normalizePath(path);
65
+ if (normalized !== undefined && (allowed.has(normalized) || reported.has(normalized)))
66
+ return;
67
+ if (normalized !== undefined)
68
+ reported.add(normalized);
69
+ diagnostics.push({
70
+ code,
71
+ severity: "error",
72
+ pointer: pointer(index),
73
+ message: MESSAGES[code],
74
+ });
75
+ });
76
+ }
77
+ /** Compare one schema-valid handoff with one schema-valid operation manifest. */
78
+ export function validateManifest(handoff, manifest, changedPaths, changedPathsProvided) {
79
+ const diagnostics = [];
80
+ const readable = collectManifestPaths(manifest.read, "read", diagnostics);
81
+ const writable = collectManifestPaths(manifest.write, "write", diagnostics);
82
+ const readableOrWritable = new Set([...readable, ...writable]);
83
+ const declaredValidation = new Set(manifest.validation);
84
+ if (handoff.scope !== undefined) {
85
+ addPathDiagnostics(diagnostics, handoff.scope.paths, readableOrWritable, "M002_SCOPE_NOT_ALLOWED", (index) => `/scope/paths/${index}`);
86
+ }
87
+ const reportedSources = new Set();
88
+ handoff.sources?.forEach((source, index) => {
89
+ const normalized = normalizePath(source.path);
90
+ if (normalized !== undefined &&
91
+ (readableOrWritable.has(normalized) || reportedSources.has(normalized)))
92
+ return;
93
+ if (normalized !== undefined)
94
+ reportedSources.add(normalized);
95
+ diagnostics.push({
96
+ code: "M003_SOURCE_NOT_DECLARED",
97
+ severity: "error",
98
+ pointer: `/sources/${index}/path`,
99
+ message: MESSAGES.M003_SOURCE_NOT_DECLARED,
100
+ });
101
+ });
102
+ const reportedChecks = new Set();
103
+ handoff.verification.forEach((verification, index) => {
104
+ if (declaredValidation.has(verification.check) || reportedChecks.has(verification.check))
105
+ return;
106
+ reportedChecks.add(verification.check);
107
+ diagnostics.push({
108
+ code: "M004_VERIFICATION_NOT_DECLARED",
109
+ severity: "error",
110
+ pointer: `/verification/${index}/check`,
111
+ message: MESSAGES.M004_VERIFICATION_NOT_DECLARED,
112
+ });
113
+ });
114
+ if (handoff.profile === "full") {
115
+ const passedChecks = new Set(handoff.verification
116
+ .filter(({ status }) => status === "pass")
117
+ .map(({ check }) => check));
118
+ if (manifest.validation.some((check) => !passedChecks.has(check))) {
119
+ diagnostics.push({
120
+ code: "H011_VALIDATION_MISSING",
121
+ severity: "error",
122
+ pointer: "/verification",
123
+ message: MESSAGES.H011_VALIDATION_MISSING,
124
+ });
125
+ }
126
+ }
127
+ if (changedPathsProvided) {
128
+ addPathDiagnostics(diagnostics, changedPaths ?? [], writable, "M005_CHANGED_PATH_NOT_WRITABLE", (index) => `/changedPaths/${index}`);
129
+ }
130
+ else {
131
+ diagnostics.push({
132
+ code: "M007_CHANGED_PATHS_MISSING",
133
+ severity: "warning",
134
+ pointer: "/changedPaths",
135
+ message: MESSAGES.M007_CHANGED_PATHS_MISSING,
136
+ });
137
+ }
138
+ return diagnostics.sort(compareDiagnostics);
139
+ }
@@ -0,0 +1,5 @@
1
+ import type { Handoff, OperationManifest, SchemaKind, SchemaValidationResult } from "./types.js";
2
+ /** Validate structure only. The input object is returned unchanged and is never mutated. */
3
+ export declare function validateSchema<T>(kind: SchemaKind, value: unknown): SchemaValidationResult<T>;
4
+ export declare function validateHandoffSchema(value: unknown): SchemaValidationResult<Handoff>;
5
+ export declare function validateOperationManifestSchema(value: unknown): SchemaValidationResult<OperationManifest>;