@prisma-next/migration-tools 0.11.0-dev.46 → 0.11.0-dev.47

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 (81) hide show
  1. package/dist/{errors-CoEN114u.mjs → errors-4YabujxZ.mjs} +3 -21
  2. package/dist/{errors-CoEN114u.mjs.map → errors-4YabujxZ.mjs.map} +1 -1
  3. package/dist/exports/aggregate.d.mts +273 -177
  4. package/dist/exports/aggregate.d.mts.map +1 -1
  5. package/dist/exports/aggregate.mjs +363 -185
  6. package/dist/exports/aggregate.mjs.map +1 -1
  7. package/dist/exports/enumerate-migration-spaces.d.mts +1 -1
  8. package/dist/exports/enumerate-migration-spaces.mjs +4 -4
  9. package/dist/exports/enumerate-migration-spaces.mjs.map +1 -1
  10. package/dist/exports/errors.mjs +1 -1
  11. package/dist/exports/graph.d.mts +1 -1
  12. package/dist/exports/hash.d.mts +1 -1
  13. package/dist/exports/invariants.mjs +1 -1
  14. package/dist/exports/io.d.mts +2 -83
  15. package/dist/exports/io.mjs +1 -1
  16. package/dist/exports/metadata.d.mts +1 -1
  17. package/dist/exports/migration-graph.d.mts +2 -2
  18. package/dist/exports/migration-graph.mjs +2 -2
  19. package/dist/exports/migration-list-graph-layout.d.mts +2 -2
  20. package/dist/exports/migration-list-graph-topology.d.mts +1 -1
  21. package/dist/exports/migration-list-types.d.mts +1 -1
  22. package/dist/exports/migration.d.mts +1 -1
  23. package/dist/exports/migration.mjs +2 -2
  24. package/dist/exports/ref-resolution.d.mts +2 -2
  25. package/dist/exports/ref-resolution.mjs +1 -1
  26. package/dist/exports/refs.d.mts +1 -1
  27. package/dist/exports/refs.mjs +2 -2
  28. package/dist/exports/spaces.d.mts +1 -130
  29. package/dist/exports/spaces.d.mts.map +1 -1
  30. package/dist/exports/spaces.mjs +6 -6
  31. package/dist/exports/spaces.mjs.map +1 -1
  32. package/dist/{graph-B0LIIjIu.d.mts → graph-3dLMZp5l.d.mts} +1 -1
  33. package/dist/{graph-B0LIIjIu.d.mts.map → graph-3dLMZp5l.d.mts.map} +1 -1
  34. package/dist/{invariants-lbJddL-S.mjs → invariants-CCOAyg6c.mjs} +2 -2
  35. package/dist/{invariants-lbJddL-S.mjs.map → invariants-CCOAyg6c.mjs.map} +1 -1
  36. package/dist/io-BH4G3F-i.d.mts +124 -0
  37. package/dist/io-BH4G3F-i.d.mts.map +1 -0
  38. package/dist/{io-Dc64lvaL.mjs → io-BHl0amF0.mjs} +99 -6
  39. package/dist/io-BHl0amF0.mjs.map +1 -0
  40. package/dist/{migration-graph-fl5ChjXE.d.mts → migration-graph-CWEM2SLR.d.mts} +2 -2
  41. package/dist/{migration-graph-fl5ChjXE.d.mts.map → migration-graph-CWEM2SLR.d.mts.map} +1 -1
  42. package/dist/{migration-graph-D5JeadSE.mjs → migration-graph-kGBkIZDa.mjs} +3 -7
  43. package/dist/migration-graph-kGBkIZDa.mjs.map +1 -0
  44. package/dist/{migration-list-graph-topology-CafEnhPT.d.mts → migration-list-graph-topology-Be1d8Y89.d.mts} +2 -2
  45. package/dist/{migration-list-graph-topology-CafEnhPT.d.mts.map → migration-list-graph-topology-Be1d8Y89.d.mts.map} +1 -1
  46. package/dist/{migration-list-types-wLyb3E-p.d.mts → migration-list-types-0YjFETIv.d.mts} +1 -1
  47. package/dist/{migration-list-types-wLyb3E-p.d.mts.map → migration-list-types-0YjFETIv.d.mts.map} +1 -1
  48. package/dist/{read-contract-space-contract-C4fEdoXO.mjs → read-contract-space-contract-7-OB-ykY.mjs} +3 -3
  49. package/dist/{read-contract-space-contract-C4fEdoXO.mjs.map → read-contract-space-contract-7-OB-ykY.mjs.map} +1 -1
  50. package/dist/{refs-D8xBNqs7.d.mts → refs-B33AsTjk.d.mts} +12 -2
  51. package/dist/refs-B33AsTjk.d.mts.map +1 -0
  52. package/dist/{refs-HhOkD8BT.mjs → refs-BBKNL45K.mjs} +75 -3
  53. package/dist/refs-BBKNL45K.mjs.map +1 -0
  54. package/dist/{verify-contract-spaces-DIdQLGo7.mjs → verify-contract-spaces-BJX5gqtD.mjs} +3 -3
  55. package/dist/{verify-contract-spaces-DIdQLGo7.mjs.map → verify-contract-spaces-BJX5gqtD.mjs.map} +1 -1
  56. package/dist/verify-contract-spaces-BdysZdQk.d.mts +132 -0
  57. package/dist/verify-contract-spaces-BdysZdQk.d.mts.map +1 -0
  58. package/package.json +6 -6
  59. package/src/aggregate/aggregate.ts +90 -0
  60. package/src/aggregate/check-integrity.ts +243 -0
  61. package/src/aggregate/loader.ts +156 -334
  62. package/src/aggregate/planner.ts +8 -6
  63. package/src/aggregate/project-schema-to-space.ts +1 -1
  64. package/src/aggregate/strategies/graph-walk.ts +12 -7
  65. package/src/aggregate/strategies/synth.ts +2 -2
  66. package/src/aggregate/types.ts +56 -64
  67. package/src/aggregate/verifier.ts +6 -4
  68. package/src/compute-extension-space-apply-path.ts +1 -1
  69. package/src/enumerate-migration-spaces.ts +1 -1
  70. package/src/exports/aggregate.ts +17 -12
  71. package/src/exports/io.ts +2 -0
  72. package/src/integrity-violation.ts +114 -0
  73. package/src/io.ts +139 -6
  74. package/src/migration-graph.ts +3 -17
  75. package/src/refs.ts +94 -0
  76. package/dist/exports/io.d.mts.map +0 -1
  77. package/dist/io-Dc64lvaL.mjs.map +0 -1
  78. package/dist/migration-graph-D5JeadSE.mjs.map +0 -1
  79. package/dist/refs-D8xBNqs7.d.mts.map +0 -1
  80. package/dist/refs-HhOkD8BT.mjs.map +0 -1
  81. /package/dist/{metadata-COhIQCiH.d.mts → metadata-CGkJF4L6.d.mts} +0 -0
@@ -0,0 +1,132 @@
1
+ //#region src/verify-contract-spaces.d.ts
2
+ /**
3
+ * List the per-space subdirectories under
4
+ * `<projectRoot>/migrations/`. Returns space-id directory names (sorted
5
+ * alphabetically) — i.e. any non-dot-prefixed subdirectory whose root
6
+ * does **not** contain a `migration.json` manifest. The manifest is the
7
+ * structural marker of a user-authored migration directory (see
8
+ * `readMigrationsDir` in `./io`); directory names themselves belong to
9
+ * the user and are not part of the contract.
10
+ *
11
+ * Returns `[]` if the migrations directory does not exist (greenfield
12
+ * project).
13
+ *
14
+ * Reads only the user's repo. **No descriptor import.** The caller
15
+ * (verifier) feeds the result into {@link verifyContractSpaces} alongside
16
+ * the loaded-space set and the marker rows.
17
+ */
18
+ declare function listContractSpaceDirectories(projectMigrationsDir: string): Promise<readonly string[]>;
19
+ /**
20
+ * On-disk head value (`(hash, invariants)`) for one contract space.
21
+ * The verifier compares this against the marker row for the same space
22
+ * to detect drift between the user-emitted artefacts and the live DB
23
+ * marker.
24
+ */
25
+ interface ContractSpaceHeadRecord {
26
+ readonly hash: string;
27
+ readonly invariants: readonly string[];
28
+ }
29
+ /**
30
+ * Marker row read from `prisma_contract.marker` (one per `space`).
31
+ * Caller resolves these via the family runtime's marker reader before
32
+ * invoking {@link verifyContractSpaces}.
33
+ */
34
+ interface SpaceMarkerRecord {
35
+ readonly hash: string;
36
+ readonly invariants: readonly string[];
37
+ }
38
+ interface VerifyContractSpacesInputs {
39
+ /**
40
+ * Set of contract spaces the project declares: `'app'` plus each
41
+ * extension space in `extensionPacks`. The caller's discovery path
42
+ * never reads the extension descriptor module — it walks the
43
+ * `extensionPacks` configuration in `prisma-next.config.ts` for the
44
+ * space ids.
45
+ */
46
+ readonly loadedSpaces: ReadonlySet<string>;
47
+ /**
48
+ * Per-space subdirectories observed under
49
+ * `<projectRoot>/migrations/`. Resolved via
50
+ * {@link listContractSpaceDirectories}.
51
+ */
52
+ readonly spaceDirsOnDisk: readonly string[];
53
+ /**
54
+ * Head ref per space, keyed by space id. Caller reads
55
+ * `<projectRoot>/migrations/<space-id>/contract.json` and
56
+ * `<projectRoot>/migrations/<space-id>/refs/head.json` to construct
57
+ * this map. Spaces with no contract-space dir on disk simply omit a
58
+ * map entry.
59
+ */
60
+ readonly headRefsBySpace: ReadonlyMap<string, ContractSpaceHeadRecord>;
61
+ /**
62
+ * Marker rows keyed by `space`. Caller reads them from the
63
+ * `prisma_contract.marker` table.
64
+ */
65
+ readonly markerRowsBySpace: ReadonlyMap<string, SpaceMarkerRecord>;
66
+ }
67
+ type SpaceVerifierViolation = {
68
+ readonly kind: 'declaredButUnmigrated';
69
+ readonly spaceId: string;
70
+ readonly remediation: string;
71
+ } | {
72
+ readonly kind: 'orphanMarker';
73
+ readonly spaceId: string;
74
+ readonly remediation: string;
75
+ } | {
76
+ readonly kind: 'orphanSpaceDir';
77
+ readonly spaceId: string;
78
+ readonly remediation: string;
79
+ } | {
80
+ readonly kind: 'hashMismatch';
81
+ readonly spaceId: string;
82
+ readonly priorHeadHash: string;
83
+ readonly markerHash: string;
84
+ readonly remediation: string;
85
+ } | {
86
+ readonly kind: 'invariantsMismatch';
87
+ readonly spaceId: string;
88
+ readonly onDiskInvariants: readonly string[];
89
+ readonly markerInvariants: readonly string[];
90
+ readonly remediation: string;
91
+ };
92
+ type VerifyContractSpacesResult = {
93
+ readonly ok: true;
94
+ } | {
95
+ readonly ok: false;
96
+ readonly violations: readonly SpaceVerifierViolation[];
97
+ };
98
+ /**
99
+ * Pure structural verifier for the per-space mechanism. Aggregates the
100
+ * three orphan / missing checks plus per-space hash and invariant
101
+ * comparison.
102
+ *
103
+ * Algorithm:
104
+ *
105
+ * - For every extension space declared in `loadedSpaces` (`'app'`
106
+ * excluded — the per-space verifier is scoped to extension members;
107
+ * the app is verified through the aggregate path):
108
+ * - If no contract-space dir on disk → `declaredButUnmigrated`.
109
+ * - Else if `markerRowsBySpace` lacks an entry → no violation here;
110
+ * the live-DB compare done outside this helper is where the
111
+ * absence shows up.
112
+ * - Else compare marker hash / invariants vs. on-disk head hash /
113
+ * invariants → `hashMismatch` / `invariantsMismatch` on drift.
114
+ * - For every contract-space dir on disk that is not in `loadedSpaces` →
115
+ * `orphanSpaceDir`.
116
+ * - For every marker row whose `space` is not in `loadedSpaces` →
117
+ * `orphanMarker`. The app-space marker is always loaded (`'app'` is
118
+ * in `loadedSpaces` by definition).
119
+ *
120
+ * Output is deterministic: violations are sorted first by `kind`
121
+ * (`declaredButUnmigrated` → `orphanMarker` → `orphanSpaceDir` →
122
+ * `hashMismatch` → `invariantsMismatch`) then by `spaceId`. Two callers
123
+ * passing equivalent inputs see byte-identical violation lists.
124
+ *
125
+ * Synchronous, pure, no I/O. **Does not import the extension descriptor**
126
+ * (the inputs are pre-resolved by the caller); the verifier reads only
127
+ * the user repo, not `node_modules`.
128
+ */
129
+ declare function verifyContractSpaces(inputs: VerifyContractSpacesInputs): VerifyContractSpacesResult;
130
+ //#endregion
131
+ export { VerifyContractSpacesResult as a, VerifyContractSpacesInputs as i, SpaceMarkerRecord as n, listContractSpaceDirectories as o, SpaceVerifierViolation as r, verifyContractSpaces as s, ContractSpaceHeadRecord as t };
132
+ //# sourceMappingURL=verify-contract-spaces-BdysZdQk.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verify-contract-spaces-BdysZdQk.d.mts","names":[],"sources":["../src/verify-contract-spaces.ts"],"mappings":";;AAyBA;;;;AAEU;AAyCV;;;;AAEqB;AAQrB;;;;AAEqB;iBAvDC,4BAAA,CACpB,oBAAA,WACC,OAAO;;;;;;;UAyCO,uBAAA;EAAA,SACN,IAAA;EAAA,SACA,UAAU;AAAA;;;;;;UAQJ,iBAAA;EAAA,SACN,IAAA;EAAA,SACA,UAAU;AAAA;AAAA,UAGJ,0BAAA;EAiCL;;;;;;;EAAA,SAzBD,YAAA,EAAc,WAAA;EAiCV;;;;;EAAA,SA1BJ,eAAA;EAoCI;;;;;;;EAAA,SA3BJ,eAAA,EAAiB,WAAA,SAAoB,uBAAA;EAqCjC;;AAAW;AAG1B;EAHe,SA/BJ,iBAAA,EAAmB,WAAA,SAAoB,iBAAA;AAAA;AAAA,KAGtC,sBAAA;EAAA,SAEG,IAAA;EAAA,SACA,OAAA;EAAA,SACA,WAAA;AAAA;EAAA,SAGA,IAAA;EAAA,SACA,OAAA;EAAA,SACA,WAAA;AAAA;EAAA,SAGA,IAAA;EAAA,SACA,OAAA;EAAA,SACA,WAAA;AAAA;EAAA,SAGA,IAAA;EAAA,SACA,OAAA;EAAA,SACA,aAAA;EAAA,SACA,UAAA;EAAA,SACA,WAAA;AAAA;EAAA,SAGA,IAAA;EAAA,SACA,OAAA;EAAA,SACA,gBAAA;EAAA,SACA,gBAAA;EAAA,SACA,WAAA;AAAA;AAAA,KAGH,0BAAA;EAAA,SACG,EAAA;AAAA;EAAA,SACA,EAAA;EAAA,SAAoB,UAAA,WAAqB,sBAAsB;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAiC9D,oBAAA,CACd,MAAA,EAAQ,0BAAA,GACP,0BAA0B"}
package/package.json CHANGED
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "name": "@prisma-next/migration-tools",
3
- "version": "0.11.0-dev.46",
3
+ "version": "0.11.0-dev.47",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "description": "On-disk migration persistence, hash verification, and chain reconstruction for Prisma Next",
8
8
  "dependencies": {
9
- "@prisma-next/contract": "0.11.0-dev.46",
10
- "@prisma-next/framework-components": "0.11.0-dev.46",
11
- "@prisma-next/utils": "0.11.0-dev.46",
9
+ "@prisma-next/contract": "0.11.0-dev.47",
10
+ "@prisma-next/framework-components": "0.11.0-dev.47",
11
+ "@prisma-next/utils": "0.11.0-dev.47",
12
12
  "arktype": "^2.2.0",
13
13
  "pathe": "^2.0.3",
14
14
  "prettier": "^3.8.3"
15
15
  },
16
16
  "devDependencies": {
17
- "@prisma-next/tsconfig": "0.11.0-dev.46",
18
- "@prisma-next/tsdown": "0.11.0-dev.46",
17
+ "@prisma-next/tsconfig": "0.11.0-dev.47",
18
+ "@prisma-next/tsdown": "0.11.0-dev.47",
19
19
  "tsdown": "0.22.0",
20
20
  "typescript": "5.9.3",
21
21
  "vitest": "4.1.6"
@@ -0,0 +1,90 @@
1
+ import type { Contract } from '@prisma-next/contract/types';
2
+ import type { MigrationGraph } from '../graph';
3
+ import type { IntegrityQueryOptions, IntegrityViolation } from '../integrity-violation';
4
+ import { reconstructGraph } from '../migration-graph';
5
+ import type { OnDiskMigrationPackage } from '../package';
6
+ import type { Refs } from '../refs';
7
+ import type { ContractSpaceHeadRecord } from '../verify-contract-spaces';
8
+ import type { ContractSpaceAggregate, ContractSpaceMember } from './types';
9
+
10
+ /**
11
+ * Resolve a member's head ref, asserting it is present. The apply/verify
12
+ * engine only runs after `checkIntegrity` has refused on `headRefMissing`,
13
+ * so a member reaching the planner / verifier without a head ref is a
14
+ * programming error (the integrity gate was skipped), not a user-facing
15
+ * state. The app member's head ref is always synthesised, so this only
16
+ * ever guards an ungated extension space.
17
+ */
18
+ export function requireHeadRef(member: ContractSpaceMember): ContractSpaceHeadRecord {
19
+ if (member.headRef === null) {
20
+ throw new Error(
21
+ `Contract space "${member.spaceId}" has no head ref; the integrity gate must refuse a missing head ref before planning or verifying.`,
22
+ );
23
+ }
24
+ return member.headRef;
25
+ }
26
+
27
+ /**
28
+ * Build a {@link ContractSpaceMember} with lazily-memoised `graph()` and
29
+ * `contract()` facets.
30
+ *
31
+ * `graph()` reconstructs the migration graph from `packages` on first
32
+ * call and caches it. `contract()` calls `resolveContract` on first call
33
+ * and caches the result; a throwing `resolveContract` (e.g. a missing or
34
+ * undeserializable on-disk contract) re-throws on each call rather than
35
+ * caching a value — `checkIntegrity` surfaces that as `contractUnreadable`.
36
+ */
37
+ export function createContractSpaceMember(args: {
38
+ readonly spaceId: string;
39
+ readonly packages: readonly OnDiskMigrationPackage[];
40
+ readonly refs: Refs;
41
+ readonly headRef: ContractSpaceHeadRecord | null;
42
+ readonly resolveContract: () => Contract;
43
+ }): ContractSpaceMember {
44
+ const { spaceId, packages, refs, headRef, resolveContract } = args;
45
+ let graphMemo: MigrationGraph | undefined;
46
+ let contractMemo: Contract | undefined;
47
+ return {
48
+ spaceId,
49
+ packages,
50
+ refs,
51
+ headRef,
52
+ graph() {
53
+ graphMemo ??= reconstructGraph(packages);
54
+ return graphMemo;
55
+ },
56
+ contract() {
57
+ contractMemo ??= resolveContract();
58
+ return contractMemo;
59
+ },
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Assemble a {@link ContractSpaceAggregate} value from its members and a
65
+ * `checkIntegrity` implementation. The query methods (`listSpaces` /
66
+ * `hasSpace` / `space` / `spaces`) are derived here so every aggregate —
67
+ * loader-built or test-built — shares one query surface: `app` first,
68
+ * then `extensions` in the order supplied (the loader sorts them
69
+ * lex-ascending by `spaceId`).
70
+ */
71
+ export function createContractSpaceAggregate(args: {
72
+ readonly targetId: string;
73
+ readonly app: ContractSpaceMember;
74
+ readonly extensions: readonly ContractSpaceMember[];
75
+ readonly checkIntegrity: (opts?: IntegrityQueryOptions) => readonly IntegrityViolation[];
76
+ }): ContractSpaceAggregate {
77
+ const { targetId, app, extensions, checkIntegrity } = args;
78
+ const ordered: readonly ContractSpaceMember[] = [app, ...extensions];
79
+ const byId = new Map(ordered.map((m) => [m.spaceId, m]));
80
+ return {
81
+ targetId,
82
+ app,
83
+ extensions,
84
+ listSpaces: () => ordered.map((m) => m.spaceId),
85
+ hasSpace: (id) => byId.has(id),
86
+ space: (id) => byId.get(id),
87
+ spaces: () => ordered,
88
+ checkIntegrity,
89
+ };
90
+ }
@@ -0,0 +1,243 @@
1
+ import { EMPTY_CONTRACT_HASH } from '../constants';
2
+ import { MigrationToolsError } from '../errors';
3
+ import type {
4
+ DeclaredExtensionEntry,
5
+ IntegrityQueryOptions,
6
+ IntegrityViolation,
7
+ } from '../integrity-violation';
8
+ import type { PackageLoadProblem } from '../io';
9
+ import type { OnDiskMigrationPackage } from '../package';
10
+ import type { RefLoadProblem } from '../refs';
11
+ import { extractStorageElementNames } from './extract-storage-element-names';
12
+ import type { ContractSpaceMember } from './types';
13
+
14
+ /**
15
+ * One space's load-time facts that `checkIntegrity` judges: the loaded
16
+ * member, the load-time problems `readMigrationsDir` surfaced for it, and
17
+ * whether it is the app space (the app head ref is synthesised, so the
18
+ * head-ref checks are skipped for it).
19
+ */
20
+ export interface IntegritySpaceState {
21
+ readonly member: ContractSpaceMember;
22
+ readonly problems: readonly PackageLoadProblem[];
23
+ /** Per-ref problems: a user ref `*.json` that exists but is unparseable. */
24
+ readonly refProblems: readonly RefLoadProblem[];
25
+ /**
26
+ * The space's `refs/head.json` problem when it exists but is unparseable.
27
+ * `null` means the head ref was read cleanly or is genuinely absent —
28
+ * the absent case is judged `headRefMissing`, the corrupt case here is
29
+ * judged `refUnreadable` (and suppresses `headRefMissing`).
30
+ */
31
+ readonly headRefProblem: RefLoadProblem | null;
32
+ readonly isApp: boolean;
33
+ }
34
+
35
+ export interface IntegrityComputationInput {
36
+ readonly targetId: string;
37
+ readonly spaces: readonly IntegritySpaceState[];
38
+ }
39
+
40
+ /**
41
+ * Walk the loaded model and return **every** integrity violation — never
42
+ * bailing at the first. Structurally-derivable violations (load-time
43
+ * problems, self-edges, missing / unreachable head refs) are always
44
+ * produced; layout-drift checks require `declaredExtensions`, and
45
+ * contract / target / disjointness checks require `checkContracts`.
46
+ */
47
+ export function computeIntegrityViolations(
48
+ input: IntegrityComputationInput,
49
+ opts?: IntegrityQueryOptions,
50
+ ): readonly IntegrityViolation[] {
51
+ const violations: IntegrityViolation[] = [];
52
+
53
+ for (const { member, problems, refProblems, headRefProblem, isApp } of input.spaces) {
54
+ const { spaceId } = member;
55
+
56
+ for (const problem of problems) {
57
+ violations.push(loadProblemToViolation(spaceId, problem));
58
+ }
59
+
60
+ for (const refProblem of refProblems) {
61
+ violations.push({
62
+ kind: 'refUnreadable',
63
+ spaceId,
64
+ refName: refProblem.refName,
65
+ detail: refProblem.detail,
66
+ });
67
+ }
68
+ if (headRefProblem !== null) {
69
+ violations.push({
70
+ kind: 'refUnreadable',
71
+ spaceId,
72
+ refName: headRefProblem.refName,
73
+ detail: headRefProblem.detail,
74
+ });
75
+ }
76
+
77
+ for (const pkg of member.packages) {
78
+ const from = pkg.metadata.from ?? EMPTY_CONTRACT_HASH;
79
+ const isSelfEdge = from === pkg.metadata.to;
80
+ const hasDataOp = pkg.ops.some((op) => op.operationClass === 'data');
81
+ if (isSelfEdge && !hasDataOp) {
82
+ violations.push({ kind: 'sameSourceAndTarget', spaceId, dirName: pkg.dirName, hash: from });
83
+ }
84
+ }
85
+
86
+ violations.push(...duplicateMigrationHashViolations(spaceId, member.packages));
87
+
88
+ // The app head ref is synthesised from the live contract, so it is
89
+ // always present and reachable; only extension spaces read their head
90
+ // ref from disk and can be missing or point outside the graph. A head
91
+ // ref that exists but is unparseable is already surfaced above as
92
+ // `refUnreadable`, so it is not also reported as `headRefMissing`.
93
+ if (!isApp && headRefProblem === null) {
94
+ if (member.headRef === null) {
95
+ violations.push({ kind: 'headRefMissing', spaceId });
96
+ } else if (!headRefPresentInGraph(member, member.headRef.hash)) {
97
+ violations.push({ kind: 'headRefNotInGraph', spaceId, hash: member.headRef.hash });
98
+ }
99
+ }
100
+ }
101
+
102
+ if (opts?.declaredExtensions !== undefined) {
103
+ violations.push(...layoutViolations(input.spaces, opts.declaredExtensions));
104
+ }
105
+
106
+ if (opts?.checkContracts === true) {
107
+ violations.push(...contractViolations(input));
108
+ }
109
+
110
+ return violations;
111
+ }
112
+
113
+ export function loadProblemToViolation(
114
+ spaceId: string,
115
+ problem: PackageLoadProblem,
116
+ ): IntegrityViolation {
117
+ switch (problem.kind) {
118
+ case 'hashMismatch':
119
+ return {
120
+ kind: 'hashMismatch',
121
+ spaceId,
122
+ dirName: problem.dirName,
123
+ stored: problem.stored,
124
+ computed: problem.computed,
125
+ };
126
+ case 'providedInvariantsMismatch':
127
+ return { kind: 'providedInvariantsMismatch', spaceId, dirName: problem.dirName };
128
+ case 'packageUnloadable':
129
+ return {
130
+ kind: 'packageUnloadable',
131
+ spaceId,
132
+ dirName: problem.dirName,
133
+ detail: problem.detail,
134
+ };
135
+ }
136
+ }
137
+
138
+ function duplicateMigrationHashViolations(
139
+ spaceId: string,
140
+ packages: readonly OnDiskMigrationPackage[],
141
+ ): readonly IntegrityViolation[] {
142
+ const dirNamesByHash = new Map<string, string[]>();
143
+ for (const pkg of packages) {
144
+ const hash = pkg.metadata.migrationHash;
145
+ const dirNames = dirNamesByHash.get(hash);
146
+ if (dirNames) dirNames.push(pkg.dirName);
147
+ else dirNamesByHash.set(hash, [pkg.dirName]);
148
+ }
149
+
150
+ const out: IntegrityViolation[] = [];
151
+ for (const [migrationHash, dirNames] of dirNamesByHash) {
152
+ if (dirNames.length > 1) {
153
+ out.push({
154
+ kind: 'duplicateMigrationHash',
155
+ spaceId,
156
+ migrationHash,
157
+ dirNames: [...dirNames].sort(),
158
+ });
159
+ }
160
+ }
161
+ return out;
162
+ }
163
+
164
+ /**
165
+ * Whether a space's head-ref hash is present in its reconstructed graph.
166
+ * An empty graph is reachable only by the empty-contract sentinel.
167
+ */
168
+ function headRefPresentInGraph(member: ContractSpaceMember, headHash: string): boolean {
169
+ const graph = member.graph();
170
+ if (graph.nodes.size === 0) {
171
+ return headHash === EMPTY_CONTRACT_HASH;
172
+ }
173
+ return graph.nodes.has(headHash);
174
+ }
175
+
176
+ function layoutViolations(
177
+ spaces: readonly IntegritySpaceState[],
178
+ declaredExtensions: readonly DeclaredExtensionEntry[],
179
+ ): readonly IntegrityViolation[] {
180
+ const out: IntegrityViolation[] = [];
181
+ const extensionSpaceIds = new Set(spaces.filter((s) => !s.isApp).map((s) => s.member.spaceId));
182
+ const declaredIds = new Set(declaredExtensions.map((d) => d.id));
183
+
184
+ for (const id of [...extensionSpaceIds].sort()) {
185
+ if (!declaredIds.has(id)) {
186
+ out.push({ kind: 'orphanSpaceDir', spaceId: id });
187
+ }
188
+ }
189
+ for (const id of [...declaredIds].sort()) {
190
+ if (!extensionSpaceIds.has(id)) {
191
+ out.push({ kind: 'declaredButUnmigrated', spaceId: id });
192
+ }
193
+ }
194
+ return out;
195
+ }
196
+
197
+ function contractViolations(input: IntegrityComputationInput): readonly IntegrityViolation[] {
198
+ const out: IntegrityViolation[] = [];
199
+ const elementClaimedBy = new Map<string, string[]>();
200
+
201
+ for (const { member } of input.spaces) {
202
+ let contract: ReturnType<ContractSpaceMember['contract']>;
203
+ try {
204
+ contract = member.contract();
205
+ } catch (error) {
206
+ out.push({ kind: 'contractUnreadable', spaceId: member.spaceId, detail: detailOf(error) });
207
+ continue;
208
+ }
209
+
210
+ if (contract.target !== input.targetId) {
211
+ out.push({
212
+ kind: 'targetMismatch',
213
+ spaceId: member.spaceId,
214
+ expected: input.targetId,
215
+ actual: contract.target,
216
+ });
217
+ }
218
+
219
+ for (const elementName of extractStorageElementNames(contract)) {
220
+ const claimers = elementClaimedBy.get(elementName);
221
+ if (claimers) claimers.push(member.spaceId);
222
+ else elementClaimedBy.set(elementName, [member.spaceId]);
223
+ }
224
+ }
225
+
226
+ const disjointness: IntegrityViolation[] = [];
227
+ for (const [element, claimedBy] of elementClaimedBy) {
228
+ if (claimedBy.length > 1) {
229
+ disjointness.push({ kind: 'disjointness', element, claimedBy: [...claimedBy].sort() });
230
+ }
231
+ }
232
+ disjointness.sort((a, b) =>
233
+ a.kind === 'disjointness' && b.kind === 'disjointness' ? a.element.localeCompare(b.element) : 0,
234
+ );
235
+ out.push(...disjointness);
236
+ return out;
237
+ }
238
+
239
+ function detailOf(error: unknown): string {
240
+ if (MigrationToolsError.is(error)) return error.why;
241
+ if (error instanceof Error) return error.message;
242
+ return String(error);
243
+ }