@topogram/cli 0.3.34

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 (257) hide show
  1. package/ARCHITECTURE.md +67 -0
  2. package/CHANGELOG.md +240 -0
  3. package/README.md +223 -0
  4. package/package.json +51 -0
  5. package/src/adoption/index.js +3 -0
  6. package/src/adoption/plan.js +702 -0
  7. package/src/adoption/reporting.js +464 -0
  8. package/src/adoption/review-groups.js +313 -0
  9. package/src/agent-ops/query-builders.js +5012 -0
  10. package/src/archive/archive.js +141 -0
  11. package/src/archive/compact.js +26 -0
  12. package/src/archive/jsonl.js +70 -0
  13. package/src/archive/resolver-bridge.js +82 -0
  14. package/src/archive/schema.js +87 -0
  15. package/src/archive/unarchive.js +108 -0
  16. package/src/catalog.js +752 -0
  17. package/src/cli/catalog-alias.js +166 -0
  18. package/src/cli.js +9738 -0
  19. package/src/component-behavior.js +173 -0
  20. package/src/example-implementation.js +91 -0
  21. package/src/format.js +19 -0
  22. package/src/generator/adapters.d.ts +4 -0
  23. package/src/generator/adapters.js +325 -0
  24. package/src/generator/api.d.ts +1 -0
  25. package/src/generator/api.js +1196 -0
  26. package/src/generator/check.js +355 -0
  27. package/src/generator/component-conformance.js +767 -0
  28. package/src/generator/components.js +39 -0
  29. package/src/generator/context/bundle.js +291 -0
  30. package/src/generator/context/diff.js +256 -0
  31. package/src/generator/context/digest.js +182 -0
  32. package/src/generator/context/domain-coverage.js +94 -0
  33. package/src/generator/context/domain-page.js +137 -0
  34. package/src/generator/context/index.js +42 -0
  35. package/src/generator/context/report.js +121 -0
  36. package/src/generator/context/shared.js +1397 -0
  37. package/src/generator/context/slice.js +703 -0
  38. package/src/generator/context/task-mode.js +466 -0
  39. package/src/generator/docs.js +327 -0
  40. package/src/generator/index.js +161 -0
  41. package/src/generator/native/parity-bundle.js +311 -0
  42. package/src/generator/output.js +300 -0
  43. package/src/generator/registry.js +482 -0
  44. package/src/generator/runtime/app-bundle.js +456 -0
  45. package/src/generator/runtime/bundle-shared.js +166 -0
  46. package/src/generator/runtime/compile-check.js +163 -0
  47. package/src/generator/runtime/deployment.js +287 -0
  48. package/src/generator/runtime/environment.js +635 -0
  49. package/src/generator/runtime/index.js +32 -0
  50. package/src/generator/runtime/runtime-check.js +554 -0
  51. package/src/generator/runtime/shared.js +515 -0
  52. package/src/generator/runtime/smoke.js +219 -0
  53. package/src/generator/schema.js +204 -0
  54. package/src/generator/sdlc/board.js +66 -0
  55. package/src/generator/sdlc/doc-page.js +53 -0
  56. package/src/generator/sdlc/index.js +23 -0
  57. package/src/generator/sdlc/release-notes.js +62 -0
  58. package/src/generator/sdlc/traceability-matrix.js +65 -0
  59. package/src/generator/shared.js +29 -0
  60. package/src/generator/surfaces/contracts.js +146 -0
  61. package/src/generator/surfaces/databases/contract.js +40 -0
  62. package/src/generator/surfaces/databases/index.js +84 -0
  63. package/src/generator/surfaces/databases/lifecycle-shared.d.ts +1 -0
  64. package/src/generator/surfaces/databases/lifecycle-shared.js +612 -0
  65. package/src/generator/surfaces/databases/migration-plan.js +281 -0
  66. package/src/generator/surfaces/databases/postgres/capabilities.js +14 -0
  67. package/src/generator/surfaces/databases/postgres/drizzle.js +99 -0
  68. package/src/generator/surfaces/databases/postgres/index.js +9 -0
  69. package/src/generator/surfaces/databases/postgres/lifecycle.js +16 -0
  70. package/src/generator/surfaces/databases/postgres/prisma.js +159 -0
  71. package/src/generator/surfaces/databases/postgres/sql-migration.js +102 -0
  72. package/src/generator/surfaces/databases/postgres/sql-schema.js +34 -0
  73. package/src/generator/surfaces/databases/shared.d.ts +1 -0
  74. package/src/generator/surfaces/databases/shared.js +350 -0
  75. package/src/generator/surfaces/databases/snapshot.js +96 -0
  76. package/src/generator/surfaces/databases/sqlite/capabilities.js +14 -0
  77. package/src/generator/surfaces/databases/sqlite/index.js +8 -0
  78. package/src/generator/surfaces/databases/sqlite/lifecycle.js +16 -0
  79. package/src/generator/surfaces/databases/sqlite/prisma.js +143 -0
  80. package/src/generator/surfaces/databases/sqlite/sql-migration.js +65 -0
  81. package/src/generator/surfaces/databases/sqlite/sql-schema.js +27 -0
  82. package/src/generator/surfaces/index.js +25 -0
  83. package/src/generator/surfaces/native/swiftui-app.js +38 -0
  84. package/src/generator/surfaces/native/swiftui-templates/Package.swift.txt +20 -0
  85. package/src/generator/surfaces/native/swiftui-templates/README.generated.md +26 -0
  86. package/src/generator/surfaces/native/swiftui-templates/runtime/DynamicScreens.swift +682 -0
  87. package/src/generator/surfaces/native/swiftui-templates/runtime/TodoAPIClient.swift +156 -0
  88. package/src/generator/surfaces/native/swiftui-templates/runtime/TodoSwiftUIApp.swift +44 -0
  89. package/src/generator/surfaces/native/swiftui-templates/runtime/Visibility.swift +183 -0
  90. package/src/generator/surfaces/services/express.d.ts +1 -0
  91. package/src/generator/surfaces/services/express.js +766 -0
  92. package/src/generator/surfaces/services/hono.d.ts +1 -0
  93. package/src/generator/surfaces/services/hono.js +204 -0
  94. package/src/generator/surfaces/services/index.js +42 -0
  95. package/src/generator/surfaces/services/persistence-wiring.js +240 -0
  96. package/src/generator/surfaces/services/runtime-helpers.js +631 -0
  97. package/src/generator/surfaces/services/server-contract.js +80 -0
  98. package/src/generator/surfaces/services/stateless.d.ts +1 -0
  99. package/src/generator/surfaces/services/stateless.js +97 -0
  100. package/src/generator/surfaces/shared.js +64 -0
  101. package/src/generator/surfaces/web/api-client.js +1 -0
  102. package/src/generator/surfaces/web/forms.js +1 -0
  103. package/src/generator/surfaces/web/index.d.ts +2 -0
  104. package/src/generator/surfaces/web/index.js +53 -0
  105. package/src/generator/surfaces/web/react-components.js +248 -0
  106. package/src/generator/surfaces/web/react.js +538 -0
  107. package/src/generator/surfaces/web/routes.js +1 -0
  108. package/src/generator/surfaces/web/screens.js +1 -0
  109. package/src/generator/surfaces/web/shared.js +369 -0
  110. package/src/generator/surfaces/web/sveltekit-actions.js +28 -0
  111. package/src/generator/surfaces/web/sveltekit-components.js +234 -0
  112. package/src/generator/surfaces/web/sveltekit.js +426 -0
  113. package/src/generator/surfaces/web/ui-web-contract.js +65 -0
  114. package/src/generator/surfaces/web/vanilla.js +239 -0
  115. package/src/generator/verification.js +84 -0
  116. package/src/generator.js +1 -0
  117. package/src/import/core/context.js +52 -0
  118. package/src/import/core/contracts.js +23 -0
  119. package/src/import/core/registry.js +81 -0
  120. package/src/import/core/runner.js +646 -0
  121. package/src/import/core/shared.js +910 -0
  122. package/src/import/enrichers/auth-session.js +18 -0
  123. package/src/import/enrichers/django-rest.js +226 -0
  124. package/src/import/enrichers/doc-linking.js +20 -0
  125. package/src/import/enrichers/rails-controllers.js +246 -0
  126. package/src/import/enrichers/rails-models.js +130 -0
  127. package/src/import/enrichers/workflow-target-state.js +10 -0
  128. package/src/import/extractors/api/aspnet-core.js +304 -0
  129. package/src/import/extractors/api/django-routes.js +318 -0
  130. package/src/import/extractors/api/express.js +154 -0
  131. package/src/import/extractors/api/fastify.js +371 -0
  132. package/src/import/extractors/api/flutter-dio.js +135 -0
  133. package/src/import/extractors/api/generic-route-fallback.js +90 -0
  134. package/src/import/extractors/api/graphql-code-first.js +565 -0
  135. package/src/import/extractors/api/graphql-sdl.js +309 -0
  136. package/src/import/extractors/api/jaxrs.js +303 -0
  137. package/src/import/extractors/api/micronaut.js +213 -0
  138. package/src/import/extractors/api/next-route.js +50 -0
  139. package/src/import/extractors/api/next-server-action.js +51 -0
  140. package/src/import/extractors/api/nextauth.js +52 -0
  141. package/src/import/extractors/api/openapi-code.js +242 -0
  142. package/src/import/extractors/api/openapi.js +232 -0
  143. package/src/import/extractors/api/rails-routes.js +230 -0
  144. package/src/import/extractors/api/react-native-repository.js +128 -0
  145. package/src/import/extractors/api/retrofit.js +103 -0
  146. package/src/import/extractors/api/spring-web.js +372 -0
  147. package/src/import/extractors/api/swift-webapi.js +116 -0
  148. package/src/import/extractors/api/trpc.js +212 -0
  149. package/src/import/extractors/db/django-models.js +232 -0
  150. package/src/import/extractors/db/dotnet-models.js +93 -0
  151. package/src/import/extractors/db/drizzle.js +242 -0
  152. package/src/import/extractors/db/ef-core.js +221 -0
  153. package/src/import/extractors/db/flutter-entities.js +120 -0
  154. package/src/import/extractors/db/jpa.js +120 -0
  155. package/src/import/extractors/db/liquibase.js +180 -0
  156. package/src/import/extractors/db/mybatis-xml.js +145 -0
  157. package/src/import/extractors/db/prisma.js +185 -0
  158. package/src/import/extractors/db/rails-schema.js +175 -0
  159. package/src/import/extractors/db/react-native-entities.js +95 -0
  160. package/src/import/extractors/db/room.js +193 -0
  161. package/src/import/extractors/db/snapshot.js +112 -0
  162. package/src/import/extractors/db/sql.js +180 -0
  163. package/src/import/extractors/db/swiftdata.js +137 -0
  164. package/src/import/extractors/ui/android-compose.js +230 -0
  165. package/src/import/extractors/ui/backend-only.js +70 -0
  166. package/src/import/extractors/ui/blazor.js +227 -0
  167. package/src/import/extractors/ui/flutter-screens.js +152 -0
  168. package/src/import/extractors/ui/maui-xaml.js +135 -0
  169. package/src/import/extractors/ui/next-app-router.js +83 -0
  170. package/src/import/extractors/ui/next-pages-router.js +141 -0
  171. package/src/import/extractors/ui/razor-pages.js +181 -0
  172. package/src/import/extractors/ui/react-native-screens.js +166 -0
  173. package/src/import/extractors/ui/react-router.js +139 -0
  174. package/src/import/extractors/ui/sveltekit.js +123 -0
  175. package/src/import/extractors/ui/swiftui.js +193 -0
  176. package/src/import/extractors/ui/uikit.js +175 -0
  177. package/src/import/extractors/verification/generic.js +290 -0
  178. package/src/import/extractors/workflows/generic.js +137 -0
  179. package/src/import/index.js +7 -0
  180. package/src/import/provenance.js +158 -0
  181. package/src/new-project.js +2107 -0
  182. package/src/parser.js +439 -0
  183. package/src/policy/review-boundaries.js +165 -0
  184. package/src/project-config.js +535 -0
  185. package/src/proofs/backend-parity.js +19 -0
  186. package/src/proofs/contract-audit.js +220 -0
  187. package/src/proofs/ios-parity.js +7 -0
  188. package/src/proofs/issues-parity.js +10 -0
  189. package/src/proofs/web-parity.js +50 -0
  190. package/src/realization/api/build-api-realization.js +5 -0
  191. package/src/realization/api/index.js +1 -0
  192. package/src/realization/backend/build-backend-runtime-realization.js +82 -0
  193. package/src/realization/backend/index.d.ts +1 -0
  194. package/src/realization/backend/index.js +4 -0
  195. package/src/realization/db/build-db-realization.js +17 -0
  196. package/src/realization/db/index.js +3 -0
  197. package/src/realization/db/migration-plan.js +5 -0
  198. package/src/realization/db/snapshot.js +5 -0
  199. package/src/realization/ui/build-ui-shared-realization.js +305 -0
  200. package/src/realization/ui/build-web-realization.js +189 -0
  201. package/src/realization/ui/index.js +2 -0
  202. package/src/reconcile/docs.js +280 -0
  203. package/src/reconcile/index.js +3 -0
  204. package/src/reconcile/journeys.js +441 -0
  205. package/src/resolver/docs.js +1 -0
  206. package/src/resolver/enrich/acceptance-criterion.js +14 -0
  207. package/src/resolver/enrich/bug.js +12 -0
  208. package/src/resolver/enrich/component.js +2 -0
  209. package/src/resolver/enrich/index.js +1 -0
  210. package/src/resolver/enrich/pitch.js +18 -0
  211. package/src/resolver/enrich/requirement.js +20 -0
  212. package/src/resolver/enrich/task.js +16 -0
  213. package/src/resolver/expressions.js +1 -0
  214. package/src/resolver/index.js +2422 -0
  215. package/src/resolver/normalize.js +1 -0
  216. package/src/resolver.js +1 -0
  217. package/src/sdlc/adopt.js +65 -0
  218. package/src/sdlc/check.js +86 -0
  219. package/src/sdlc/dod/acceptance-criterion.js +22 -0
  220. package/src/sdlc/dod/bug.js +26 -0
  221. package/src/sdlc/dod/document.js +23 -0
  222. package/src/sdlc/dod/index.js +25 -0
  223. package/src/sdlc/dod/pitch.js +23 -0
  224. package/src/sdlc/dod/requirement.js +34 -0
  225. package/src/sdlc/dod/task.js +39 -0
  226. package/src/sdlc/explain.js +116 -0
  227. package/src/sdlc/history.js +80 -0
  228. package/src/sdlc/paths.js +11 -0
  229. package/src/sdlc/release.js +106 -0
  230. package/src/sdlc/scaffold.js +89 -0
  231. package/src/sdlc/status-filter.js +54 -0
  232. package/src/sdlc/transition.js +112 -0
  233. package/src/sdlc/transitions/acceptance-criterion.js +28 -0
  234. package/src/sdlc/transitions/bug.js +31 -0
  235. package/src/sdlc/transitions/document.js +29 -0
  236. package/src/sdlc/transitions/index.js +56 -0
  237. package/src/sdlc/transitions/pitch.js +34 -0
  238. package/src/sdlc/transitions/requirement.js +31 -0
  239. package/src/sdlc/transitions/task.js +34 -0
  240. package/src/template-trust.js +597 -0
  241. package/src/validator/expressions.js +1 -0
  242. package/src/validator/index.js +3424 -0
  243. package/src/validator/kinds.js +346 -0
  244. package/src/validator/per-kind/acceptance-criterion.js +91 -0
  245. package/src/validator/per-kind/bug.js +77 -0
  246. package/src/validator/per-kind/component.js +274 -0
  247. package/src/validator/per-kind/domain.js +205 -0
  248. package/src/validator/per-kind/pitch.js +101 -0
  249. package/src/validator/per-kind/requirement.js +75 -0
  250. package/src/validator/per-kind/task.js +96 -0
  251. package/src/validator/registry.js +1 -0
  252. package/src/validator/utils.js +12 -0
  253. package/src/validator.js +1 -0
  254. package/src/workflows.js +7597 -0
  255. package/src/workspace-docs.js +265 -0
  256. package/template-helpers/react.js +5 -0
  257. package/template-helpers/sveltekit.js +5 -0
@@ -0,0 +1,597 @@
1
+ // @ts-check
2
+
3
+ import fs from "node:fs";
4
+ import crypto from "node:crypto";
5
+ import path from "node:path";
6
+
7
+ export const TEMPLATE_TRUST_FILE = ".topogram-template-trust.json";
8
+ export const TEMPLATE_TRUST_POLICY = "topogram-template-executable-implementation-v1";
9
+
10
+ /**
11
+ * @typedef {Object} TemplateTrustRecord
12
+ * @property {string} version
13
+ * @property {string} trustPolicy
14
+ * @property {string} trustedAt
15
+ * @property {{ id: string|null, version: string|null, source: string|null, sourceSpec: string|null, requested: string|null, sourceRoot: string|null, catalog?: Record<string, any>|null }} template
16
+ * @property {{ id: string|null, module: string, export: string }} implementation
17
+ * @property {{ algorithm: "sha256", root: string, digest: string, files: Array<{ path: string, sha256: string, size: number }> }} content
18
+ */
19
+
20
+ const IGNORED_IMPLEMENTATION_ENTRIES = new Set([".DS_Store", "node_modules", ".tmp"]);
21
+ const MAX_TEXT_DIFF_BYTES = 256 * 1024;
22
+
23
+ /**
24
+ * @param {string} parent
25
+ * @param {string} child
26
+ * @returns {boolean}
27
+ */
28
+ function isSameOrInside(parent, child) {
29
+ const relative = path.relative(path.resolve(parent), path.resolve(child));
30
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
31
+ }
32
+
33
+ /**
34
+ * @param {string} value
35
+ * @returns {string}
36
+ */
37
+ function normalizeRoot(value) {
38
+ return String(value || "").replace(/\\/g, "/");
39
+ }
40
+
41
+ /**
42
+ * @param {string} value
43
+ * @returns {string}
44
+ */
45
+ function normalizeRelativePath(value) {
46
+ return value.replace(/\\/g, "/");
47
+ }
48
+
49
+ /**
50
+ * @param {string} value
51
+ * @returns {string}
52
+ */
53
+ function escapeDiffPath(value) {
54
+ return value.replace(/\t/g, "\\t").replace(/\n/g, "\\n");
55
+ }
56
+
57
+ /**
58
+ * @param {any} bytes
59
+ * @returns {boolean}
60
+ */
61
+ function isLikelyText(bytes) {
62
+ if (bytes.includes(0)) {
63
+ return false;
64
+ }
65
+ const length = Math.min(bytes.length, 4096);
66
+ let suspicious = 0;
67
+ for (let index = 0; index < length; index += 1) {
68
+ const byte = bytes[index];
69
+ if (byte === 9 || byte === 10 || byte === 13) {
70
+ continue;
71
+ }
72
+ if (byte < 32 || byte === 127) {
73
+ suspicious += 1;
74
+ }
75
+ }
76
+ return length === 0 || suspicious / length < 0.02;
77
+ }
78
+
79
+ /**
80
+ * @param {string} text
81
+ * @returns {string[]}
82
+ */
83
+ function linesForDiff(text) {
84
+ const lines = text.split("\n");
85
+ if (lines.at(-1) === "") {
86
+ lines.pop();
87
+ }
88
+ return lines;
89
+ }
90
+
91
+ /**
92
+ * @param {string[]} before
93
+ * @param {string[]} after
94
+ * @returns {Array<{ type: "same"|"added"|"removed", text: string }>}
95
+ */
96
+ function diffLines(before, after) {
97
+ const rows = before.length;
98
+ const columns = after.length;
99
+ /** @type {number[][]} */
100
+ const table = Array.from({ length: rows + 1 }, () => Array(columns + 1).fill(0));
101
+ for (let row = rows - 1; row >= 0; row -= 1) {
102
+ for (let column = columns - 1; column >= 0; column -= 1) {
103
+ table[row][column] = before[row] === after[column]
104
+ ? table[row + 1][column + 1] + 1
105
+ : Math.max(table[row + 1][column], table[row][column + 1]);
106
+ }
107
+ }
108
+ /** @type {Array<{ type: "same"|"added"|"removed", text: string }>} */
109
+ const changes = [];
110
+ let row = 0;
111
+ let column = 0;
112
+ while (row < rows && column < columns) {
113
+ if (before[row] === after[column]) {
114
+ changes.push({ type: "same", text: before[row] });
115
+ row += 1;
116
+ column += 1;
117
+ } else if (table[row + 1][column] >= table[row][column + 1]) {
118
+ changes.push({ type: "removed", text: before[row] });
119
+ row += 1;
120
+ } else {
121
+ changes.push({ type: "added", text: after[column] });
122
+ column += 1;
123
+ }
124
+ }
125
+ while (row < rows) {
126
+ changes.push({ type: "removed", text: before[row] });
127
+ row += 1;
128
+ }
129
+ while (column < columns) {
130
+ changes.push({ type: "added", text: after[column] });
131
+ column += 1;
132
+ }
133
+ return changes;
134
+ }
135
+
136
+ /**
137
+ * @param {string} relativePath
138
+ * @param {string|null} beforeText
139
+ * @param {string|null} afterText
140
+ * @returns {string|null}
141
+ */
142
+ function unifiedTextDiff(relativePath, beforeText, afterText) {
143
+ if (beforeText === null && afterText === null) {
144
+ return null;
145
+ }
146
+ const beforeLines = beforeText === null ? [] : linesForDiff(beforeText);
147
+ const afterLines = afterText === null ? [] : linesForDiff(afterText);
148
+ const changes = diffLines(beforeLines, afterLines);
149
+ const lines = [
150
+ `--- a/implementation/${escapeDiffPath(relativePath)}`,
151
+ `+++ b/implementation/${escapeDiffPath(relativePath)}`,
152
+ `@@ -1,${beforeLines.length} +1,${afterLines.length} @@`
153
+ ];
154
+ for (const change of changes) {
155
+ const prefix = change.type === "added" ? "+" : change.type === "removed" ? "-" : " ";
156
+ lines.push(`${prefix}${change.text}`);
157
+ }
158
+ return `${lines.join("\n")}\n`;
159
+ }
160
+
161
+ /**
162
+ * @param {string} filePath
163
+ * @returns {{ text: string|null, binary: boolean, omitted: boolean }}
164
+ */
165
+ function readReviewText(filePath) {
166
+ const bytes = fs.readFileSync(filePath);
167
+ if (bytes.length > MAX_TEXT_DIFF_BYTES) {
168
+ return { text: null, binary: false, omitted: true };
169
+ }
170
+ if (!isLikelyText(bytes)) {
171
+ return { text: null, binary: true, omitted: false };
172
+ }
173
+ return { text: bytes.toString("utf8"), binary: false, omitted: false };
174
+ }
175
+
176
+ /**
177
+ * @param {string} implementationRoot
178
+ * @param {string} currentDir
179
+ * @param {string[]} files
180
+ * @returns {void}
181
+ */
182
+ function collectImplementationFiles(implementationRoot, currentDir, files) {
183
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
184
+ if (IGNORED_IMPLEMENTATION_ENTRIES.has(entry.name)) {
185
+ continue;
186
+ }
187
+ const entryPath = path.join(currentDir, entry.name);
188
+ if (entry.isDirectory()) {
189
+ collectImplementationFiles(implementationRoot, entryPath, files);
190
+ continue;
191
+ }
192
+ if (entry.isFile()) {
193
+ files.push(normalizeRelativePath(path.relative(implementationRoot, entryPath)));
194
+ }
195
+ }
196
+ }
197
+
198
+ /**
199
+ * @param {string} configDir
200
+ * @returns {{ algorithm: "sha256", root: string, digest: string, files: Array<{ path: string, sha256: string, size: number }> }}
201
+ */
202
+ export function hashImplementationContent(configDir) {
203
+ const implementationRoot = path.join(configDir, "implementation");
204
+ if (!fs.existsSync(implementationRoot) || !fs.statSync(implementationRoot).isDirectory()) {
205
+ throw new Error(`Cannot trust template implementation because ${normalizeRoot(implementationRoot)} does not exist.`);
206
+ }
207
+ /** @type {string[]} */
208
+ const relativePaths = [];
209
+ collectImplementationFiles(implementationRoot, implementationRoot, relativePaths);
210
+ relativePaths.sort((a, b) => a.localeCompare(b));
211
+ const files = relativePaths.map((relativePath) => {
212
+ const filePath = path.join(implementationRoot, relativePath);
213
+ const bytes = fs.readFileSync(filePath);
214
+ return {
215
+ path: relativePath,
216
+ sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
217
+ size: bytes.length
218
+ };
219
+ });
220
+ const aggregate = crypto.createHash("sha256");
221
+ for (const file of files) {
222
+ aggregate.update(file.path);
223
+ aggregate.update("\0");
224
+ aggregate.update(file.sha256);
225
+ aggregate.update("\0");
226
+ aggregate.update(String(file.size));
227
+ aggregate.update("\0");
228
+ }
229
+ return {
230
+ algorithm: "sha256",
231
+ root: "implementation",
232
+ digest: aggregate.digest("hex"),
233
+ files
234
+ };
235
+ }
236
+
237
+ /**
238
+ * @param {Record<string, any>} config
239
+ * @returns {{ id: string|null, module: string, export: string }}
240
+ */
241
+ export function implementationTrustFingerprint(config) {
242
+ const implementationModule = config.implementation_module || config.module;
243
+ if (!implementationModule || typeof implementationModule !== "string") {
244
+ throw new Error("Topogram implementation config is missing implementation module.");
245
+ }
246
+ return {
247
+ id: config.implementation_id || config.id || null,
248
+ module: implementationModule,
249
+ export: config.implementation_export || config.export || "default"
250
+ };
251
+ }
252
+
253
+ /**
254
+ * @param {{ config: Record<string, any>, configDir: string }} implementationInfo
255
+ * @returns {boolean}
256
+ */
257
+ export function implementationRequiresTrust(implementationInfo) {
258
+ const fingerprint = implementationTrustFingerprint(implementationInfo.config);
259
+ const modulePath = path.resolve(implementationInfo.configDir, fingerprint.module);
260
+ const implementationRoot = path.resolve(implementationInfo.configDir, "implementation");
261
+ return isSameOrInside(implementationRoot, modulePath);
262
+ }
263
+
264
+ /**
265
+ * @param {string} configDir
266
+ * @returns {TemplateTrustRecord|null}
267
+ */
268
+ export function readTemplateTrustRecord(configDir) {
269
+ const trustPath = path.join(configDir, TEMPLATE_TRUST_FILE);
270
+ if (!fs.existsSync(trustPath)) {
271
+ return null;
272
+ }
273
+ return /** @type {TemplateTrustRecord} */ (JSON.parse(fs.readFileSync(trustPath, "utf8")));
274
+ }
275
+
276
+ /**
277
+ * @param {string} configDir
278
+ * @param {Record<string, any>} projectConfig
279
+ * @param {{ id: string|null, module: string, export: string }} implementation
280
+ * @returns {TemplateTrustRecord}
281
+ */
282
+ function buildTrustRecord(configDir, projectConfig, implementation) {
283
+ const template = projectConfig.template || {};
284
+ const content = hashImplementationContent(configDir);
285
+ return {
286
+ version: "1",
287
+ trustPolicy: TEMPLATE_TRUST_POLICY,
288
+ trustedAt: new Date().toISOString(),
289
+ template: {
290
+ id: typeof template.id === "string" ? template.id : null,
291
+ version: typeof template.version === "string" ? template.version : null,
292
+ source: typeof template.source === "string" ? template.source : null,
293
+ sourceSpec: typeof template.sourceSpec === "string" ? template.sourceSpec : null,
294
+ requested: typeof template.requested === "string" ? template.requested : null,
295
+ sourceRoot: typeof template.sourceRoot === "string" ? template.sourceRoot : null,
296
+ catalog: template.catalog && typeof template.catalog === "object" && !Array.isArray(template.catalog)
297
+ ? template.catalog
298
+ : null
299
+ },
300
+ implementation,
301
+ content
302
+ };
303
+ }
304
+
305
+ /**
306
+ * @param {string} configDir
307
+ * @param {Record<string, any>} projectConfig
308
+ * @returns {TemplateTrustRecord}
309
+ */
310
+ export function writeTemplateTrustRecord(configDir, projectConfig) {
311
+ const implementationConfig = projectConfig.implementation;
312
+ if (!implementationConfig) {
313
+ throw new Error("Cannot trust template implementation because topogram.project.json has no implementation config.");
314
+ }
315
+ const implementation = implementationTrustFingerprint(implementationConfig);
316
+ const record = buildTrustRecord(configDir, projectConfig, implementation);
317
+ fs.writeFileSync(path.join(configDir, TEMPLATE_TRUST_FILE), `${JSON.stringify(record, null, 2)}\n`, "utf8");
318
+ return record;
319
+ }
320
+
321
+ /**
322
+ * @param {{ config: Record<string, any>, configPath: string|null, configDir: string }} implementationInfo
323
+ * @param {Record<string, any>|null} projectConfig
324
+ * @returns {void}
325
+ */
326
+ export function assertTrustedImplementation(implementationInfo, projectConfig = null) {
327
+ const status = getTemplateTrustStatus(implementationInfo, projectConfig);
328
+ if (!status.requiresTrust || status.ok) {
329
+ return;
330
+ }
331
+ const firstIssue = status.issues[0] || "implementation trust is invalid";
332
+ throw new Error(
333
+ `${firstIssue}. Review implementation/ and run 'topogram trust template' to trust the current files.`
334
+ );
335
+ }
336
+
337
+ /**
338
+ * @param {Map<string, { path: string, sha256: string, size: number }>} trustedByPath
339
+ * @param {Map<string, { path: string, sha256: string, size: number }>} currentByPath
340
+ * @returns {{ added: string[], removed: string[], changed: string[] }}
341
+ */
342
+ function diffContentFiles(trustedByPath, currentByPath) {
343
+ /** @type {string[]} */
344
+ const added = [];
345
+ /** @type {string[]} */
346
+ const removed = [];
347
+ /** @type {string[]} */
348
+ const changed = [];
349
+ for (const [filePath, current] of currentByPath) {
350
+ const trusted = trustedByPath.get(filePath);
351
+ if (!trusted) {
352
+ added.push(filePath);
353
+ } else if (trusted.sha256 !== current.sha256 || trusted.size !== current.size) {
354
+ changed.push(filePath);
355
+ }
356
+ }
357
+ for (const filePath of trustedByPath.keys()) {
358
+ if (!currentByPath.has(filePath)) {
359
+ removed.push(filePath);
360
+ }
361
+ }
362
+ return {
363
+ added: added.sort((a, b) => a.localeCompare(b)),
364
+ removed: removed.sort((a, b) => a.localeCompare(b)),
365
+ changed: changed.sort((a, b) => a.localeCompare(b))
366
+ };
367
+ }
368
+
369
+ /**
370
+ * @param {string} configDir
371
+ * @param {string} relativePath
372
+ * @param {{ path: string, sha256: string, size: number }|null} file
373
+ * @returns {{ path: string, sha256: string|null, size: number|null, binary: boolean, diffOmitted: boolean, text: string|null }}
374
+ */
375
+ function implementationReviewFile(configDir, relativePath, file) {
376
+ if (!file) {
377
+ return { path: relativePath, sha256: null, size: null, binary: false, diffOmitted: false, text: null };
378
+ }
379
+ const reviewText = readReviewText(path.join(configDir, "implementation", relativePath));
380
+ return {
381
+ path: relativePath,
382
+ sha256: file.sha256,
383
+ size: file.size,
384
+ binary: reviewText.binary,
385
+ diffOmitted: reviewText.omitted,
386
+ text: reviewText.text
387
+ };
388
+ }
389
+
390
+ /**
391
+ * @param {{ config: Record<string, any>, configPath: string|null, configDir: string }} implementationInfo
392
+ * @param {Record<string, any>|null} projectConfig
393
+ * @returns {{ ok: boolean, requiresTrust: boolean, trustPath: string, trustRecord: TemplateTrustRecord|null, template: { id: string|null, version: string|null, source: string|null, sourceSpec: string|null, requested: string|null, sourceRoot: string|null, catalog?: Record<string, any>|null, includesExecutableImplementation: boolean|null }, implementation: { id: string|null, module: string|null, export: string|null }, content: { trustedDigest: string|null, currentDigest: string|null, added: string[], removed: string[], changed: string[] }, issues: string[] }}
394
+ */
395
+ export function getTemplateTrustStatus(implementationInfo, projectConfig = null) {
396
+ if (!implementationRequiresTrust(implementationInfo)) {
397
+ return {
398
+ ok: true,
399
+ requiresTrust: false,
400
+ trustPath: path.join(implementationInfo.configDir, TEMPLATE_TRUST_FILE),
401
+ trustRecord: null,
402
+ template: { id: null, version: null, source: null, sourceSpec: null, requested: null, sourceRoot: null, catalog: null, includesExecutableImplementation: null },
403
+ implementation: { id: null, module: null, export: null },
404
+ content: { trustedDigest: null, currentDigest: null, added: [], removed: [], changed: [] },
405
+ issues: []
406
+ };
407
+ }
408
+ const fingerprint = implementationTrustFingerprint(implementationInfo.config);
409
+ const trustRecord = readTemplateTrustRecord(implementationInfo.configDir);
410
+ const configLabel = implementationInfo.configPath || "topogram.project.json";
411
+ const trustPath = path.join(implementationInfo.configDir, TEMPLATE_TRUST_FILE);
412
+ const projectTemplate = projectConfig?.template || null;
413
+ /** @type {string[]} */
414
+ const issues = [];
415
+ /** @type {{ trustedDigest: string|null, currentDigest: string|null, added: string[], removed: string[], changed: string[] }} */
416
+ const contentStatus = { trustedDigest: null, currentDigest: null, added: [], removed: [], changed: [] };
417
+
418
+ if (!trustRecord) {
419
+ issues.push(
420
+ `Refusing to load executable implementation '${fingerprint.module}' from ${normalizeRoot(configLabel)} without ${TEMPLATE_TRUST_FILE}`
421
+ );
422
+ } else {
423
+ if (trustRecord.trustPolicy !== TEMPLATE_TRUST_POLICY) {
424
+ issues.push(`${TEMPLATE_TRUST_FILE} uses unsupported trust policy '${trustRecord.trustPolicy}'`);
425
+ }
426
+ if (trustRecord.implementation?.module !== fingerprint.module) {
427
+ issues.push(`${TEMPLATE_TRUST_FILE} trusts implementation module '${trustRecord.implementation?.module}', but ${normalizeRoot(configLabel)} uses '${fingerprint.module}'`);
428
+ }
429
+ if (trustRecord.implementation?.export !== fingerprint.export) {
430
+ issues.push(`${TEMPLATE_TRUST_FILE} trusts implementation export '${trustRecord.implementation?.export}', but ${normalizeRoot(configLabel)} uses '${fingerprint.export}'`);
431
+ }
432
+ const trustedId = trustRecord.implementation?.id || null;
433
+ if (trustedId !== (fingerprint.id || null)) {
434
+ issues.push(`${TEMPLATE_TRUST_FILE} trusts implementation id '${trustedId || ""}', but ${normalizeRoot(configLabel)} uses '${fingerprint.id || ""}'`);
435
+ }
436
+
437
+ if (projectTemplate?.id && trustRecord.template?.id !== projectTemplate.id) {
438
+ issues.push(`${TEMPLATE_TRUST_FILE} trusts template '${trustRecord.template?.id}', but topogram.project.json declares '${projectTemplate.id}'`);
439
+ }
440
+ if (projectTemplate?.version && trustRecord.template?.version !== projectTemplate.version) {
441
+ issues.push(`${TEMPLATE_TRUST_FILE} trusts template version '${trustRecord.template?.version}', but topogram.project.json declares '${projectTemplate.version}'`);
442
+ }
443
+
444
+ if (!trustRecord.content) {
445
+ issues.push(`${TEMPLATE_TRUST_FILE} is missing implementation content hashes`);
446
+ } else if (trustRecord.content.algorithm !== "sha256") {
447
+ issues.push(`${TEMPLATE_TRUST_FILE} uses unsupported content hash algorithm '${trustRecord.content.algorithm}'`);
448
+ } else {
449
+ const currentContent = hashImplementationContent(implementationInfo.configDir);
450
+ contentStatus.trustedDigest = trustRecord.content.digest;
451
+ contentStatus.currentDigest = currentContent.digest;
452
+ const trustedByPath = new Map((trustRecord.content.files || []).map((file) => [file.path, file]));
453
+ const currentByPath = new Map(currentContent.files.map((file) => [file.path, file]));
454
+ const diff = diffContentFiles(trustedByPath, currentByPath);
455
+ contentStatus.added = diff.added;
456
+ contentStatus.removed = diff.removed;
457
+ contentStatus.changed = diff.changed;
458
+ if (trustRecord.content.digest !== currentContent.digest) {
459
+ issues.push(`${TEMPLATE_TRUST_FILE} implementation content changed since it was last trusted`);
460
+ }
461
+ }
462
+ }
463
+
464
+ return {
465
+ ok: issues.length === 0,
466
+ requiresTrust: true,
467
+ trustPath,
468
+ trustRecord,
469
+ template: {
470
+ id: projectTemplate?.id || trustRecord?.template?.id || null,
471
+ version: projectTemplate?.version || trustRecord?.template?.version || null,
472
+ source: projectTemplate?.source || trustRecord?.template?.source || null,
473
+ sourceSpec: projectTemplate?.sourceSpec || trustRecord?.template?.sourceSpec || null,
474
+ requested: projectTemplate?.requested || trustRecord?.template?.requested || null,
475
+ sourceRoot: projectTemplate?.sourceRoot || trustRecord?.template?.sourceRoot || null,
476
+ catalog: projectTemplate?.catalog || trustRecord?.template?.catalog || null,
477
+ includesExecutableImplementation: typeof projectTemplate?.includesExecutableImplementation === "boolean"
478
+ ? projectTemplate.includesExecutableImplementation
479
+ : null
480
+ },
481
+ implementation: fingerprint,
482
+ content: contentStatus,
483
+ issues
484
+ };
485
+ }
486
+
487
+ /**
488
+ * @param {{ config: Record<string, any>, configPath: string|null, configDir: string }} implementationInfo
489
+ * @param {Record<string, any>|null} projectConfig
490
+ * @returns {{ ok: boolean, requiresTrust: boolean, status: ReturnType<typeof getTemplateTrustStatus>, files: Array<{ path: string, kind: "added"|"removed"|"changed", trusted: { path: string, sha256: string|null, size: number|null }|null, current: { path: string, sha256: string|null, size: number|null, binary: boolean, diffOmitted: boolean }|null, binary: boolean, diffOmitted: boolean, unifiedDiff: string|null }> }}
491
+ */
492
+ export function getTemplateTrustDiff(implementationInfo, projectConfig = null) {
493
+ const status = getTemplateTrustStatus(implementationInfo, projectConfig);
494
+ if (!status.requiresTrust || !status.trustRecord?.content) {
495
+ return { ok: status.ok, requiresTrust: status.requiresTrust, status, files: [] };
496
+ }
497
+ const currentContent = hashImplementationContent(implementationInfo.configDir);
498
+ const trustedByPath = new Map((status.trustRecord.content.files || []).map((file) => [file.path, file]));
499
+ const currentByPath = new Map(currentContent.files.map((file) => [file.path, file]));
500
+ /** @type {Array<{ path: string, kind: "added"|"removed"|"changed", trusted: { path: string, sha256: string|null, size: number|null }|null, current: { path: string, sha256: string|null, size: number|null, binary: boolean, diffOmitted: boolean }|null, binary: boolean, diffOmitted: boolean, unifiedDiff: string|null }>} */
501
+ const files = [];
502
+
503
+ for (const relativePath of status.content.changed) {
504
+ const trusted = trustedByPath.get(relativePath) || null;
505
+ const current = currentByPath.get(relativePath) || null;
506
+ const currentReview = implementationReviewFile(implementationInfo.configDir, relativePath, current);
507
+ files.push({
508
+ path: relativePath,
509
+ kind: "changed",
510
+ trusted: trusted ? { path: relativePath, sha256: trusted.sha256, size: trusted.size } : null,
511
+ current: {
512
+ path: relativePath,
513
+ sha256: currentReview.sha256,
514
+ size: currentReview.size,
515
+ binary: currentReview.binary,
516
+ diffOmitted: currentReview.diffOmitted
517
+ },
518
+ binary: currentReview.binary,
519
+ diffOmitted: true,
520
+ unifiedDiff: null
521
+ });
522
+ }
523
+ for (const relativePath of status.content.added) {
524
+ const current = currentByPath.get(relativePath) || null;
525
+ const currentReview = implementationReviewFile(implementationInfo.configDir, relativePath, current);
526
+ files.push({
527
+ path: relativePath,
528
+ kind: "added",
529
+ trusted: null,
530
+ current: {
531
+ path: relativePath,
532
+ sha256: currentReview.sha256,
533
+ size: currentReview.size,
534
+ binary: currentReview.binary,
535
+ diffOmitted: currentReview.diffOmitted
536
+ },
537
+ binary: currentReview.binary,
538
+ diffOmitted: currentReview.binary || currentReview.diffOmitted,
539
+ unifiedDiff: currentReview.binary || currentReview.diffOmitted
540
+ ? null
541
+ : unifiedTextDiff(relativePath, null, currentReview.text)
542
+ });
543
+ }
544
+ for (const relativePath of status.content.removed) {
545
+ const trusted = trustedByPath.get(relativePath) || null;
546
+ files.push({
547
+ path: relativePath,
548
+ kind: "removed",
549
+ trusted: trusted ? { path: relativePath, sha256: trusted.sha256, size: trusted.size } : null,
550
+ current: null,
551
+ binary: false,
552
+ diffOmitted: true,
553
+ unifiedDiff: null
554
+ });
555
+ }
556
+
557
+ files.sort((a, b) => a.path.localeCompare(b.path) || a.kind.localeCompare(b.kind));
558
+ return {
559
+ ok: status.ok,
560
+ requiresTrust: status.requiresTrust,
561
+ status,
562
+ files
563
+ };
564
+ }
565
+
566
+ /**
567
+ * @param {{ config: Record<string, any>, configPath: string|null, configDir: string }|null} projectConfigInfo
568
+ * @returns {{ ok: boolean, errors: Array<{ message: string, loc: any }> }}
569
+ */
570
+ export function validateProjectImplementationTrust(projectConfigInfo) {
571
+ if (!projectConfigInfo?.config?.implementation) {
572
+ return { ok: true, errors: [] };
573
+ }
574
+ const implementationModule =
575
+ projectConfigInfo.config.implementation.implementation_module ||
576
+ projectConfigInfo.config.implementation.module;
577
+ if (!implementationModule) {
578
+ return { ok: true, errors: [] };
579
+ }
580
+ const implementationInfo = {
581
+ config: projectConfigInfo.config.implementation,
582
+ configPath: projectConfigInfo.configPath,
583
+ configDir: projectConfigInfo.configDir
584
+ };
585
+ try {
586
+ assertTrustedImplementation(implementationInfo, projectConfigInfo.config);
587
+ return { ok: true, errors: [] };
588
+ } catch (error) {
589
+ return {
590
+ ok: false,
591
+ errors: [{
592
+ message: error instanceof Error ? error.message : String(error),
593
+ loc: null
594
+ }]
595
+ };
596
+ }
597
+ }
@@ -0,0 +1 @@
1
+ // Expression validation remains orchestrated from index.js after the initial split.