@documentation-robotics/cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/README.md +10 -14
  2. package/dist/analyzers/active-analyzer.js +62 -0
  3. package/dist/analyzers/active-analyzer.js.map +7 -0
  4. package/dist/analyzers/cards/build.js +21 -1
  5. package/dist/analyzers/cards/build.js.map +2 -2
  6. package/dist/analyzers/cards/extract.js +138 -0
  7. package/dist/analyzers/cards/extract.js.map +7 -0
  8. package/dist/analyzers/cards/routes.js +96 -0
  9. package/dist/analyzers/cards/routes.js.map +7 -0
  10. package/dist/analyzers/cbm-analyzer.js +76 -4
  11. package/dist/analyzers/cbm-analyzer.js.map +2 -2
  12. package/dist/analyzers/cbm-responses.js +12 -6
  13. package/dist/analyzers/cbm-responses.js.map +2 -2
  14. package/dist/analyzers/code-changes.js +159 -0
  15. package/dist/analyzers/code-changes.js.map +7 -0
  16. package/dist/analyzers/edge-relationships.js +6 -2
  17. package/dist/analyzers/edge-relationships.js.map +2 -2
  18. package/dist/analyzers/extraction-plan.js +541 -0
  19. package/dist/analyzers/extraction-plan.js.map +7 -0
  20. package/dist/analyzers/index-job.js +229 -0
  21. package/dist/analyzers/index-job.js.map +7 -0
  22. package/dist/cli.js +12 -10
  23. package/dist/cli.js.map +2 -2
  24. package/dist/coding-agents/drbot-mcp.js +120 -18
  25. package/dist/coding-agents/drbot-mcp.js.map +2 -2
  26. package/dist/commands/add.js +4 -2
  27. package/dist/commands/add.js.map +2 -2
  28. package/dist/commands/analyzer.js +35 -35
  29. package/dist/commands/analyzer.js.map +2 -2
  30. package/dist/commands/changeset-verify.js +16 -8
  31. package/dist/commands/changeset-verify.js.map +2 -2
  32. package/dist/commands/changeset.js +41 -11
  33. package/dist/commands/changeset.js.map +2 -2
  34. package/dist/commands/init.js +9 -26
  35. package/dist/commands/init.js.map +2 -2
  36. package/dist/commands/mcp.js +35 -31
  37. package/dist/commands/mcp.js.map +3 -3
  38. package/dist/commands/relationship.js +2 -12
  39. package/dist/commands/relationship.js.map +2 -2
  40. package/dist/commands/reports.js +3 -3
  41. package/dist/commands/reports.js.map +2 -2
  42. package/dist/commands/update.js +14 -13
  43. package/dist/commands/update.js.map +2 -2
  44. package/dist/commands/upgrade.js +28 -120
  45. package/dist/commands/upgrade.js.map +2 -2
  46. package/dist/commands/validate.js +19 -3
  47. package/dist/commands/validate.js.map +2 -2
  48. package/dist/commands/version.js +1 -1
  49. package/dist/commands/visualize.js +30 -12
  50. package/dist/commands/visualize.js.map +2 -2
  51. package/dist/core/annotation-store.js +15 -5
  52. package/dist/core/annotation-store.js.map +2 -2
  53. package/dist/core/catalog-migration.js +2 -1
  54. package/dist/core/catalog-migration.js.map +2 -2
  55. package/dist/core/changeset-base-index.js +7 -0
  56. package/dist/core/changeset-base-index.js.map +2 -2
  57. package/dist/core/changeset-brief-compact.js +256 -0
  58. package/dist/core/changeset-brief-compact.js.map +7 -0
  59. package/dist/core/changeset-verify.js +356 -26
  60. package/dist/core/changeset-verify.js.map +3 -3
  61. package/dist/core/extraction-observability.js +161 -0
  62. package/dist/core/extraction-observability.js.map +7 -0
  63. package/dist/core/migration-registry.js +13 -1
  64. package/dist/core/migration-registry.js.map +2 -2
  65. package/dist/core/project-init.js +53 -0
  66. package/dist/core/project-init.js.map +7 -0
  67. package/dist/core/relationship-staging.js +94 -1
  68. package/dist/core/relationship-staging.js.map +2 -2
  69. package/dist/core/staged-changeset-storage.js +83 -3
  70. package/dist/core/staged-changeset-storage.js.map +2 -2
  71. package/dist/core/staging-area.js +85 -8
  72. package/dist/core/staging-area.js.map +2 -2
  73. package/dist/core/upgrade-plan.js +117 -0
  74. package/dist/core/upgrade-plan.js.map +7 -0
  75. package/dist/generated/relationship-index.js +124 -15
  76. package/dist/generated/relationship-index.js.map +2 -2
  77. package/dist/integrations/claude_code/.claude-plugin/plugin.json +24 -0
  78. package/dist/integrations/claude_code/agents/dr-architect.md +19 -5
  79. package/dist/integrations/claude_code/agents/dr-extractor.md +7 -1
  80. package/dist/integrations/claude_code/commands/dr-changeset.md +2 -1
  81. package/dist/integrations/claude_code/commands/dr-map.md +7 -1
  82. package/dist/integrations/claude_code/commands/dr-plan.md +42 -16
  83. package/dist/integrations/claude_code/docs/MCP_SERVER.md +28 -31
  84. package/dist/integrations/claude_code/docs/README.md +20 -1
  85. package/dist/integrations/claude_code/plugin-agents/dr-advisor.md +255 -0
  86. package/dist/integrations/claude_code/plugin-agents/dr-architect.md +306 -0
  87. package/dist/integrations/claude_code/skills/dr_13_testing_layer/SKILL.md +3 -0
  88. package/dist/integrations/claude_code/skills/dr_change_planning/SKILL.md +90 -31
  89. package/dist/integrations/claude_code/skills/dr_changeset_reviewer/SKILL.md +5 -0
  90. package/dist/integrations/claude_code/skills/dr_engineering_guide/SKILL.md +5 -0
  91. package/dist/integrations/github_copilot/agents/dr-architect.md +2 -0
  92. package/dist/integrations/github_copilot/skills/dr_13_testing_layer/SKILL.md +3 -0
  93. package/dist/integrations/github_copilot/skills/dr_change_planning/SKILL.md +88 -30
  94. package/dist/mcp/changeset-only.js +14 -3
  95. package/dist/mcp/changeset-only.js.map +2 -2
  96. package/dist/mcp/project-root.js +85 -0
  97. package/dist/mcp/project-root.js.map +7 -0
  98. package/dist/mcp/server-instructions.js +27 -0
  99. package/dist/mcp/server-instructions.js.map +7 -0
  100. package/dist/mcp/strict-schema.js +52 -0
  101. package/dist/mcp/strict-schema.js.map +7 -0
  102. package/dist/mcp/tool-registry.js +39 -5
  103. package/dist/mcp/tool-registry.js.map +2 -2
  104. package/dist/mcp/tools/changeset-analysis-tools.js +25 -5
  105. package/dist/mcp/tools/changeset-analysis-tools.js.map +3 -3
  106. package/dist/mcp/tools/changeset-commit.js +224 -0
  107. package/dist/mcp/tools/changeset-commit.js.map +7 -0
  108. package/dist/mcp/tools/changeset-editing.js +133 -57
  109. package/dist/mcp/tools/changeset-editing.js.map +3 -3
  110. package/dist/mcp/tools/changeset-lifecycle.js +1 -1
  111. package/dist/mcp/tools/changeset-lifecycle.js.map +2 -2
  112. package/dist/mcp/tools/changeset-verify.js +12 -6
  113. package/dist/mcp/tools/changeset-verify.js.map +2 -2
  114. package/dist/mcp/tools/codebase-extract.js +836 -0
  115. package/dist/mcp/tools/codebase-extract.js.map +7 -0
  116. package/dist/mcp/tools/codebase-index.js +125 -0
  117. package/dist/mcp/tools/codebase-index.js.map +7 -0
  118. package/dist/mcp/tools/codebase-shared.js +265 -0
  119. package/dist/mcp/tools/codebase-shared.js.map +7 -0
  120. package/dist/mcp/tools/codebase-verify.js +48 -0
  121. package/dist/mcp/tools/codebase-verify.js.map +7 -0
  122. package/dist/mcp/tools/index.js +15 -0
  123. package/dist/mcp/tools/index.js.map +2 -2
  124. package/dist/mcp/tools/model-add.js +189 -73
  125. package/dist/mcp/tools/model-add.js.map +2 -2
  126. package/dist/mcp/tools/model-update.js +16 -12
  127. package/dist/mcp/tools/model-update.js.map +2 -2
  128. package/dist/mcp/tools/model-validate.js +46 -4
  129. package/dist/mcp/tools/model-validate.js.map +2 -2
  130. package/dist/mcp/tools/project-status-collectors.js +381 -0
  131. package/dist/mcp/tools/project-status-collectors.js.map +7 -0
  132. package/dist/mcp/tools/project-status.js +237 -0
  133. package/dist/mcp/tools/project-status.js.map +7 -0
  134. package/dist/mcp/tools/project-tools.js +505 -0
  135. package/dist/mcp/tools/project-tools.js.map +7 -0
  136. package/dist/mcp/tools/relationship-tools.js +89 -20
  137. package/dist/mcp/tools/relationship-tools.js.map +2 -2
  138. package/dist/mcp/tools/review-open.js +210 -0
  139. package/dist/mcp/tools/review-open.js.map +7 -0
  140. package/dist/mcp/tools/shared.js +10 -2
  141. package/dist/mcp/tools/shared.js.map +2 -2
  142. package/dist/reports/model-report-orchestrator.js +31 -2
  143. package/dist/reports/model-report-orchestrator.js.map +2 -2
  144. package/dist/schemas/bundled/analyzers/cbm.json +22 -10
  145. package/dist/schemas/bundled/analyzers/manifest.json +2 -2
  146. package/dist/schemas/bundled/api.json +1 -1
  147. package/dist/schemas/bundled/apm.json +1 -1
  148. package/dist/schemas/bundled/application.json +1 -1
  149. package/dist/schemas/bundled/base.json +1 -1
  150. package/dist/schemas/bundled/business.json +1 -1
  151. package/dist/schemas/bundled/data-model.json +1 -1
  152. package/dist/schemas/bundled/data-store.json +1 -1
  153. package/dist/schemas/bundled/manifest.json +3 -3
  154. package/dist/schemas/bundled/motivation.json +1 -1
  155. package/dist/schemas/bundled/navigation.json +1 -1
  156. package/dist/schemas/bundled/product.json +1 -1
  157. package/dist/schemas/bundled/security.json +1 -1
  158. package/dist/schemas/bundled/technology.json +1 -1
  159. package/dist/schemas/bundled/testing.json +145 -1
  160. package/dist/schemas/bundled/ux.json +1 -1
  161. package/dist/server/server.js +508 -237
  162. package/dist/server/server.js.map +3 -3
  163. package/dist/server/viewer-registry.js +264 -0
  164. package/dist/server/viewer-registry.js.map +7 -0
  165. package/dist/telemetry/index.js +1 -1
  166. package/dist/utils/id-generator.js +5 -2
  167. package/dist/utils/id-generator.js.map +2 -2
  168. package/dist/utils/source-reference.js +58 -27
  169. package/dist/utils/source-reference.js.map +2 -2
  170. package/dist/utils/spec-version.js +2 -2
  171. package/dist/utils/spec-version.js.map +1 -1
  172. package/dist/validators/model-warnings.js +28 -0
  173. package/dist/validators/model-warnings.js.map +7 -0
  174. package/dist/viewer/README.md +6 -6
  175. package/dist/viewer/assets/main-CUOQGGRA.css +1 -0
  176. package/dist/viewer/assets/main-DvH3gYrI.js +84 -0
  177. package/dist/viewer/index.html +2 -2
  178. package/dist/viewer/manifest.json +10 -10
  179. package/dist/viewer/version.json +1 -1
  180. package/package.json +1 -1
  181. package/dist/viewer/assets/main-CxzcuU_o.js +0 -84
  182. package/dist/viewer/assets/main-j-7ULYpm.css +0 -1
@@ -0,0 +1,264 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, readdirSync, readFileSync, realpathSync, unlinkSync } from "node:fs";
4
+ import { chmod, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { basename, dirname, join, resolve } from "node:path";
7
+ const DR_STATE_DIR_ENV = "DR_STATE_DIR";
8
+ function stateDir(env = process.env) {
9
+ const override = env[DR_STATE_DIR_ENV];
10
+ if (override)
11
+ return resolve(override);
12
+ const xdg = env.XDG_STATE_HOME;
13
+ return xdg ? join(resolve(xdg), "dr") : join(homedir(), ".local", "state", "dr");
14
+ }
15
+ function viewerRecordDir(env = process.env) {
16
+ return join(stateDir(env), "viewers");
17
+ }
18
+ function canonicalProjectRoot(projectRoot) {
19
+ try {
20
+ return realpathSync(projectRoot);
21
+ } catch {
22
+ return resolve(projectRoot);
23
+ }
24
+ }
25
+ function recordKey(projectRoot) {
26
+ return createHash("sha256").update(canonicalProjectRoot(projectRoot)).digest("hex").slice(0, 24);
27
+ }
28
+ function viewerRecordPath(projectRoot, env = process.env) {
29
+ return join(viewerRecordDir(env), `${recordKey(projectRoot)}.json`);
30
+ }
31
+ function viewerLogPath(projectRoot, env = process.env) {
32
+ return join(viewerRecordDir(env), `${recordKey(projectRoot)}.log`);
33
+ }
34
+ function viewerStartingPath(projectRoot, env = process.env) {
35
+ return join(viewerRecordDir(env), `${recordKey(projectRoot)}.starting`);
36
+ }
37
+ let tempSeq = 0;
38
+ function tempPathFor(path, pid) {
39
+ return `${path}.${pid}.${++tempSeq}.tmp`;
40
+ }
41
+ function removeTempFilesSync(path, pid) {
42
+ const prefix = `${basename(path)}.${pid}.`;
43
+ try {
44
+ for (const name of readdirSync(dirname(path)))
45
+ name.startsWith(prefix) && name.endsWith(".tmp") && unlinkSync(join(dirname(path), name));
46
+ } catch {
47
+ }
48
+ }
49
+ const STALE_TEMP_MS = 6e4;
50
+ async function ensureViewerRecordDir(env = process.env) {
51
+ const dir = viewerRecordDir(env);
52
+ return await mkdir(dir, { recursive: !0, mode: 448 }), await chmod(dir, 448), dir;
53
+ }
54
+ async function removeStaleTempFiles(env = process.env, now = Date.now()) {
55
+ const dir = viewerRecordDir(env), removed = [];
56
+ let names;
57
+ try {
58
+ names = await readdir(dir);
59
+ } catch {
60
+ return removed;
61
+ }
62
+ for (const name of names) {
63
+ if (!name.endsWith(".tmp"))
64
+ continue;
65
+ const path = join(dir, name);
66
+ try {
67
+ now - (await stat(path)).mtimeMs > STALE_TEMP_MS && (await unlink(path), removed.push(path));
68
+ } catch {
69
+ }
70
+ }
71
+ return removed;
72
+ }
73
+ async function writeViewerRecord(record, env = process.env) {
74
+ await ensureViewerRecordDir(env), await removeStaleTempFiles(env);
75
+ const path = viewerRecordPath(record.projectRoot, env), stored = { ...record, projectRoot: canonicalProjectRoot(record.projectRoot) }, temp = tempPathFor(path, process.pid);
76
+ try {
77
+ await writeFile(temp, JSON.stringify(stored, null, 2) + `
78
+ `, { mode: 384 }), await chmod(temp, 384), await rename(temp, path);
79
+ } catch (error) {
80
+ throw await unlink(temp).catch(() => {
81
+ }), error;
82
+ }
83
+ return path;
84
+ }
85
+ function parseRecord(text) {
86
+ try {
87
+ const data = JSON.parse(text);
88
+ if (data && typeof data == "object" && Number.isInteger(data.pid) && Number.isInteger(data.port) && (typeof data.token == "string" || data.token === null) && typeof data.projectRoot == "string")
89
+ return {
90
+ pid: data.pid,
91
+ port: data.port,
92
+ token: data.token,
93
+ projectRoot: data.projectRoot,
94
+ startedAt: typeof data.startedAt == "string" ? data.startedAt : "",
95
+ withChangesets: data.withChangesets === !0
96
+ };
97
+ } catch {
98
+ }
99
+ return null;
100
+ }
101
+ async function readViewerRecord(projectRoot, env = process.env) {
102
+ let text;
103
+ try {
104
+ text = await readFile(viewerRecordPath(projectRoot, env), "utf8");
105
+ } catch (error) {
106
+ if (error.code === "ENOENT")
107
+ return;
108
+ throw error;
109
+ }
110
+ return parseRecord(text);
111
+ }
112
+ async function removeViewerRecord(projectRoot, options = {}) {
113
+ const env = options.env ?? process.env, path = viewerRecordPath(projectRoot, env);
114
+ if (options.pid !== void 0) {
115
+ const current = await readViewerRecord(projectRoot, env).catch(() => {
116
+ });
117
+ if (current && current.pid !== options.pid)
118
+ return;
119
+ }
120
+ try {
121
+ await unlink(path);
122
+ } catch (error) {
123
+ if (error.code !== "ENOENT")
124
+ throw error;
125
+ }
126
+ }
127
+ function removeViewerRecordSync(projectRoot, options = {}) {
128
+ const env = options.env ?? process.env, path = viewerRecordPath(projectRoot, env);
129
+ removeTempFilesSync(path, options.pid ?? process.pid);
130
+ try {
131
+ if (!existsSync(path))
132
+ return;
133
+ if (options.pid !== void 0) {
134
+ const current = parseRecord(readFileSync(path, "utf8"));
135
+ if (current && current.pid !== options.pid)
136
+ return;
137
+ }
138
+ unlinkSync(path);
139
+ } catch {
140
+ }
141
+ }
142
+ function isProcessAlive(pid) {
143
+ if (!Number.isInteger(pid) || pid <= 0)
144
+ return !1;
145
+ try {
146
+ return process.kill(pid, 0), !0;
147
+ } catch (error) {
148
+ return error.code === "EPERM";
149
+ }
150
+ }
151
+ async function viewerAnswers(port, timeoutMs = 1500) {
152
+ try {
153
+ const response = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(timeoutMs) });
154
+ if (!response.ok)
155
+ return !1;
156
+ const body = await response.json();
157
+ return body?.status === "ok" || body?.status === "degraded";
158
+ } catch {
159
+ return !1;
160
+ }
161
+ }
162
+ async function probeViewer(projectRoot, options = {}) {
163
+ const env = options.env ?? process.env, isAlive = options.isAlive ?? isProcessAlive, answers = options.answers ?? viewerAnswers, record = await readViewerRecord(projectRoot, env);
164
+ return record === void 0 ? { state: "none" } : record === null ? (await removeViewerRecord(projectRoot, { env }), { state: "none" }) : record.projectRoot !== canonicalProjectRoot(projectRoot) ? { state: "none" } : isAlive(record.pid) ? await answers(record.port, options.probeTimeoutMs ?? 1500) ? { state: "running", record } : await answers(record.port, options.retryTimeoutMs ?? 5e3) ? { state: "running", record } : { state: "unreachable", record } : (await removeViewerRecord(projectRoot, { env, pid: record.pid }), { state: "none" });
165
+ }
166
+ async function findRunningViewer(projectRoot, options = {}) {
167
+ const probe = await probeViewer(projectRoot, options);
168
+ return probe.state === "running" ? probe.record : null;
169
+ }
170
+ async function writeViewerStarting(projectRoot, starting, env = process.env) {
171
+ await ensureViewerRecordDir(env);
172
+ const path = viewerStartingPath(projectRoot, env), temp = tempPathFor(path, process.pid);
173
+ await writeFile(temp, JSON.stringify(starting) + `
174
+ `, { mode: 384 }), await rename(temp, path);
175
+ }
176
+ async function readViewerStarting(projectRoot, env = process.env, isAlive = isProcessAlive) {
177
+ const path = viewerStartingPath(projectRoot, env);
178
+ let data;
179
+ try {
180
+ data = JSON.parse(await readFile(path, "utf8"));
181
+ } catch (error) {
182
+ return error.code !== "ENOENT" && await unlink(path).catch(() => {
183
+ }), null;
184
+ }
185
+ return !Number.isInteger(data?.pid) || !isAlive(data.pid) ? (await unlink(path).catch(() => {
186
+ }), null) : data;
187
+ }
188
+ async function removeViewerStarting(projectRoot, options = {}) {
189
+ const env = options.env ?? process.env, path = viewerStartingPath(projectRoot, env);
190
+ if (options.pid !== void 0)
191
+ try {
192
+ if (JSON.parse(await readFile(path, "utf8")).pid !== options.pid)
193
+ return;
194
+ } catch {
195
+ return;
196
+ }
197
+ await unlink(path).catch(() => {
198
+ });
199
+ }
200
+ async function viewersOnPort(port, env = process.env, isAlive = isProcessAlive) {
201
+ const dir = viewerRecordDir(env);
202
+ let names;
203
+ try {
204
+ names = await readdir(dir);
205
+ } catch {
206
+ return [];
207
+ }
208
+ const found = [];
209
+ for (const name of names) {
210
+ if (!name.endsWith(".json"))
211
+ continue;
212
+ const record = parseRecord(await readFile(join(dir, name), "utf8").catch(() => ""));
213
+ record && record.port === port && isAlive(record.pid) && found.push(record);
214
+ }
215
+ return found;
216
+ }
217
+ async function describePortHolder(port, env = process.env) {
218
+ const viewers = await viewersOnPort(port, env).catch(() => []);
219
+ if (viewers.length > 0) {
220
+ const viewer = viewers[0];
221
+ return `the dr viewer for ${viewer.projectRoot} (pid ${viewer.pid})`;
222
+ }
223
+ try {
224
+ const lines = (spawnSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fpc"], {
225
+ encoding: "utf8",
226
+ timeout: 2e3
227
+ }).stdout ?? "").split(`
228
+ `), pid = lines.find((line) => line.startsWith("p"))?.slice(1), command = lines.find((line) => line.startsWith("c"))?.slice(1);
229
+ if (pid)
230
+ return `${command ? `${command} ` : "a process "}(pid ${pid})`;
231
+ } catch {
232
+ }
233
+ return "another program";
234
+ }
235
+ function viewerUrl(record, options = {}) {
236
+ const query = record.token ? `?token=${encodeURIComponent(record.token)}` : "", route = options.changesetId ? `#/changesets/list?changeset=${encodeURIComponent(options.changesetId)}` : "";
237
+ return `http://localhost:${record.port}/${query}${route}`;
238
+ }
239
+ export {
240
+ DR_STATE_DIR_ENV,
241
+ canonicalProjectRoot,
242
+ describePortHolder,
243
+ ensureViewerRecordDir,
244
+ findRunningViewer,
245
+ isProcessAlive,
246
+ probeViewer,
247
+ readViewerRecord,
248
+ readViewerStarting,
249
+ removeStaleTempFiles,
250
+ removeViewerRecord,
251
+ removeViewerRecordSync,
252
+ removeViewerStarting,
253
+ stateDir,
254
+ viewerAnswers,
255
+ viewerLogPath,
256
+ viewerRecordDir,
257
+ viewerRecordPath,
258
+ viewerStartingPath,
259
+ viewerUrl,
260
+ viewersOnPort,
261
+ writeViewerRecord,
262
+ writeViewerStarting
263
+ };
264
+ //# sourceMappingURL=viewer-registry.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/server/viewer-registry.ts"],
4
+ "sourcesContent": ["/**\n * Viewer registry: which `dr visualize` is serving which project.\n *\n * `dr visualize` writes one record per project once its server is listening, and removes it on\n * exit. Tools that hand the human a link to the viewer (`review_open`, `project_status`) read it\n * with `findRunningViewer` and reuse a running viewer instead of starting another.\n *\n * The record holds the viewer's auth token, so it lives in the user's state directory, outside\n * any repository, where it can't be committed:\n *\n * $DR_STATE_DIR/viewers/<hash>.json (DR_STATE_DIR overrides, for tests)\n * $XDG_STATE_HOME/dr/viewers/<hash>.json (when XDG_STATE_HOME is set)\n * ~/.local/state/dr/viewers/<hash>.json (otherwise)\n *\n * `<hash>` is a hash of the project root's real path. Each file is mode 0600 and the viewers\n * directory 0700.\n *\n * Stable API (imported by MCP tools): ViewerRecord, writeViewerRecord, readViewerRecord,\n * removeViewerRecord, removeViewerRecordSync, findRunningViewer, probeViewer, viewerUrl,\n * describePortHolder.\n */\n\nimport { spawnSync } from \"node:child_process\";\nimport { createHash } from \"node:crypto\";\nimport { existsSync, readdirSync, readFileSync, realpathSync, unlinkSync } from \"node:fs\";\nimport { chmod, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from \"node:fs/promises\";\nimport { homedir } from \"node:os\";\nimport { basename, dirname, join, resolve } from \"node:path\";\n\n/** Env var that points the user state directory somewhere else (tests). */\nexport const DR_STATE_DIR_ENV = \"DR_STATE_DIR\";\n\n/** A running `dr visualize` for one project. */\nexport interface ViewerRecord {\n /** Pid of the `dr visualize` process. */\n pid: number;\n port: number;\n /** The auth token, or null when the viewer runs with --no-auth. */\n token: string | null;\n /** Real path of the project root the viewer serves. */\n projectRoot: string;\n /** ISO timestamp of when the viewer started listening. */\n startedAt: string;\n /** Whether the viewer runs with --with-changesets (DrBot may stage changes). */\n withChangesets: boolean;\n}\n\n/** The user's dr state directory (not the project's .dr/). */\nexport function stateDir(env: NodeJS.ProcessEnv = process.env): string {\n const override = env[DR_STATE_DIR_ENV];\n if (override) return resolve(override);\n const xdg = env.XDG_STATE_HOME;\n if (xdg) return join(resolve(xdg), \"dr\");\n return join(homedir(), \".local\", \"state\", \"dr\");\n}\n\n/** Directory holding the viewer records. */\nexport function viewerRecordDir(env: NodeJS.ProcessEnv = process.env): string {\n return join(stateDir(env), \"viewers\");\n}\n\n/** The project root's real path, or its resolved path when it doesn't exist. */\nexport function canonicalProjectRoot(projectRoot: string): string {\n try {\n return realpathSync(projectRoot);\n } catch {\n return resolve(projectRoot);\n }\n}\n\nfunction recordKey(projectRoot: string): string {\n return createHash(\"sha256\").update(canonicalProjectRoot(projectRoot)).digest(\"hex\").slice(0, 24);\n}\n\n/** Path of a project's viewer record. */\nexport function viewerRecordPath(projectRoot: string, env: NodeJS.ProcessEnv = process.env): string {\n return join(viewerRecordDir(env), `${recordKey(projectRoot)}.json`);\n}\n\n/** Path of the log a viewer started by `review_open` writes its output to. */\nexport function viewerLogPath(projectRoot: string, env: NodeJS.ProcessEnv = process.env): string {\n return join(viewerRecordDir(env), `${recordKey(projectRoot)}.log`);\n}\n\n/** Path of the \"a viewer is starting\" marker review_open writes while a viewer it spawned loads. */\nexport function viewerStartingPath(projectRoot: string, env: NodeJS.ProcessEnv = process.env): string {\n return join(viewerRecordDir(env), `${recordKey(projectRoot)}.starting`);\n}\n\nlet tempSeq = 0;\n\n/** Temp file for one write before it is renamed into place: `<file>.<pid>.<n>.tmp`. */\nfunction tempPathFor(path: string, pid: number): string {\n return `${path}.${pid}.${++tempSeq}.tmp`;\n}\n\n/** Removes this pid's temp files for `path` (writes it had in flight when it exited). */\nfunction removeTempFilesSync(path: string, pid: number): void {\n const prefix = `${basename(path)}.${pid}.`;\n try {\n for (const name of readdirSync(dirname(path))) {\n if (name.startsWith(prefix) && name.endsWith(\".tmp\")) unlinkSync(join(dirname(path), name));\n }\n } catch {\n // No directory, or nothing to remove.\n }\n}\n\n/** Temp files older than this are left over from a writer that died mid-write. */\nconst STALE_TEMP_MS = 60_000;\n\n/** Creates the viewers directory (0700) and returns it. */\nexport async function ensureViewerRecordDir(env: NodeJS.ProcessEnv = process.env): Promise<string> {\n const dir = viewerRecordDir(env);\n await mkdir(dir, { recursive: true, mode: 0o700 });\n await chmod(dir, 0o700);\n return dir;\n}\n\n/**\n * Removes `.tmp` files older than a minute from the viewers directory: half-written records\n * (which may hold a token) left by a writer that was killed mid-write. Never throws.\n */\nexport async function removeStaleTempFiles(\n env: NodeJS.ProcessEnv = process.env,\n now: number = Date.now()\n): Promise<string[]> {\n const dir = viewerRecordDir(env);\n const removed: string[] = [];\n let names: string[];\n try {\n names = await readdir(dir);\n } catch {\n return removed;\n }\n for (const name of names) {\n if (!name.endsWith(\".tmp\")) continue;\n const path = join(dir, name);\n try {\n if (now - (await stat(path)).mtimeMs > STALE_TEMP_MS) {\n await unlink(path);\n removed.push(path);\n }\n } catch {\n // Gone already, or not ours to remove.\n }\n }\n return removed;\n}\n\n/**\n * Writes a project's viewer record (0600), replacing any earlier one. The write is atomic (a\n * temp file renamed into place), so a reader never sees half a record; stale temp files from\n * earlier writers are cleaned up first. Returns the record's path.\n */\nexport async function writeViewerRecord(\n record: ViewerRecord,\n env: NodeJS.ProcessEnv = process.env\n): Promise<string> {\n await ensureViewerRecordDir(env);\n await removeStaleTempFiles(env);\n const path = viewerRecordPath(record.projectRoot, env);\n const stored: ViewerRecord = { ...record, projectRoot: canonicalProjectRoot(record.projectRoot) };\n const temp = tempPathFor(path, process.pid);\n try {\n await writeFile(temp, JSON.stringify(stored, null, 2) + \"\\n\", { mode: 0o600 });\n await chmod(temp, 0o600);\n await rename(temp, path);\n } catch (error) {\n await unlink(temp).catch(() => {});\n throw error;\n }\n return path;\n}\n\nfunction parseRecord(text: string): ViewerRecord | null {\n try {\n const data = JSON.parse(text);\n if (\n data &&\n typeof data === \"object\" &&\n Number.isInteger(data.pid) &&\n Number.isInteger(data.port) &&\n (typeof data.token === \"string\" || data.token === null) &&\n typeof data.projectRoot === \"string\"\n ) {\n return {\n pid: data.pid,\n port: data.port,\n token: data.token,\n projectRoot: data.projectRoot,\n startedAt: typeof data.startedAt === \"string\" ? data.startedAt : \"\",\n withChangesets: data.withChangesets === true,\n };\n }\n } catch {\n // Unparseable: treated as no record.\n }\n return null;\n}\n\n/**\n * A project's viewer record as written, without checking that the viewer still runs. Undefined\n * when there is no record; null when the file is there but unreadable as a record.\n */\nexport async function readViewerRecord(\n projectRoot: string,\n env: NodeJS.ProcessEnv = process.env\n): Promise<ViewerRecord | null | undefined> {\n let text: string;\n try {\n text = await readFile(viewerRecordPath(projectRoot, env), \"utf8\");\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === \"ENOENT\") return undefined;\n throw error;\n }\n return parseRecord(text);\n}\n\nexport interface RemoveViewerRecordOptions {\n /** Remove only if the record belongs to this pid (so a viewer never removes a newer one's). */\n pid?: number;\n env?: NodeJS.ProcessEnv;\n}\n\n/** Removes a project's viewer record. Missing is fine. */\nexport async function removeViewerRecord(\n projectRoot: string,\n options: RemoveViewerRecordOptions = {}\n): Promise<void> {\n const env = options.env ?? process.env;\n const path = viewerRecordPath(projectRoot, env);\n if (options.pid !== undefined) {\n const current = await readViewerRecord(projectRoot, env).catch(() => undefined);\n if (current && current.pid !== options.pid) return;\n }\n try {\n await unlink(path);\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== \"ENOENT\") throw error;\n }\n}\n\n/** Synchronous removeViewerRecord, for a process \"exit\" handler. Never throws. */\nexport function removeViewerRecordSync(projectRoot: string, options: RemoveViewerRecordOptions = {}): void {\n const env = options.env ?? process.env;\n const path = viewerRecordPath(projectRoot, env);\n // A write this process had in flight when it exited.\n removeTempFilesSync(path, options.pid ?? process.pid);\n try {\n if (!existsSync(path)) return;\n if (options.pid !== undefined) {\n const current = parseRecord(readFileSync(path, \"utf8\"));\n if (current && current.pid !== options.pid) return;\n }\n unlinkSync(path);\n } catch {\n // Best effort at exit; findRunningViewer clears stale records anyway.\n }\n}\n\n/** Whether a process with this pid exists (EPERM means it exists but belongs to someone else). */\nexport function isProcessAlive(pid: number): boolean {\n if (!Number.isInteger(pid) || pid <= 0) return false;\n try {\n process.kill(pid, 0);\n return true;\n } catch (error) {\n return (error as NodeJS.ErrnoException).code === \"EPERM\";\n }\n}\n\n/** Whether a dr viewer answers its health check on this port. */\nexport async function viewerAnswers(port: number, timeoutMs = 1500): Promise<boolean> {\n try {\n const response = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(timeoutMs) });\n if (!response.ok) return false;\n const body = (await response.json()) as { status?: unknown };\n return body?.status === \"ok\" || body?.status === \"degraded\";\n } catch {\n return false;\n }\n}\n\nexport interface FindRunningViewerOptions {\n env?: NodeJS.ProcessEnv;\n /** Injected for tests; defaults to isProcessAlive. */\n isAlive?: (pid: number) => boolean;\n /** Injected for tests; defaults to viewerAnswers. Called with the probe's timeout. */\n answers?: (port: number, timeoutMs: number) => Promise<boolean>;\n /** First health probe's timeout (default 1.5s). */\n probeTimeoutMs?: number;\n /** The retry's timeout when the first probe fails and the pid is alive (default 5s). */\n retryTimeoutMs?: number;\n}\n\n/**\n * What the registry knows about a project's viewer:\n * - `none`: no record, or a stale one (its pid is dead), which is removed.\n * - `running`: the pid is alive and the server answers its health check.\n * - `unreachable`: the pid is alive but the server did not answer, twice. The record is kept: the\n * viewer may only be busy, and a second viewer for the project would be wrong.\n */\nexport type ViewerProbe =\n | { state: \"none\" }\n | { state: \"running\"; record: ViewerRecord }\n | { state: \"unreachable\"; record: ViewerRecord };\n\n/** Checks a project's viewer record against its process and its health check. */\nexport async function probeViewer(projectRoot: string, options: FindRunningViewerOptions = {}): Promise<ViewerProbe> {\n const env = options.env ?? process.env;\n const isAlive = options.isAlive ?? isProcessAlive;\n const answers = options.answers ?? viewerAnswers;\n\n const record = await readViewerRecord(projectRoot, env);\n if (record === undefined) return { state: \"none\" };\n if (record === null) {\n await removeViewerRecord(projectRoot, { env });\n return { state: \"none\" };\n }\n if (record.projectRoot !== canonicalProjectRoot(projectRoot)) return { state: \"none\" };\n if (!isAlive(record.pid)) {\n await removeViewerRecord(projectRoot, { env, pid: record.pid });\n return { state: \"none\" };\n }\n if (await answers(record.port, options.probeTimeoutMs ?? 1500)) return { state: \"running\", record };\n if (await answers(record.port, options.retryTimeoutMs ?? 5000)) return { state: \"running\", record };\n return { state: \"unreachable\", record };\n}\n\n/**\n * The viewer serving this project, or null. A record counts only when its pid is alive and the\n * server answers its health check. A record whose pid is dead is removed; one whose pid is alive\n * but doesn't answer is kept (probeViewer tells the two apart).\n */\nexport async function findRunningViewer(\n projectRoot: string,\n options: FindRunningViewerOptions = {}\n): Promise<ViewerRecord | null> {\n const probe = await probeViewer(projectRoot, options);\n return probe.state === \"running\" ? probe.record : null;\n}\n\n/** A viewer review_open spawned that has not recorded itself yet. */\nexport interface ViewerStarting {\n pid: number;\n startedAt: string;\n logPath?: string;\n}\n\n/** Marks that a viewer is starting for the project (0600), so other callers wait for it. */\nexport async function writeViewerStarting(\n projectRoot: string,\n starting: ViewerStarting,\n env: NodeJS.ProcessEnv = process.env\n): Promise<void> {\n await ensureViewerRecordDir(env);\n const path = viewerStartingPath(projectRoot, env);\n const temp = tempPathFor(path, process.pid);\n await writeFile(temp, JSON.stringify(starting) + \"\\n\", { mode: 0o600 });\n await rename(temp, path);\n}\n\n/** The viewer starting for the project while its pid is alive; a dead one's marker is removed. */\nexport async function readViewerStarting(\n projectRoot: string,\n env: NodeJS.ProcessEnv = process.env,\n isAlive: (pid: number) => boolean = isProcessAlive\n): Promise<ViewerStarting | null> {\n const path = viewerStartingPath(projectRoot, env);\n let data: ViewerStarting;\n try {\n data = JSON.parse(await readFile(path, \"utf8\"));\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== \"ENOENT\") await unlink(path).catch(() => {});\n return null;\n }\n if (!Number.isInteger(data?.pid) || !isAlive(data.pid)) {\n await unlink(path).catch(() => {});\n return null;\n }\n return data;\n}\n\n/** Removes the starting marker (only this pid's, when given). */\nexport async function removeViewerStarting(\n projectRoot: string,\n options: { pid?: number; env?: NodeJS.ProcessEnv } = {}\n): Promise<void> {\n const env = options.env ?? process.env;\n const path = viewerStartingPath(projectRoot, env);\n if (options.pid !== undefined) {\n try {\n const data = JSON.parse(await readFile(path, \"utf8\")) as ViewerStarting;\n if (data.pid !== options.pid) return;\n } catch {\n return;\n }\n }\n await unlink(path).catch(() => {});\n}\n\n/** The live dr viewers the registry knows of on this port (any project). */\nexport async function viewersOnPort(\n port: number,\n env: NodeJS.ProcessEnv = process.env,\n isAlive: (pid: number) => boolean = isProcessAlive\n): Promise<ViewerRecord[]> {\n const dir = viewerRecordDir(env);\n let names: string[];\n try {\n names = await readdir(dir);\n } catch {\n return [];\n }\n const found: ViewerRecord[] = [];\n for (const name of names) {\n if (!name.endsWith(\".json\")) continue;\n const record = parseRecord(await readFile(join(dir, name), \"utf8\").catch(() => \"\"));\n if (record && record.port === port && isAlive(record.pid)) found.push(record);\n }\n return found;\n}\n\n/**\n * Says what holds a port, for an error a person reads: another project's dr viewer (from the\n * registry), else the listening process `lsof` names, else \"another program\". Never throws.\n */\nexport async function describePortHolder(port: number, env: NodeJS.ProcessEnv = process.env): Promise<string> {\n const viewers = await viewersOnPort(port, env).catch(() => []);\n if (viewers.length > 0) {\n const viewer = viewers[0];\n return `the dr viewer for ${viewer.projectRoot} (pid ${viewer.pid})`;\n }\n try {\n const result = spawnSync(\"lsof\", [\"-nP\", `-iTCP:${port}`, \"-sTCP:LISTEN\", \"-Fpc\"], {\n encoding: \"utf8\",\n timeout: 2000,\n });\n const lines = (result.stdout ?? \"\").split(\"\\n\");\n const pid = lines.find((line) => line.startsWith(\"p\"))?.slice(1);\n const command = lines.find((line) => line.startsWith(\"c\"))?.slice(1);\n if (pid) return `${command ? `${command} ` : \"a process \"}(pid ${pid})`;\n } catch {\n // No lsof: fall through.\n }\n return \"another program\";\n}\n\nexport interface ViewerUrlOptions {\n /** Open this changeset in the viewer's changeset overlay. */\n changesetId?: string;\n}\n\n/**\n * Link into a running viewer. The viewer reads the token from the query string before the hash\n * (`/?token=...#/route`), stores it and drops it from the address bar; the route and its search\n * params go in the hash. Without auth there is no token.\n */\nexport function viewerUrl(record: Pick<ViewerRecord, \"port\" | \"token\">, options: ViewerUrlOptions = {}): string {\n const query = record.token ? `?token=${encodeURIComponent(record.token)}` : \"\";\n const route = options.changesetId\n ? `#/changesets/list?changeset=${encodeURIComponent(options.changesetId)}`\n : \"\";\n return `http://localhost:${record.port}/${query}${route}`;\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -21,7 +21,7 @@ async function initTelemetry() {
21
21
  `), process.stderr.write(` - Logs endpoint: ${otlpConfig.logsEndpoint}
22
22
  `), process.stderr.write(` - Service name: ${otlpConfig.serviceName}
23
23
  `));
24
- const cliVersion = "0.3.0";
24
+ const cliVersion = "0.4.0";
25
25
  let projectName = "unknown";
26
26
  try {
27
27
  const [{ readFileSync, existsSync }, { parse }] = await Promise.all([
@@ -1,7 +1,10 @@
1
1
  import crypto from "node:crypto";
2
2
  function toKebabCase(text) {
3
- let result = text.replace(/[\s_]+/g, "-");
4
- return result = result.replace(/([a-z])([A-Z])/g, "$1-$2"), result = result.toLowerCase(), result = result.replace(/\./g, ""), result = result.replace(/[^a-z0-9-]/g, ""), result = result.replace(/-+/g, "-"), result = result.replace(/^-+|-+$/g, ""), result;
3
+ let result = text.replace(/[A-Za-z0-9$]+(?:\.[A-Za-z0-9$]+)+/g, (chain) => {
4
+ const parts = chain.split("."), camel = (part) => /[a-z][A-Z]/.test(part);
5
+ return parts.reduce((joined, part, i) => i === 0 ? part : joined + (camel(parts[i - 1]) || camel(part) ? "-" : ".") + part, "");
6
+ });
7
+ return result = result.replace(/[\s_]+/g, "-"), result = result.replace(/([a-z])([A-Z])/g, "$1-$2"), result = result.toLowerCase(), result = result.replace(/\./g, ""), result = result.replace(/[^a-z0-9-]/g, ""), result = result.replace(/-+/g, "-"), result = result.replace(/^-+|-+$/g, ""), result;
5
8
  }
6
9
  function generateElementId(layer, elementType, name) {
7
10
  const kebabName = toKebabCase(name);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/utils/id-generator.ts"],
4
- "sourcesContent": ["/**\n * Element ID generation utilities matching Python CLI implementation.\n */\n\nimport crypto from \"node:crypto\";\n\n/**\n * Convert text to kebab-case.\n * Matches Python implementation in utils/id_generator.py\n *\n * @example\n * toKebabCase(\"Customer Management\") // \"customer-management\"\n * toKebabCase(\"CustomerManagement\") // \"customer-management\"\n * toKebabCase(\"customer_management\") // \"customer-management\"\n */\nexport function toKebabCase(text: string): string {\n // Replace spaces and underscores with hyphens\n let result = text.replace(/[\\s_]+/g, \"-\");\n\n // Insert hyphen before capital letters (for camelCase/PascalCase)\n result = result.replace(/([a-z])([A-Z])/g, \"$1-$2\");\n\n // Convert to lowercase\n result = result.toLowerCase();\n\n // Remove dots (e.g. \"Node.js\" \u2192 \"nodejs\") to prevent extra ID segments\n result = result.replace(/\\./g, \"\");\n\n // Remove any characters not valid in a slug (keeps only a-z, 0-9, hyphens)\n result = result.replace(/[^a-z0-9-]/g, \"\");\n\n // Remove multiple consecutive hyphens\n result = result.replace(/-+/g, \"-\");\n\n // Remove leading/trailing hyphens\n result = result.replace(/^-+|-+$/g, \"\");\n\n return result;\n}\n\n/**\n * Generate element ID following Python CLI convention: {layer}.{type}.{kebab-name}\n * Matches Python implementation in utils/id_generator.py\n *\n * @example\n * generateElementId(\"business\", \"service\", \"Customer Management\")\n * // \"business.service.customer-management\"\n */\nexport function generateElementId(layer: string, elementType: string, name: string): string {\n const kebabName = toKebabCase(name);\n return `${layer}.${elementType}.${kebabName}`;\n}\n\n/**\n * Generate a new UUIDv4 using the Node.js built-in crypto module.\n *\n * @example\n * generateUUID() // \"550e8400-e29b-41d4-a716-446655440000\"\n */\nexport function generateUUID(): string {\n return crypto.randomUUID();\n}\n"],
5
- "mappings": "AAIA,OAAO,YAAY;AAWb,SAAU,YAAY,MAAY;AAEtC,MAAI,SAAS,KAAK,QAAQ,WAAW,GAAG;AAGxC,kBAAS,OAAO,QAAQ,mBAAmB,OAAO,GAGlD,SAAS,OAAO,YAAW,GAG3B,SAAS,OAAO,QAAQ,OAAO,EAAE,GAGjC,SAAS,OAAO,QAAQ,eAAe,EAAE,GAGzC,SAAS,OAAO,QAAQ,OAAO,GAAG,GAGlC,SAAS,OAAO,QAAQ,YAAY,EAAE,GAE/B;AACT;AAUM,SAAU,kBAAkB,OAAe,aAAqB,MAAY;AAChF,QAAM,YAAY,YAAY,IAAI;AAClC,SAAO,GAAG,KAAK,IAAI,WAAW,IAAI,SAAS;AAC7C;AAQM,SAAU,eAAY;AAC1B,SAAO,OAAO,WAAU;AAC1B;",
4
+ "sourcesContent": ["/**\n * Element ID generation utilities matching Python CLI implementation.\n */\n\nimport crypto from \"node:crypto\";\n\n/**\n * Convert text to kebab-case.\n * Matches Python implementation in utils/id_generator.py\n *\n * @example\n * toKebabCase(\"Customer Management\") // \"customer-management\"\n * toKebabCase(\"CustomerManagement\") // \"customer-management\"\n * toKebabCase(\"customer_management\") // \"customer-management\"\n * toKebabCase(\"PetTypeRepository.findPetTypes\") // \"pet-type-repository-find-pet-types\"\n * toKebabCase(\"Node.js\") // \"nodejs\"\n */\nexport function toKebabCase(text: string): string {\n // A dot between code-symbol parts is a word break: \"Repo.findAll\" \u2192 \"Repo-findAll\". A part is a\n // code symbol when it is camelCase (a lowercase letter followed by an uppercase one). Other dots\n // are dropped below, so \"Node.js\", \"ASP.NET\" and \"1.2.3\" join as before.\n let result = text.replace(/[A-Za-z0-9$]+(?:\\.[A-Za-z0-9$]+)+/g, (chain) => {\n const parts = chain.split(\".\");\n const camel = (part: string) => /[a-z][A-Z]/.test(part);\n return parts.reduce((joined, part, i) => {\n if (i === 0) return part;\n return joined + (camel(parts[i - 1]) || camel(part) ? \"-\" : \".\") + part;\n }, \"\");\n });\n\n // Replace spaces and underscores with hyphens\n result = result.replace(/[\\s_]+/g, \"-\");\n\n // Insert hyphen before capital letters (for camelCase/PascalCase)\n result = result.replace(/([a-z])([A-Z])/g, \"$1-$2\");\n\n // Convert to lowercase\n result = result.toLowerCase();\n\n // Remove dots (e.g. \"Node.js\" \u2192 \"nodejs\") to prevent extra ID segments\n result = result.replace(/\\./g, \"\");\n\n // Remove any characters not valid in a slug (keeps only a-z, 0-9, hyphens)\n result = result.replace(/[^a-z0-9-]/g, \"\");\n\n // Remove multiple consecutive hyphens\n result = result.replace(/-+/g, \"-\");\n\n // Remove leading/trailing hyphens\n result = result.replace(/^-+|-+$/g, \"\");\n\n return result;\n}\n\n/**\n * Generate element ID following Python CLI convention: {layer}.{type}.{kebab-name}\n * Matches Python implementation in utils/id_generator.py\n *\n * @example\n * generateElementId(\"business\", \"service\", \"Customer Management\")\n * // \"business.service.customer-management\"\n */\nexport function generateElementId(layer: string, elementType: string, name: string): string {\n const kebabName = toKebabCase(name);\n return `${layer}.${elementType}.${kebabName}`;\n}\n\n/**\n * Generate a new UUIDv4 using the Node.js built-in crypto module.\n *\n * @example\n * generateUUID() // \"550e8400-e29b-41d4-a716-446655440000\"\n */\nexport function generateUUID(): string {\n return crypto.randomUUID();\n}\n"],
5
+ "mappings": "AAIA,OAAO,YAAY;AAab,SAAU,YAAY,MAAY;AAItC,MAAI,SAAS,KAAK,QAAQ,sCAAsC,CAAC,UAAS;AACxE,UAAM,QAAQ,MAAM,MAAM,GAAG,GACvB,QAAQ,CAAC,SAAiB,aAAa,KAAK,IAAI;AACtD,WAAO,MAAM,OAAO,CAAC,QAAQ,MAAM,MAC7B,MAAM,IAAU,OACb,UAAU,MAAM,MAAM,IAAI,CAAC,CAAC,KAAK,MAAM,IAAI,IAAI,MAAM,OAAO,MAClE,EAAE;EACP,CAAC;AAGD,kBAAS,OAAO,QAAQ,WAAW,GAAG,GAGtC,SAAS,OAAO,QAAQ,mBAAmB,OAAO,GAGlD,SAAS,OAAO,YAAW,GAG3B,SAAS,OAAO,QAAQ,OAAO,EAAE,GAGjC,SAAS,OAAO,QAAQ,eAAe,EAAE,GAGzC,SAAS,OAAO,QAAQ,OAAO,GAAG,GAGlC,SAAS,OAAO,QAAQ,YAAY,EAAE,GAE/B;AACT;AAUM,SAAU,kBAAkB,OAAe,aAAqB,MAAY;AAChF,QAAM,YAAY,YAAY,IAAI;AAClC,SAAO,GAAG,KAAK,IAAI,WAAW,IAAI,SAAS;AAC7C;AAQM,SAAU,eAAY;AAC1B,SAAO,OAAO,WAAU;AAC1B;",
6
6
  "names": []
7
7
  }
@@ -1,33 +1,42 @@
1
1
  import { CLIError } from "./errors.js";
2
2
  import { optionExample, optionName } from "./error-hints.js";
3
- function validateSourceReferenceOptions(options, surface = "cli") {
4
- const name = (option) => optionName(surface, option), example = (option, value) => optionExample(surface, option, value);
5
- if (!(options.sourceFile || options.sourceSymbol || options.sourceProvenance || options.sourceRepoRemote || options.sourceRepoCommit)) {
6
- process.stderr.isTTY && console.warn(`Warning: no source provenance specified. Add ${name("sourceFile")} and ${name("sourceProvenance")} to maintain traceability for drift detection and /dr-sync workflows.`);
7
- return;
8
- }
9
- if (options.sourceSymbol && !options.sourceFile)
10
- throw new CLIError(`${name("sourceSymbol")} requires ${name("sourceFile")}`, 1, [
11
- "Provide the source file that contains the symbol",
3
+ const VALID_PROVENANCE = ["extracted", "manual", "inferred", "generated"], FILE_REQUIRED_PROVENANCE = ["extracted", "manual"], DEFAULT_PROVENANCE = "manual";
4
+ function hasSourceOptions(options) {
5
+ return !!(options.sourceFile || options.sourceSymbol || options.sourceProvenance || options.sourceRepoRemote || options.sourceRepoCommit);
6
+ }
7
+ function referenceFiles(reference) {
8
+ return [...new Set((reference?.locations ?? []).map((location) => location.file).filter(Boolean))];
9
+ }
10
+ function symbolOnExistingFile(options, surface, existing) {
11
+ const name = (option) => optionName(surface, option), example = (option, value) => optionExample(surface, option, value), files = referenceFiles(existing);
12
+ if (files.length === 0)
13
+ throw new CLIError(`${name("sourceSymbol")} needs ${name("sourceFile")}: a symbol names something inside a file, and the element has no source file to put it on`, 1, [
14
+ "Send the source file that contains the symbol with it",
12
15
  `Example: ${example("sourceFile", "src/auth.ts")} ${example("sourceSymbol", "validateToken")}`
13
16
  ]);
14
- if (!options.sourceProvenance)
15
- throw new CLIError(`${name("sourceProvenance")} is required when specifying source reference`, 1, [
16
- "Specify provenance type: extracted, manual, inferred, or generated"
17
- ]);
18
- const fileOptionalProvenances = ["inferred", "generated"];
19
- if (!options.sourceFile && !fileOptionalProvenances.includes(options.sourceProvenance))
20
- throw new CLIError(`${name("sourceFile")} is required when specifying source reference`, 1, [
21
- "Provide source file path relative to repository root",
22
- `Example: ${example("sourceFile", "src/services/auth.ts")}`,
23
- `Note: ${name("sourceFile")} is optional when ${name("sourceProvenance")} is 'inferred' or 'generated'`
24
- ]);
25
- const validProvenance = ["extracted", "manual", "inferred", "generated"];
26
- if (!validProvenance.includes(options.sourceProvenance))
17
+ if (files.length > 1)
18
+ throw new CLIError(`${name("sourceSymbol")} alone is ambiguous: this element has ${files.length} source files (${files.join(", ")})`, 1, [`Send ${name("sourceFile")} with it to say which file holds the symbol`]);
19
+ const file = files[0], symbol = options.sourceSymbol, current = existing?.locations ?? [], settledLocations = current.length === 1 && !current[0].symbol ? [{ ...current[0], symbol }] : current.some((location) => location.symbol === symbol) ? current.map((location) => ({ ...location })) : [...current.map((location) => ({ ...location })), { file, symbol }], repository = existing?.repository, keepRepository = !options.sourceRepoRemote && !options.sourceRepoCommit && !!repository?.url && !!repository?.commit;
20
+ return {
21
+ ...options,
22
+ sourceFile: file,
23
+ settledLocations,
24
+ sourceProvenance: options.sourceProvenance ?? existing?.provenance ?? DEFAULT_PROVENANCE,
25
+ ...keepRepository ? { sourceRepoRemote: repository.url, sourceRepoCommit: repository.commit } : {}
26
+ };
27
+ }
28
+ function precheckUpdateSourceReferenceOptions(options, surface = "cli") {
29
+ resolveSourceReferenceOptions(options.sourceFile ? options : { ...options, sourceSymbol: void 0 }, surface);
30
+ }
31
+ function resolveSourceReferenceOptions(options, surface = "cli", existing) {
32
+ const name = (option) => optionName(surface, option), example = (option, value) => optionExample(surface, option, value), warnings = [];
33
+ if (!hasSourceOptions(options))
34
+ return { options, warnings };
35
+ if (options.sourceProvenance && !VALID_PROVENANCE.includes(options.sourceProvenance))
27
36
  throw new CLIError(`Invalid ${name("sourceProvenance")} value: "${options.sourceProvenance}"`, 1, [
28
- `Must be one of: ${validProvenance.join(", ")}`
37
+ `Must be one of: ${VALID_PROVENANCE.join(", ")}`
29
38
  ]);
30
- if (options.sourceRepoCommit && !/^[0-9a-f]{40}$/.test(options.sourceRepoCommit))
39
+ if (options.sourceSymbol && !options.sourceFile && (options = symbolOnExistingFile(options, surface, existing)), options.sourceRepoCommit && !/^[0-9a-f]{40}$/.test(options.sourceRepoCommit))
31
40
  throw new CLIError(`Invalid ${name("sourceRepoCommit")} value: "${options.sourceRepoCommit}"`, 1, [
32
41
  "Must be exactly 40 hexadecimal characters (full Git commit SHA)"
33
42
  ]);
@@ -35,14 +44,33 @@ function validateSourceReferenceOptions(options, surface = "cli") {
35
44
  throw new CLIError(`${name("sourceRepoCommit")} is required when ${name("sourceRepoRemote")} is provided`, 1, ["Provide both remote URL and commit SHA for repository context"]);
36
45
  if (options.sourceRepoCommit && !options.sourceRepoRemote)
37
46
  throw new CLIError(`${name("sourceRepoRemote")} is required when ${name("sourceRepoCommit")} is provided`, 1, ["Provide both remote URL and commit SHA for repository context"]);
47
+ if (!options.sourceProvenance) {
48
+ if (!options.sourceFile)
49
+ throw new CLIError(`${name("sourceRepoRemote")} and ${name("sourceRepoCommit")} need a source file`, 1, [
50
+ `Send ${name("sourceFile")} with them, e.g. ${example("sourceFile", "src/services/auth.ts")}`
51
+ ]);
52
+ const provenance = existing?.provenance ?? DEFAULT_PROVENANCE;
53
+ return warnings.push(`${name("sourceProvenance")} was not given; used '${provenance}'` + (existing?.provenance ? " (the element's current provenance)." : ".")), { options: { ...options, sourceProvenance: provenance }, warnings };
54
+ }
55
+ return !(!!options.sourceFile || (existing?.locations?.length ?? 0) > 0) && FILE_REQUIRED_PROVENANCE.includes(options.sourceProvenance) ? (warnings.push(`${name("sourceProvenance")} '${options.sourceProvenance}' was ignored: it needs a ${name("sourceFile")}, so no source reference was recorded. For an element with no code yet, send no source fields (or ${example("sourceProvenance", "inferred")}); otherwise send ${name("sourceFile")} with it.`), { options: {}, warnings }) : { options, warnings };
56
+ }
57
+ function validateSourceReferenceOptions(options, surface = "cli") {
58
+ if (!hasSourceOptions(options)) {
59
+ if (process.stderr.isTTY) {
60
+ const name = (option) => optionName(surface, option);
61
+ console.warn(`Warning: no source provenance specified. Add ${name("sourceFile")} and ${name("sourceProvenance")} to maintain traceability for drift detection and /dr-sync workflows.`);
62
+ }
63
+ return;
64
+ }
65
+ resolveSourceReferenceOptions(options, surface);
38
66
  }
39
- function buildSourceReference(options) {
67
+ function buildSourceReference(options, existing) {
40
68
  if (!options.sourceProvenance)
41
69
  return;
42
70
  const reference = {
43
71
  provenance: options.sourceProvenance
44
72
  };
45
- return options.sourceFile && (reference.locations = [
73
+ return options.settledLocations ? reference.locations = options.settledLocations : options.sourceFile && (reference.locations = [
46
74
  {
47
75
  file: options.sourceFile,
48
76
  ...options.sourceSymbol && { symbol: options.sourceSymbol }
@@ -50,10 +78,13 @@ function buildSourceReference(options) {
50
78
  ]), options.sourceRepoRemote && options.sourceRepoCommit && (reference.repository = {
51
79
  url: options.sourceRepoRemote,
52
80
  commit: options.sourceRepoCommit
53
- }), reference;
81
+ }), !reference.locations && existing?.locations?.length && (reference.locations = existing.locations, !reference.repository && existing.repository && (reference.repository = existing.repository)), reference;
54
82
  }
55
83
  export {
84
+ DEFAULT_PROVENANCE,
56
85
  buildSourceReference,
86
+ precheckUpdateSourceReferenceOptions,
87
+ resolveSourceReferenceOptions,
57
88
  validateSourceReferenceOptions
58
89
  };
59
90
  //# sourceMappingURL=source-reference.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/utils/source-reference.ts"],
4
- "sourcesContent": ["/**\n * Source Reference Utilities\n * Shared validation and builder functions for source reference options\n */\n\nimport type { SourceReference, ProvenanceType } from \"../types/index.js\";\nimport { CLIError } from \"./errors.js\";\nimport { optionExample, optionName, type HintOption, type HintSurface } from \"./error-hints.js\";\n\nexport interface SourceReferenceOptions {\n sourceFile?: string;\n sourceSymbol?: string;\n sourceProvenance?: string;\n sourceRepoRemote?: string;\n sourceRepoCommit?: string;\n}\n\n/**\n * Validate source reference options\n */\nexport function validateSourceReferenceOptions(\n options: SourceReferenceOptions,\n surface: HintSurface = \"cli\"\n): void {\n const name = (option: HintOption) => optionName(surface, option);\n const example = (option: HintOption, value: string) => optionExample(surface, option, value);\n const hasSourceOptions =\n options.sourceFile ||\n options.sourceSymbol ||\n options.sourceProvenance ||\n options.sourceRepoRemote ||\n options.sourceRepoCommit;\n\n if (!hasSourceOptions) {\n if (process.stderr.isTTY) {\n console.warn(\n `Warning: no source provenance specified. Add ${name(\"sourceFile\")} and ${name(\"sourceProvenance\")} to maintain traceability for drift detection and /dr-sync workflows.`\n );\n }\n return;\n }\n\n // Require source-symbol to have source-file (both or neither is not acceptable here)\n if (options.sourceSymbol && !options.sourceFile) {\n throw new CLIError(`${name(\"sourceSymbol\")} requires ${name(\"sourceFile\")}`, 1, [\n \"Provide the source file that contains the symbol\",\n `Example: ${example(\"sourceFile\", \"src/auth.ts\")} ${example(\"sourceSymbol\", \"validateToken\")}`,\n ]);\n }\n\n // Require provenance first so we can use it to determine file requirements\n if (!options.sourceProvenance) {\n throw new CLIError(`${name(\"sourceProvenance\")} is required when specifying source reference`, 1, [\n \"Specify provenance type: extracted, manual, inferred, or generated\",\n ]);\n }\n\n // For inferred and generated provenance, source file is optional (no code exists yet)\n const fileOptionalProvenances = [\"inferred\", \"generated\"];\n if (!options.sourceFile && !fileOptionalProvenances.includes(options.sourceProvenance)) {\n throw new CLIError(`${name(\"sourceFile\")} is required when specifying source reference`, 1, [\n \"Provide source file path relative to repository root\",\n `Example: ${example(\"sourceFile\", \"src/services/auth.ts\")}`,\n `Note: ${name(\"sourceFile\")} is optional when ${name(\"sourceProvenance\")} is 'inferred' or 'generated'`,\n ]);\n }\n\n // Validate provenance enum\n const validProvenance = [\"extracted\", \"manual\", \"inferred\", \"generated\"];\n if (!validProvenance.includes(options.sourceProvenance)) {\n throw new CLIError(`Invalid ${name(\"sourceProvenance\")} value: \"${options.sourceProvenance}\"`, 1, [\n `Must be one of: ${validProvenance.join(\", \")}`,\n ]);\n }\n\n // Validate commit SHA format if provided\n if (options.sourceRepoCommit && !/^[0-9a-f]{40}$/.test(options.sourceRepoCommit)) {\n throw new CLIError(`Invalid ${name(\"sourceRepoCommit\")} value: \"${options.sourceRepoCommit}\"`, 1, [\n \"Must be exactly 40 hexadecimal characters (full Git commit SHA)\",\n ]);\n }\n\n // Require both or neither repo context fields\n if (options.sourceRepoRemote && !options.sourceRepoCommit) {\n throw new CLIError(\n `${name(\"sourceRepoCommit\")} is required when ${name(\"sourceRepoRemote\")} is provided`,\n 1,\n [\"Provide both remote URL and commit SHA for repository context\"]\n );\n }\n\n if (options.sourceRepoCommit && !options.sourceRepoRemote) {\n throw new CLIError(\n `${name(\"sourceRepoRemote\")} is required when ${name(\"sourceRepoCommit\")} is provided`,\n 1,\n [\"Provide both remote URL and commit SHA for repository context\"]\n );\n }\n}\n\n/**\n * Build SourceReference object from options\n */\nexport function buildSourceReference(options: SourceReferenceOptions): SourceReference | undefined {\n if (!options.sourceProvenance) {\n return undefined;\n }\n\n const reference: SourceReference = {\n provenance: options.sourceProvenance as ProvenanceType,\n };\n\n if (options.sourceFile) {\n reference.locations = [\n {\n file: options.sourceFile,\n ...(options.sourceSymbol && { symbol: options.sourceSymbol }),\n },\n ];\n }\n\n if (options.sourceRepoRemote && options.sourceRepoCommit) {\n reference.repository = {\n url: options.sourceRepoRemote,\n commit: options.sourceRepoCommit,\n };\n }\n\n return reference;\n}\n"],
5
- "mappings": "AAMA,SAAS,gBAAgB;AACzB,SAAS,eAAe,kBAAqD;AAavE,SAAU,+BACd,SACA,UAAuB,OAAK;AAE5B,QAAM,OAAO,CAAC,WAAuB,WAAW,SAAS,MAAM,GACzD,UAAU,CAAC,QAAoB,UAAkB,cAAc,SAAS,QAAQ,KAAK;AAQ3F,MAAI,EANF,QAAQ,cACR,QAAQ,gBACR,QAAQ,oBACR,QAAQ,oBACR,QAAQ,mBAEa;AACrB,IAAI,QAAQ,OAAO,SACjB,QAAQ,KACN,gDAAgD,KAAK,YAAY,CAAC,QAAQ,KAAK,kBAAkB,CAAC,uEAAuE;AAG7K;EACF;AAGA,MAAI,QAAQ,gBAAgB,CAAC,QAAQ;AACnC,UAAM,IAAI,SAAS,GAAG,KAAK,cAAc,CAAC,aAAa,KAAK,YAAY,CAAC,IAAI,GAAG;MAC9E;MACA,YAAY,QAAQ,cAAc,aAAa,CAAC,IAAI,QAAQ,gBAAgB,eAAe,CAAC;KAC7F;AAIH,MAAI,CAAC,QAAQ;AACX,UAAM,IAAI,SAAS,GAAG,KAAK,kBAAkB,CAAC,iDAAiD,GAAG;MAChG;KACD;AAIH,QAAM,0BAA0B,CAAC,YAAY,WAAW;AACxD,MAAI,CAAC,QAAQ,cAAc,CAAC,wBAAwB,SAAS,QAAQ,gBAAgB;AACnF,UAAM,IAAI,SAAS,GAAG,KAAK,YAAY,CAAC,iDAAiD,GAAG;MAC1F;MACA,YAAY,QAAQ,cAAc,sBAAsB,CAAC;MACzD,SAAS,KAAK,YAAY,CAAC,qBAAqB,KAAK,kBAAkB,CAAC;KACzE;AAIH,QAAM,kBAAkB,CAAC,aAAa,UAAU,YAAY,WAAW;AACvE,MAAI,CAAC,gBAAgB,SAAS,QAAQ,gBAAgB;AACpD,UAAM,IAAI,SAAS,WAAW,KAAK,kBAAkB,CAAC,YAAY,QAAQ,gBAAgB,KAAK,GAAG;MAChG,mBAAmB,gBAAgB,KAAK,IAAI,CAAC;KAC9C;AAIH,MAAI,QAAQ,oBAAoB,CAAC,iBAAiB,KAAK,QAAQ,gBAAgB;AAC7E,UAAM,IAAI,SAAS,WAAW,KAAK,kBAAkB,CAAC,YAAY,QAAQ,gBAAgB,KAAK,GAAG;MAChG;KACD;AAIH,MAAI,QAAQ,oBAAoB,CAAC,QAAQ;AACvC,UAAM,IAAI,SACR,GAAG,KAAK,kBAAkB,CAAC,qBAAqB,KAAK,kBAAkB,CAAC,gBACxE,GACA,CAAC,+DAA+D,CAAC;AAIrE,MAAI,QAAQ,oBAAoB,CAAC,QAAQ;AACvC,UAAM,IAAI,SACR,GAAG,KAAK,kBAAkB,CAAC,qBAAqB,KAAK,kBAAkB,CAAC,gBACxE,GACA,CAAC,+DAA+D,CAAC;AAGvE;AAKM,SAAU,qBAAqB,SAA+B;AAClE,MAAI,CAAC,QAAQ;AACX;AAGF,QAAM,YAA6B;IACjC,YAAY,QAAQ;;AAGtB,SAAI,QAAQ,eACV,UAAU,YAAY;IACpB;MACE,MAAM,QAAQ;MACd,GAAI,QAAQ,gBAAgB,EAAE,QAAQ,QAAQ,aAAY;;MAK5D,QAAQ,oBAAoB,QAAQ,qBACtC,UAAU,aAAa;IACrB,KAAK,QAAQ;IACb,QAAQ,QAAQ;MAIb;AACT;",
4
+ "sourcesContent": ["/**\n * Source Reference Utilities\n * Shared validation and builder functions for source reference options\n */\n\nimport type { SourceReference, SourceLocation, ProvenanceType } from \"../types/index.js\";\nimport { CLIError } from \"./errors.js\";\nimport { optionExample, optionName, type HintOption, type HintSurface } from \"./error-hints.js\";\n\nexport interface SourceReferenceOptions {\n sourceFile?: string;\n sourceSymbol?: string;\n sourceProvenance?: string;\n sourceRepoRemote?: string;\n sourceRepoCommit?: string;\n /**\n * Set only by {@link resolveSourceReferenceOptions} for a symbol sent without a file: the\n * element's locations with the symbol added. {@link buildSourceReference} uses them as they are.\n */\n settledLocations?: SourceLocation[];\n}\n\nconst VALID_PROVENANCE = [\"extracted\", \"manual\", \"inferred\", \"generated\"];\n\n/** Provenances the source-references schema documents as needing a location (a file). */\nconst FILE_REQUIRED_PROVENANCE = [\"extracted\", \"manual\"];\n\n/** The provenance given to a source file sent without one. The schema has no `planned` value. */\nexport const DEFAULT_PROVENANCE = \"manual\";\n\nexport interface ResolvedSourceReferenceOptions {\n /** The options to build the reference from: provenance filled in, or the source fields dropped. */\n options: SourceReferenceOptions;\n /** What was defaulted or ignored, to show the caller. */\n warnings: string[];\n}\n\nfunction hasSourceOptions(options: SourceReferenceOptions): boolean {\n return !!(\n options.sourceFile ||\n options.sourceSymbol ||\n options.sourceProvenance ||\n options.sourceRepoRemote ||\n options.sourceRepoCommit\n );\n}\n\n/** The distinct files an element's source reference names, in order. */\nfunction referenceFiles(reference?: SourceReference): string[] {\n return [...new Set((reference?.locations ?? []).map((location) => location.file).filter(Boolean))];\n}\n\n/**\n * Settle a symbol sent without a file, on the element's one source file, keeping its other\n * locations, its provenance and its repository unless the call names them:\n *\n * - one location with no symbol: the symbol goes on it;\n * - otherwise the file holds other symbols: `{file, symbol}` is appended, unless it is there already.\n *\n * Throws when there is no file to keep (always, on add) or more than one to choose from.\n */\nfunction symbolOnExistingFile(\n options: SourceReferenceOptions,\n surface: HintSurface,\n existing?: SourceReference\n): SourceReferenceOptions {\n const name = (option: HintOption) => optionName(surface, option);\n const example = (option: HintOption, value: string) => optionExample(surface, option, value);\n const files = referenceFiles(existing);\n\n if (files.length === 0) {\n throw new CLIError(\n `${name(\"sourceSymbol\")} needs ${name(\"sourceFile\")}: a symbol names something inside a file, and the ` +\n `element has no source file to put it on`,\n 1,\n [\n \"Send the source file that contains the symbol with it\",\n `Example: ${example(\"sourceFile\", \"src/auth.ts\")} ${example(\"sourceSymbol\", \"validateToken\")}`,\n ]\n );\n }\n if (files.length > 1) {\n throw new CLIError(\n `${name(\"sourceSymbol\")} alone is ambiguous: this element has ${files.length} source files (${files.join(\", \")})`,\n 1,\n [`Send ${name(\"sourceFile\")} with it to say which file holds the symbol`]\n );\n }\n\n const file = files[0];\n const symbol = options.sourceSymbol!;\n const current = existing?.locations ?? [];\n const settledLocations: SourceLocation[] =\n current.length === 1 && !current[0].symbol\n ? [{ ...current[0], symbol }]\n : current.some((location) => location.symbol === symbol)\n ? current.map((location) => ({ ...location }))\n : [...current.map((location) => ({ ...location })), { file, symbol }];\n\n const repository = existing?.repository;\n const keepRepository =\n !options.sourceRepoRemote && !options.sourceRepoCommit && !!repository?.url && !!repository?.commit;\n return {\n ...options,\n sourceFile: file,\n settledLocations,\n sourceProvenance: options.sourceProvenance ?? existing?.provenance ?? DEFAULT_PROVENANCE,\n ...(keepRepository ? { sourceRepoRemote: repository!.url, sourceRepoCommit: repository!.commit } : {}),\n };\n}\n\n/**\n * Refuse, before the element is loaded, the source options no element could settle. A symbol sent\n * without a file is left to {@link resolveSourceReferenceOptions} with the element's reference: on\n * update it can go on the element's own file.\n */\nexport function precheckUpdateSourceReferenceOptions(\n options: SourceReferenceOptions,\n surface: HintSurface = \"cli\"\n): void {\n resolveSourceReferenceOptions(options.sourceFile ? options : { ...options, sourceSymbol: undefined }, surface);\n}\n\n/**\n * Check source reference options and settle the two partial forms instead of refusing them:\n *\n * - A file without a provenance gets the element's current provenance (`existing`, on update),\n * else `manual`.\n * - A symbol without a file goes on the element's one source file (`existing`, on update), keeping\n * its provenance and repository.\n * - An `extracted` or `manual` provenance without a file (and no file on the element already) is\n * ignored with a warning: the schema documents a location as required for those two. `inferred`\n * and `generated` need no file.\n *\n * Throws for what cannot be settled: an unknown provenance, a symbol with no file sent or kept, a bad commit\n * SHA, or repository fields without a file or with one of the pair missing.\n */\nexport function resolveSourceReferenceOptions(\n options: SourceReferenceOptions,\n surface: HintSurface = \"cli\",\n existing?: SourceReference\n): ResolvedSourceReferenceOptions {\n const name = (option: HintOption) => optionName(surface, option);\n const example = (option: HintOption, value: string) => optionExample(surface, option, value);\n const warnings: string[] = [];\n\n if (!hasSourceOptions(options)) {\n return { options, warnings };\n }\n\n if (options.sourceProvenance && !VALID_PROVENANCE.includes(options.sourceProvenance)) {\n throw new CLIError(`Invalid ${name(\"sourceProvenance\")} value: \"${options.sourceProvenance}\"`, 1, [\n `Must be one of: ${VALID_PROVENANCE.join(\", \")}`,\n ]);\n }\n\n // A symbol names something inside a file. Sent alone on update, it goes on the element's own\n // file, keeping the provenance and repository; with no file to keep, the file must be sent.\n if (options.sourceSymbol && !options.sourceFile) {\n options = symbolOnExistingFile(options, surface, existing);\n }\n\n // Validate commit SHA format if provided\n if (options.sourceRepoCommit && !/^[0-9a-f]{40}$/.test(options.sourceRepoCommit)) {\n throw new CLIError(`Invalid ${name(\"sourceRepoCommit\")} value: \"${options.sourceRepoCommit}\"`, 1, [\n \"Must be exactly 40 hexadecimal characters (full Git commit SHA)\",\n ]);\n }\n\n // Require both or neither repo context fields\n if (options.sourceRepoRemote && !options.sourceRepoCommit) {\n throw new CLIError(\n `${name(\"sourceRepoCommit\")} is required when ${name(\"sourceRepoRemote\")} is provided`,\n 1,\n [\"Provide both remote URL and commit SHA for repository context\"]\n );\n }\n\n if (options.sourceRepoCommit && !options.sourceRepoRemote) {\n throw new CLIError(\n `${name(\"sourceRepoRemote\")} is required when ${name(\"sourceRepoCommit\")} is provided`,\n 1,\n [\"Provide both remote URL and commit SHA for repository context\"]\n );\n }\n\n if (!options.sourceProvenance) {\n if (!options.sourceFile) {\n // Only repository fields: there is no file to attach them to.\n throw new CLIError(`${name(\"sourceRepoRemote\")} and ${name(\"sourceRepoCommit\")} need a source file`, 1, [\n `Send ${name(\"sourceFile\")} with them, e.g. ${example(\"sourceFile\", \"src/services/auth.ts\")}`,\n ]);\n }\n const provenance = existing?.provenance ?? DEFAULT_PROVENANCE;\n warnings.push(\n `${name(\"sourceProvenance\")} was not given; used '${provenance}'` +\n (existing?.provenance ? \" (the element's current provenance).\" : \".\")\n );\n return { options: { ...options, sourceProvenance: provenance }, warnings };\n }\n\n const hasFile = !!options.sourceFile || (existing?.locations?.length ?? 0) > 0;\n if (!hasFile && FILE_REQUIRED_PROVENANCE.includes(options.sourceProvenance)) {\n warnings.push(\n `${name(\"sourceProvenance\")} '${options.sourceProvenance}' was ignored: it needs a ${name(\"sourceFile\")}, so no ` +\n `source reference was recorded. For an element with no code yet, send no source fields (or ` +\n `${example(\"sourceProvenance\", \"inferred\")}); otherwise send ${name(\"sourceFile\")} with it.`\n );\n return { options: {}, warnings };\n }\n\n return { options, warnings };\n}\n\n/**\n * Validate source reference options. Throws where {@link resolveSourceReferenceOptions} throws;\n * the partial forms it settles pass.\n */\nexport function validateSourceReferenceOptions(\n options: SourceReferenceOptions,\n surface: HintSurface = \"cli\"\n): void {\n if (!hasSourceOptions(options)) {\n if (process.stderr.isTTY) {\n const name = (option: HintOption) => optionName(surface, option);\n console.warn(\n `Warning: no source provenance specified. Add ${name(\"sourceFile\")} and ${name(\"sourceProvenance\")} to maintain traceability for drift detection and /dr-sync workflows.`\n );\n }\n return;\n }\n resolveSourceReferenceOptions(options, surface);\n}\n\n/**\n * Build SourceReference object from options. On update, pass the element's current reference as\n * `existing`: a change that names no file keeps its locations (and its repository with them).\n */\nexport function buildSourceReference(\n options: SourceReferenceOptions,\n existing?: SourceReference\n): SourceReference | undefined {\n if (!options.sourceProvenance) {\n return undefined;\n }\n\n const reference: SourceReference = {\n provenance: options.sourceProvenance as ProvenanceType,\n };\n\n if (options.settledLocations) {\n reference.locations = options.settledLocations;\n } else if (options.sourceFile) {\n reference.locations = [\n {\n file: options.sourceFile,\n ...(options.sourceSymbol && { symbol: options.sourceSymbol }),\n },\n ];\n }\n\n if (options.sourceRepoRemote && options.sourceRepoCommit) {\n reference.repository = {\n url: options.sourceRepoRemote,\n commit: options.sourceRepoCommit,\n };\n }\n\n if (!reference.locations && existing?.locations?.length) {\n reference.locations = existing.locations;\n if (!reference.repository && existing.repository) reference.repository = existing.repository;\n }\n\n return reference;\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,7 +1,7 @@
1
1
  import { readJSON, fileExists } from "./file-io.js";
2
2
  import { join } from "path";
3
3
  import { createRequire } from "node:module";
4
- const BUNDLED_SPEC_VERSION = "0.11.0";
4
+ const BUNDLED_SPEC_VERSION = "0.12.0";
5
5
  function resolveDevVersion() {
6
6
  try {
7
7
  return createRequire(import.meta.url)("../../package.json").version ?? "0.0.0";
@@ -10,7 +10,7 @@ function resolveDevVersion() {
10
10
  }
11
11
  }
12
12
  function getCliVersion() {
13
- return "0.3.0";
13
+ return "0.4.0";
14
14
  }
15
15
  function getCliBundledSpecVersion() {
16
16
  return BUNDLED_SPEC_VERSION;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/utils/spec-version.ts"],
4
- "sourcesContent": ["/**\n * Spec Version Utilities\n *\n * Manages spec version information for the CLI\n */\n\nimport { readJSON, fileExists } from \"./file-io.js\";\nimport { join } from \"path\";\nimport { createRequire } from \"node:module\";\n\n// Declare build-time constant (substituted by esbuild)\ndeclare const CLI_VERSION: string;\n\n/**\n * Bundled spec version\n * This should match the version in ../../../spec/VERSION\n * Updated during build process\n */\nconst BUNDLED_SPEC_VERSION = \"0.11.0\";\n\n// Dev-mode fallback (e.g. `tsx src/cli.ts`) that reads package.json directly, so it can't drift.\nfunction resolveDevVersion(): string {\n try {\n const require = createRequire(import.meta.url);\n const pkg = require(\"../../package.json\") as { version?: string };\n return pkg.version ?? \"0.0.0\";\n } catch (error) {\n console.error(\n `Failed to resolve dev-mode CLI version from package.json, falling back to \"0.0.0\": ${\n error instanceof Error ? error.message : String(error)\n }`\n );\n return \"0.0.0\";\n }\n}\n\n/**\n * Get the CLI version\n *\n * @returns CLI version string\n */\nexport function getCliVersion(): string {\n return typeof CLI_VERSION !== \"undefined\" ? CLI_VERSION : resolveDevVersion();\n}\n\n/**\n * Get the CLI's bundled spec version\n *\n * This is the version of the DR specification that is bundled with this CLI.\n * It represents what version of the spec this CLI supports.\n *\n * @returns Bundled spec version string\n */\nexport function getCliBundledSpecVersion(): string {\n return BUNDLED_SPEC_VERSION;\n}\n\n/**\n * Read spec version from .dr/manifest.json (installed spec reference)\n *\n * @param drPath - Path to .dr/ directory\n * @returns Spec version from .dr/manifest.json, or null if not found\n */\nexport async function getInstalledSpecVersion(drPath: string): Promise<string | null> {\n const manifestPath = join(drPath, \"manifest.json\");\n\n if (!(await fileExists(manifestPath))) {\n return null;\n }\n\n try {\n const manifest = await readJSON<{ specVersion?: string }>(manifestPath);\n return manifest.specVersion || null;\n } catch {\n return null;\n }\n}\n\n/**\n * Read spec version from documentation_robotics/model/manifest.yaml\n *\n * @param modelPath - Path to model directory\n * @returns Spec version from model manifest, or null if not found\n */\nexport async function getModelSpecVersion(modelPath: string): Promise<string | null> {\n const manifestPath = join(modelPath, \"manifest.yaml\");\n\n if (!(await fileExists(manifestPath))) {\n return null;\n }\n\n try {\n // Use YAML parser\n const yaml = await import(\"yaml\");\n const fs = await import(\"fs/promises\");\n const content = await fs.readFile(manifestPath, \"utf-8\");\n const manifest = yaml.parse(content) as { spec_version?: string; specVersion?: string };\n // Check snake_case first (Python CLI format), then camelCase (legacy)\n return manifest.spec_version || manifest.specVersion || null;\n } catch {\n return null;\n }\n}\n"],
4
+ "sourcesContent": ["/**\n * Spec Version Utilities\n *\n * Manages spec version information for the CLI\n */\n\nimport { readJSON, fileExists } from \"./file-io.js\";\nimport { join } from \"path\";\nimport { createRequire } from \"node:module\";\n\n// Declare build-time constant (substituted by esbuild)\ndeclare const CLI_VERSION: string;\n\n/**\n * Bundled spec version\n * This should match the version in ../../../spec/VERSION\n * Updated during build process\n */\nconst BUNDLED_SPEC_VERSION = \"0.12.0\";\n\n// Dev-mode fallback (e.g. `tsx src/cli.ts`) that reads package.json directly, so it can't drift.\nfunction resolveDevVersion(): string {\n try {\n const require = createRequire(import.meta.url);\n const pkg = require(\"../../package.json\") as { version?: string };\n return pkg.version ?? \"0.0.0\";\n } catch (error) {\n console.error(\n `Failed to resolve dev-mode CLI version from package.json, falling back to \"0.0.0\": ${\n error instanceof Error ? error.message : String(error)\n }`\n );\n return \"0.0.0\";\n }\n}\n\n/**\n * Get the CLI version\n *\n * @returns CLI version string\n */\nexport function getCliVersion(): string {\n return typeof CLI_VERSION !== \"undefined\" ? CLI_VERSION : resolveDevVersion();\n}\n\n/**\n * Get the CLI's bundled spec version\n *\n * This is the version of the DR specification that is bundled with this CLI.\n * It represents what version of the spec this CLI supports.\n *\n * @returns Bundled spec version string\n */\nexport function getCliBundledSpecVersion(): string {\n return BUNDLED_SPEC_VERSION;\n}\n\n/**\n * Read spec version from .dr/manifest.json (installed spec reference)\n *\n * @param drPath - Path to .dr/ directory\n * @returns Spec version from .dr/manifest.json, or null if not found\n */\nexport async function getInstalledSpecVersion(drPath: string): Promise<string | null> {\n const manifestPath = join(drPath, \"manifest.json\");\n\n if (!(await fileExists(manifestPath))) {\n return null;\n }\n\n try {\n const manifest = await readJSON<{ specVersion?: string }>(manifestPath);\n return manifest.specVersion || null;\n } catch {\n return null;\n }\n}\n\n/**\n * Read spec version from documentation_robotics/model/manifest.yaml\n *\n * @param modelPath - Path to model directory\n * @returns Spec version from model manifest, or null if not found\n */\nexport async function getModelSpecVersion(modelPath: string): Promise<string | null> {\n const manifestPath = join(modelPath, \"manifest.yaml\");\n\n if (!(await fileExists(manifestPath))) {\n return null;\n }\n\n try {\n // Use YAML parser\n const yaml = await import(\"yaml\");\n const fs = await import(\"fs/promises\");\n const content = await fs.readFile(manifestPath, \"utf-8\");\n const manifest = yaml.parse(content) as { spec_version?: string; specVersion?: string };\n // Check snake_case first (Python CLI format), then camelCase (legacy)\n return manifest.spec_version || manifest.specVersion || null;\n } catch {\n return null;\n }\n}\n"],
5
5
  "mappings": "AAMA,SAAS,UAAU,kBAAkB;AACrC,SAAS,YAAY;AACrB,SAAS,qBAAqB;AAU9B,MAAM,uBAAuB;AAG7B,SAAS,oBAAiB;AACxB,MAAI;AAGF,WAFgB,cAAc,YAAY,GAAG,EACzB,oBAAoB,EAC7B,WAAW;EACxB,SAAS,OAAO;AACd,mBAAQ,MACN,sFACE,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,CACvD,EAAE,GAEG;EACT;AACF;AAOM,SAAU,gBAAa;AAC3B,SAA4C;AAC9C;AAUM,SAAU,2BAAwB;AACtC,SAAO;AACT;AAQA,eAAsB,wBAAwB,QAAc;AAC1D,QAAM,eAAe,KAAK,QAAQ,eAAe;AAEjD,MAAI,CAAE,MAAM,WAAW,YAAY;AACjC,WAAO;AAGT,MAAI;AAEF,YADiB,MAAM,SAAmC,YAAY,GACtD,eAAe;EACjC,QAAQ;AACN,WAAO;EACT;AACF;AAQA,eAAsB,oBAAoB,WAAiB;AACzD,QAAM,eAAe,KAAK,WAAW,eAAe;AAEpD,MAAI,CAAE,MAAM,WAAW,YAAY;AACjC,WAAO;AAGT,MAAI;AAEF,UAAM,OAAO,MAAM,OAAO,MAAM,GAE1B,UAAU,OADL,MAAM,OAAO,aAAa,GACZ,SAAS,cAAc,OAAO,GACjD,WAAW,KAAK,MAAM,OAAO;AAEnC,WAAO,SAAS,gBAAgB,SAAS,eAAe;EAC1D,QAAQ;AACN,WAAO;EACT;AACF;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,28 @@
1
+ import { ValidationFormatter } from "./validation-formatter.js";
2
+ function addModelWarnings(result, model) {
3
+ const stats = ValidationFormatter.calculateStats(model);
4
+ for (const orphanId of stats.orphanedElements)
5
+ result.addWarning({
6
+ message: `Element '${orphanId}' is orphaned (no cross-layer references or intra-layer relationships)`,
7
+ layer: orphanId.split(".")[0] ?? "",
8
+ elementId: orphanId,
9
+ category: "orphan",
10
+ fixSuggestion: "Add cross-layer references or relationships to connect this element to the rest of the model"
11
+ });
12
+ for (const [layerName, layer] of model.layers)
13
+ for (const element of layer.listElements()) {
14
+ if (element.source_reference)
15
+ continue;
16
+ const elementId = element.path || element.id;
17
+ result.addWarning({
18
+ message: `Element '${elementId}' has no source reference`,
19
+ layer: layerName,
20
+ elementId,
21
+ fixSuggestion: "Add a source_reference with provenance to link this element to its implementation"
22
+ });
23
+ }
24
+ }
25
+ export {
26
+ addModelWarnings
27
+ };
28
+ //# sourceMappingURL=model-warnings.js.map