@decantr/cli 2.8.0 → 2.9.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.
@@ -1,1756 +1,2073 @@
1
- // src/bundled-content.ts
2
- import { existsSync, readdirSync, readFileSync } from "fs";
3
- import { dirname, join } from "path";
4
- import { fileURLToPath } from "url";
5
- function bundledDirCandidates(contentType) {
6
- const currentDir = dirname(fileURLToPath(import.meta.url));
7
- return [
8
- join(currentDir, "bundled", contentType),
9
- join(currentDir, "..", "src", "bundled", contentType),
10
- join(currentDir, "..", "bundled", contentType)
11
- ];
1
+ // src/commands/heal.ts
2
+ import { existsSync as existsSync10, readFileSync as readFileSync10 } from "fs";
3
+ import { join as join10 } from "path";
4
+ import { evaluateGuard, isV4, validateEssence } from "@decantr/essence-spec";
5
+
6
+ // src/brownfield-check.ts
7
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
8
+ import { join as join5 } from "path";
9
+
10
+ // src/ambient-context.ts
11
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
12
+ import { basename, extname, join, relative, sep } from "path";
13
+ var SKIP_DIRS = /* @__PURE__ */ new Set([
14
+ ".decantr",
15
+ ".git",
16
+ ".next",
17
+ ".nuxt",
18
+ ".svelte-kit",
19
+ ".turbo",
20
+ ".vercel",
21
+ "build",
22
+ "coverage",
23
+ "dist",
24
+ "node_modules",
25
+ "playwright-report"
26
+ ]);
27
+ var ROOT_CONTEXT_FILES = /* @__PURE__ */ new Set([
28
+ "AGENTS.md",
29
+ "CLAUDE.md",
30
+ "GEMINI.md",
31
+ "README.md",
32
+ "copilot-instructions.md",
33
+ ".cursorrules",
34
+ ".windsurfrules",
35
+ ".cursorignore",
36
+ ".claudeignore",
37
+ "components.json",
38
+ "tailwind.config.js",
39
+ "tailwind.config.ts",
40
+ "tailwind.config.mjs",
41
+ "tailwind.config.cjs",
42
+ "next.config.js",
43
+ "next.config.ts",
44
+ "next.config.mjs",
45
+ "nuxt.config.js",
46
+ "nuxt.config.ts",
47
+ "astro.config.mjs",
48
+ "astro.config.ts",
49
+ "svelte.config.js",
50
+ "svelte.config.ts",
51
+ "angular.json",
52
+ "vite.config.js",
53
+ "vite.config.ts",
54
+ "vitest.config.ts",
55
+ "vitest.config.js",
56
+ "playwright.config.ts",
57
+ "playwright.config.js",
58
+ "tsconfig.json",
59
+ "package.json",
60
+ "decantr.essence.json"
61
+ ]);
62
+ var CONTEXT_DIRECTORIES = /* @__PURE__ */ new Set([
63
+ ".agents",
64
+ ".claude",
65
+ ".claude/initiatives",
66
+ ".claude/rules",
67
+ ".codex",
68
+ ".cursor",
69
+ ".cursor/rules",
70
+ ".github/workflows",
71
+ "docs",
72
+ "docs/initiatives",
73
+ "initiatives",
74
+ "memory",
75
+ "memories",
76
+ "project-memory",
77
+ "supabase"
78
+ ]);
79
+ function shouldSkipDir(name) {
80
+ return SKIP_DIRS.has(name);
12
81
  }
13
- function getBundledContentPath(contentType, id) {
14
- for (const dir of bundledDirCandidates(contentType)) {
15
- const candidate = join(dir, `${id}.json`);
16
- if (existsSync(candidate)) return candidate;
17
- }
18
- return null;
82
+ function normalizedPath(relPath) {
83
+ return relPath.split(sep).join("/");
19
84
  }
20
- function loadBundledContentItem(contentType, id) {
21
- const path = getBundledContentPath(contentType, id);
22
- if (!path) return null;
23
- try {
24
- const data = JSON.parse(readFileSync(path, "utf-8"));
25
- return { id, data, path };
26
- } catch {
27
- return null;
28
- }
85
+ function isPotentialContextFile(relPath, name) {
86
+ const normalized2 = normalizedPath(relPath);
87
+ if (ROOT_CONTEXT_FILES.has(name)) return true;
88
+ if (name.startsWith(".env")) return true;
89
+ if (normalized2.startsWith(".claude/")) return true;
90
+ if (normalized2.startsWith(".agents/")) return true;
91
+ if (normalized2.startsWith(".codex/")) return true;
92
+ if (normalized2.startsWith(".cursor/")) return true;
93
+ if (normalized2.startsWith(".github/workflows/")) return true;
94
+ if (normalized2.startsWith("docs/")) return true;
95
+ if (normalized2.startsWith("initiatives/")) return true;
96
+ if (normalized2.startsWith("memory/")) return true;
97
+ if (normalized2.startsWith("memories/")) return true;
98
+ if (normalized2.startsWith("project-memory/")) return true;
99
+ if (normalized2.startsWith("supabase/")) return true;
100
+ if (normalized2.startsWith("migrations/")) return true;
101
+ if (normalized2.startsWith("db/")) return true;
102
+ if (normalized2.startsWith("ROLEMIGRATIONS/")) return true;
103
+ if (normalized2 === "src/middleware.ts" || normalized2 === "middleware.ts") return true;
104
+ if (normalized2.includes("/middleware.")) return true;
105
+ const ext = extname(name).toLowerCase();
106
+ return ext === ".md" || ext === ".mdx" || ext === ".sql" || ext === ".yml" || ext === ".yaml";
29
107
  }
30
- function loadBundledContentList(contentType) {
31
- const entries = [];
32
- const seen = /* @__PURE__ */ new Set();
33
- for (const dir of bundledDirCandidates(contentType)) {
34
- if (!existsSync(dir)) continue;
35
- try {
36
- for (const file of readdirSync(dir).filter((name) => name.endsWith(".json"))) {
37
- const id = file.replace(/\.json$/, "");
38
- if (seen.has(id)) continue;
39
- const path = join(dir, file);
40
- const data = JSON.parse(readFileSync(path, "utf-8"));
41
- entries.push({ id, data, path });
42
- seen.add(id);
43
- }
44
- } catch {
45
- }
108
+ function classifyContext(relPath) {
109
+ const normalized2 = normalizedPath(relPath);
110
+ const lower = normalized2.toLowerCase();
111
+ const name = basename(normalized2);
112
+ if (lower === "decantr.essence.json") {
113
+ return {
114
+ role: "architecture",
115
+ confidence: 0.82,
116
+ reason: "existing Decantr contract evidence"
117
+ };
46
118
  }
47
- return entries;
119
+ if (lower === ".claude/initiatives" || lower === "docs/initiatives" || lower === "initiatives" || lower === "memory" || lower === "memories" || lower === "project-memory" || lower.startsWith(".claude/initiatives/") || lower.startsWith("docs/initiatives/") || lower.startsWith("initiatives/") || lower.startsWith("memory/") || lower.startsWith("memories/") || lower.startsWith("project-memory/") || lower.includes("/feature/") || lower.includes("feature") || lower.includes("rbac") || lower.includes("billing") || lower.includes("admin") || lower.includes("dashboard")) {
120
+ return {
121
+ role: "feature-business",
122
+ confidence: 0.78,
123
+ reason: "feature, initiative, memory, or business-domain evidence"
124
+ };
125
+ }
126
+ if (lower === ".agents" || lower === ".claude" || lower === ".codex" || lower === ".cursor" || lower === "claude.md" || lower === "agents.md" || lower === "gemini.md" || lower === "copilot-instructions.md" || lower === ".cursorrules" || lower === ".windsurfrules" || lower.startsWith(".claude/") || lower.startsWith(".agents/") || lower.startsWith(".codex/") || lower.startsWith(".cursor/rules/")) {
127
+ return {
128
+ role: "assistant-specific",
129
+ confidence: 0.98,
130
+ reason: "assistant or AI-agent instruction surface"
131
+ };
132
+ }
133
+ if (lower.includes("security") || lower.includes("auth") || lower.includes("rls") || lower.includes("schema") || lower.includes("migration") || lower.startsWith("supabase/") || lower.startsWith("migrations/") || lower.startsWith("db/") || lower.startsWith("rolemigrations/") || lower.includes("middleware.")) {
134
+ return {
135
+ role: "security-data",
136
+ confidence: 0.9,
137
+ reason: "security, auth, schema, middleware, or data-governance evidence"
138
+ };
139
+ }
140
+ if (lower.includes("design-system") || lower === "components.json" || lower.startsWith("tailwind.config") || lower.includes("ui-components") || lower.includes("colors") || lower.includes("typography") || lower.includes("spacing")) {
141
+ return {
142
+ role: "design-system",
143
+ confidence: 0.88,
144
+ reason: "design system or styling convention evidence"
145
+ };
146
+ }
147
+ if (lower.startsWith(".github/workflows/") || lower.includes("workflow") || lower.includes("testing") || lower.includes("deployment") || lower.includes("vitest.config") || lower.includes("playwright.config") || lower === "package.json") {
148
+ return {
149
+ role: "workflow-ci",
150
+ confidence: 0.84,
151
+ reason: "workflow, CI, deployment, or validation command evidence"
152
+ };
153
+ }
154
+ if (lower === "docs" || lower.includes("architecture") || lower === "readme.md" || lower.includes("setup") || lower.includes("contributing") || name === "tsconfig.json" || lower.endsWith("config.ts") || lower.endsWith("config.js") || lower.endsWith("config.mjs")) {
155
+ return {
156
+ role: "architecture",
157
+ confidence: 0.72,
158
+ reason: "architecture, setup, or framework configuration evidence"
159
+ };
160
+ }
161
+ if (lower.includes("complete") || lower.includes("summary") || lower.includes("deprecated") || lower.includes("legacy") || lower.includes("migration")) {
162
+ return {
163
+ role: "stale-or-historical",
164
+ confidence: 0.64,
165
+ reason: "historical or possibly stale project documentation"
166
+ };
167
+ }
168
+ return { role: "unknown", confidence: 0.35, reason: "unclassified context candidate" };
48
169
  }
49
-
50
- // src/telemetry.ts
51
- import { randomUUID } from "crypto";
52
- import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
53
- import { homedir } from "os";
54
- import { dirname as dirname2, join as join2, resolve } from "path";
55
- import { fileURLToPath as fileURLToPath2 } from "url";
56
- import {
57
- createFetchTelemetrySink,
58
- createTelemetryClient,
59
- isTelemetryActorType
60
- } from "@decantr/telemetry";
61
- var TELEMETRY_ENDPOINT = "https://api.decantr.ai/v1/telemetry/guard";
62
- var DEFAULT_TELEMETRY_EVENTS_ENDPOINT = "https://api.decantr.ai/v1/telemetry/events";
63
- var TELEMETRY_TIMEOUT_MS = 3e3;
64
- var DNA_RULES = /* @__PURE__ */ new Set(["theme", "style", "density", "accessibility", "theme-mode"]);
65
- async function sendGuardMetrics(metrics) {
66
- try {
67
- const controller = new AbortController();
68
- const timer = setTimeout(() => controller.abort(), TELEMETRY_TIMEOUT_MS);
69
- await fetch(TELEMETRY_ENDPOINT, {
70
- method: "POST",
71
- headers: { "Content-Type": "application/json" },
72
- body: JSON.stringify(metrics),
73
- signal: controller.signal
74
- });
75
- clearTimeout(timer);
76
- } catch {
170
+ function isSafeToCite(relPath) {
171
+ const lower = normalizedPath(relPath).toLowerCase();
172
+ if (lower.startsWith(".env") && lower !== ".env.example" && lower !== ".env.sample") return false;
173
+ if (lower.includes("secret") || lower.includes("private-key") || lower.includes("credentials")) {
174
+ return false;
77
175
  }
176
+ return true;
78
177
  }
79
- function isOptedIn(projectRoot) {
80
- const projectJsonPath = join2(projectRoot, ".decantr", "project.json");
81
- if (!existsSync2(projectJsonPath)) return false;
178
+ function addDirectoryContext(items, projectRoot, relPath) {
179
+ const fullPath = join(projectRoot, relPath);
180
+ if (!existsSync(fullPath)) return;
181
+ const stats = statSync(fullPath);
182
+ const classified = classifyContext(relPath);
183
+ items.push({
184
+ path: normalizedPath(relPath),
185
+ type: "directory",
186
+ role: classified.role,
187
+ confidence: classified.confidence,
188
+ sizeBytes: stats.size,
189
+ safeToCite: isSafeToCite(relPath),
190
+ reason: classified.reason
191
+ });
192
+ }
193
+ function walk(projectRoot, dir, items, depth) {
194
+ if (depth > 6) return;
195
+ let entries;
82
196
  try {
83
- const data = JSON.parse(readFileSync2(projectJsonPath, "utf-8"));
84
- return data.telemetry === true;
197
+ entries = readdirSync(dir);
85
198
  } catch {
86
- return false;
199
+ return;
87
200
  }
88
- }
89
- function optIn(projectRoot) {
90
- const projectJsonPath = join2(projectRoot, ".decantr", "project.json");
91
- let data = {};
92
- if (existsSync2(projectJsonPath)) {
201
+ for (const entry of entries) {
202
+ if (shouldSkipDir(entry)) continue;
203
+ const fullPath = join(dir, entry);
204
+ const relPath = normalizedPath(relative(projectRoot, fullPath));
205
+ let stats;
93
206
  try {
94
- data = JSON.parse(readFileSync2(projectJsonPath, "utf-8"));
207
+ stats = statSync(fullPath);
95
208
  } catch {
209
+ continue;
96
210
  }
211
+ if (stats.isDirectory()) {
212
+ if (CONTEXT_DIRECTORIES.has(relPath)) {
213
+ addDirectoryContext(items, projectRoot, relPath);
214
+ }
215
+ walk(projectRoot, fullPath, items, depth + 1);
216
+ continue;
217
+ }
218
+ if (!stats.isFile() || !isPotentialContextFile(relPath, entry)) continue;
219
+ const classified = classifyContext(relPath);
220
+ items.push({
221
+ path: relPath,
222
+ type: "file",
223
+ role: classified.role,
224
+ confidence: classified.confidence,
225
+ sizeBytes: stats.size,
226
+ safeToCite: isSafeToCite(relPath),
227
+ reason: classified.reason
228
+ });
97
229
  }
98
- data.telemetry = true;
99
- mkdirSync(dirname2(projectJsonPath), { recursive: true });
100
- writeFileSync(projectJsonPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
101
- }
102
- async function captureCliTelemetryEvent(input) {
103
- const projectRoot = resolveCliTelemetryProjectRoot(
104
- input.projectRoot ?? process.cwd(),
105
- input.args ?? []
106
- );
107
- if (!isOptedIn(projectRoot)) {
108
- return;
109
- }
110
- const identities = ensureTelemetryIdentities(projectRoot);
111
- if (!identities) {
112
- return;
113
- }
114
- const registrySource = input.registrySource ?? getRegistrySourceProperty(input.properties) ?? inferRegistrySource(input.args ?? []);
115
- const client = createTelemetryClient({
116
- sink: createFetchTelemetrySink({
117
- endpoint: getTelemetryEventsEndpoint(),
118
- timeoutMs: TELEMETRY_TIMEOUT_MS
119
- })
120
- });
121
- const event = {
122
- name: input.name,
123
- context: {
124
- source: "cli",
125
- actorType: getTelemetryActorType(),
126
- environment: "production",
127
- decantrVersion: getCliVersion(),
128
- installId: identities.installId,
129
- projectId: identities.projectId,
130
- registrySource
131
- },
132
- properties: input.properties
230
+ }
231
+ function summarize(items) {
232
+ const summary = {
233
+ "assistant-specific": 0,
234
+ "security-data": 0,
235
+ architecture: 0,
236
+ "design-system": 0,
237
+ "workflow-ci": 0,
238
+ "feature-business": 0,
239
+ "stale-or-historical": 0,
240
+ unknown: 0
133
241
  };
242
+ for (const item of items) summary[item.role] += 1;
243
+ return summary;
244
+ }
245
+ function readSmallText(projectRoot, relPath) {
246
+ const fullPath = join(projectRoot, relPath);
134
247
  try {
135
- await client.capture(event);
248
+ const stat = statSync(fullPath);
249
+ if (stat.size > 64e3) return "";
250
+ return readFileSync(fullPath, "utf-8");
136
251
  } catch {
252
+ return "";
137
253
  }
138
254
  }
139
- async function sendCliCommandTelemetry(input) {
140
- const projectRoot = resolveCliTelemetryProjectRoot(input.projectRoot ?? process.cwd(), input.args);
141
- const command = normalizeCommand(input.args[0]);
142
- if (!isOptedIn(projectRoot) || !command || command === "help" || command === "version" || isHelpOrVersionProbe(input.args)) {
143
- return;
255
+ function detectConflicts(projectRoot, items) {
256
+ const text = items.filter(
257
+ (item) => item.type === "file" && item.safeToCite && item.path.match(/\.(md|mdx|json|ts|js|yml|yaml)$/)
258
+ ).slice(0, 80).map((item) => readSmallText(projectRoot, item.path)).join("\n").toLowerCase();
259
+ const conflicts = [];
260
+ const frameworkSignals = [
261
+ ["next", /\bnext\.?js\b|\bapp router\b|\bpages router\b/],
262
+ ["angular", /\bangular\b/],
263
+ ["svelte", /\bsvelte\b|\bsveltekit\b/],
264
+ ["vue", /\bvue\b|\bnuxt\b/]
265
+ ].filter(([, pattern]) => pattern.test(text));
266
+ if (frameworkSignals.length > 1) {
267
+ conflicts.push(
268
+ `Multiple framework doctrines appear in ambient docs: ${frameworkSignals.map(([name]) => name).join(", ")}.`
269
+ );
144
270
  }
145
- const properties = buildCliLifecycleProperties({
146
- args: input.args,
147
- command,
148
- durationMs: input.durationMs,
149
- projectRoot,
150
- success: input.success
151
- });
152
- await captureCliTelemetryEvent({
153
- args: input.args,
154
- name: "cli.command.completed",
155
- projectRoot,
156
- properties,
157
- registrySource: properties.registrySource
158
- });
159
- const lifecycleEventName = lifecycleTelemetryEventName(command);
160
- if (lifecycleEventName) {
161
- await captureCliTelemetryEvent({
162
- args: input.args,
163
- name: lifecycleEventName,
164
- projectRoot,
165
- properties,
166
- registrySource: properties.registrySource
167
- });
271
+ const forbidsTailwind = /\b(do not|don't|avoid|forbid|forbidden)\s+use\s+tailwind\b|\bno\s+tailwind\b/.test(text);
272
+ const endorsesTailwind = /\btailwind\.config\b|\btailwindcss\b|\b@tailwind\b|\btailwind\s+classes\b/.test(text);
273
+ if (forbidsTailwind && endorsesTailwind) {
274
+ conflicts.push("Ambient docs contain both Tailwind usage and anti-Tailwind language.");
168
275
  }
169
- }
170
- async function sendProjectHealthReportTelemetry(input) {
171
- const projectRoot = input.projectRoot ?? process.cwd();
172
- const properties = buildProjectHealthTelemetryProperties(input, projectRoot);
173
- await captureCliTelemetryEvent({
174
- name: "health.report.generated",
175
- projectRoot,
176
- properties
177
- });
178
- if (input.report.status === "healthy") {
179
- await captureCliTelemetryEvent({
180
- name: "decantr.health.healthy",
181
- projectRoot,
182
- properties
183
- });
276
+ if (/\bclient component\b/.test(text) && /\bserver components? only\b/.test(text)) {
277
+ conflicts.push("Ambient docs may conflict on client vs server component boundaries.");
184
278
  }
279
+ return conflicts;
185
280
  }
186
- async function sendAnalyzeCompletedTelemetry(input) {
187
- const projectRoot = input.projectRoot ?? process.cwd();
188
- const metadata = readProjectTelemetryMetadata(projectRoot);
189
- const properties = {
190
- command: "analyze",
191
- success: input.success,
192
- durationMs: input.durationMs,
193
- adoptionMode: metadata.adoptionMode ?? "contract-only",
194
- componentCount: input.componentCount,
195
- dependencyCategoryCount: input.dependencyCategoryCount,
196
- errorCode: input.success ? void 0 : "analyze_failed",
197
- pageCount: input.pageCount,
198
- projectScope: metadata.projectScope ?? inferProjectScope(projectRoot),
199
- routeCount: input.routeCount,
200
- targetFramework: input.targetFramework,
201
- workflowMode: metadata.workflowMode ?? "brownfield-attach"
202
- };
203
- await captureCliTelemetryEvent({
204
- args: ["analyze"],
205
- name: "decantr.analyze.completed",
206
- projectRoot,
207
- properties
208
- });
281
+ function detectDecantrEssenceStaleRisk(projectRoot, items) {
282
+ if (!items.some((item) => item.path === "decantr.essence.json")) return [];
283
+ const content = readSmallText(projectRoot, "decantr.essence.json");
284
+ if (!content) return [];
285
+ try {
286
+ const essence = JSON.parse(content);
287
+ const risks = [];
288
+ if (essence.version !== "4.0.0") {
289
+ risks.push(
290
+ `decantr.essence.json uses Decantr essence version ${essence.version ?? "unknown"}; run decantr migrate --to v4 or review before treating it as current brownfield doctrine.`
291
+ );
292
+ }
293
+ if (essence.dna?.theme?.id === "luminarum" && essence.structure) {
294
+ risks.push(
295
+ "decantr.essence.json looks like an older Decantr default scaffold; verify before importing its theme or page layout as brownfield truth."
296
+ );
297
+ }
298
+ return risks;
299
+ } catch {
300
+ return [
301
+ "decantr.essence.json could not be parsed during ambient inventory; review before treating it as current doctrine."
302
+ ];
303
+ }
209
304
  }
210
- async function sendNewProjectCompletedTelemetry(input) {
211
- const projectRoot = input.projectRoot ?? process.cwd();
212
- const args = input.args ?? ["new"];
213
- const base = buildCliLifecycleProperties({
214
- args,
215
- command: "new",
216
- durationMs: input.durationMs,
217
- projectRoot,
218
- success: input.success
219
- });
220
- const properties = {
221
- ...base,
222
- command: "new"
223
- };
224
- await captureCliTelemetryEvent({
225
- args,
226
- name: "decantr.new.completed",
227
- projectRoot,
228
- properties,
229
- registrySource: properties.registrySource
230
- });
305
+ function detectStaleRisks(projectRoot, items) {
306
+ const pathRisks = items.filter(
307
+ (item) => item.role === "stale-or-historical" || /complete|summary|legacy|deprecated/i.test(item.path)
308
+ ).slice(0, 12).map(
309
+ (item) => `${item.path} may be historical; verify before treating it as current doctrine.`
310
+ );
311
+ return [...pathRisks, ...detectDecantrEssenceStaleRisk(projectRoot, items)];
231
312
  }
232
- async function sendProjectHealthPromptTelemetry(input) {
233
- const projectRoot = input.projectRoot ?? process.cwd();
234
- const finding = input.finding;
235
- const properties = {
236
- success: Boolean(finding),
237
- findingFound: Boolean(finding),
238
- adoptionMode: normalizeAdoptionMode(input.report.summary.adoptionMode),
239
- ci: input.ci ?? false,
240
- findingSeverity: normalizeFindingSeverity(finding?.severity),
241
- findingSource: normalizeFindingSource(finding?.source),
242
- projectScope: inferProjectScope(projectRoot),
243
- workflowMode: normalizeWorkflowMode(input.report.summary.workflowMode)
313
+ function scanAmbientContext(projectRoot) {
314
+ const items = [];
315
+ walk(projectRoot, projectRoot, items, 0);
316
+ const deduped = [...new Map(items.map((item) => [item.path, item])).values()].sort(
317
+ (a, b) => a.path.localeCompare(b.path)
318
+ );
319
+ return {
320
+ version: 1,
321
+ scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
322
+ items: deduped,
323
+ summary: summarize(deduped),
324
+ conflicts: detectConflicts(projectRoot, deduped),
325
+ staleRisks: detectStaleRisks(projectRoot, deduped)
244
326
  };
245
- await captureCliTelemetryEvent({
246
- name: "health.finding.prompt_requested",
247
- projectRoot,
248
- properties
249
- });
250
327
  }
251
- async function sendProjectHealthCiFailedTelemetry(input) {
252
- const projectRoot = input.projectRoot ?? process.cwd();
253
- const properties = {
254
- ...buildProjectHealthTelemetryProperties(input, projectRoot),
255
- errorCode: "project_health_ci_failed",
256
- failOn: input.failOn,
257
- success: false
258
- };
259
- await captureCliTelemetryEvent({
260
- name: "health.ci.failed",
261
- projectRoot,
262
- properties
263
- });
328
+
329
+ // src/analyzers/routes.ts
330
+ import { existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
331
+ import { join as join2, relative as relative2 } from "path";
332
+ var SKIP_DIRS2 = /* @__PURE__ */ new Set(["node_modules", ".next", ".git", "api", "_app", "_document"]);
333
+ function shouldSkipDir2(name) {
334
+ return name.startsWith("_") || name.startsWith(".") || SKIP_DIRS2.has(name);
264
335
  }
265
- async function sendStudioStartedTelemetry(input) {
266
- const projectRoot = input.projectRoot ?? process.cwd();
267
- const metadata = readProjectTelemetryMetadata(projectRoot);
268
- const properties = {
269
- success: true,
270
- hostMode: isLoopbackHost(input.host) ? "loopback" : "custom",
271
- port: input.port,
272
- adoptionMode: metadata.adoptionMode,
273
- projectScope: inferProjectScope(projectRoot),
274
- workflowMode: metadata.workflowMode
275
- };
276
- await captureCliTelemetryEvent({
277
- name: "studio.started",
278
- projectRoot,
279
- properties
280
- });
281
- }
282
- async function sendStudioHealthRefreshedTelemetry(input) {
283
- const projectRoot = input.projectRoot ?? process.cwd();
284
- const properties = {
285
- ...buildProjectHealthTelemetryProperties(input, projectRoot),
286
- trigger: input.trigger ?? "api-refresh"
287
- };
288
- await captureCliTelemetryEvent({
289
- name: "studio.health_refreshed",
290
- projectRoot,
291
- properties
292
- });
293
- }
294
- function buildCliLifecycleProperties(input) {
295
- const metadata = readProjectTelemetryMetadata(input.projectRoot);
296
- const registrySource = inferRegistrySource(input.args);
297
- return {
298
- command: input.command,
299
- success: input.success,
300
- durationMs: input.durationMs,
301
- adoptionMode: inferAdoptionMode(input.args) ?? metadata.adoptionMode,
302
- errorCode: input.success ? void 0 : "cli_command_failed",
303
- offline: input.args.includes("--offline"),
304
- projectScope: metadata.projectScope ?? inferProjectScope(input.projectRoot),
305
- registrySource,
306
- targetFramework: inferFlagValue(input.args, "--target"),
307
- workflowMode: inferWorkflowMode(input.args) ?? metadata.workflowMode
308
- };
309
- }
310
- function lifecycleTelemetryEventName(command) {
311
- if (command === "check") return "decantr.check.completed";
312
- if (command === "init") return "decantr.init.completed";
313
- if (command === "refresh") return "decantr.refresh.completed";
314
- return null;
315
- }
316
- function buildProjectHealthTelemetryProperties(input, projectRoot) {
317
- const { report } = input;
318
- return {
319
- success: true,
320
- status: report.status,
321
- score: report.score,
322
- durationMs: input.durationMs,
323
- adoptionMode: normalizeAdoptionMode(report.summary.adoptionMode),
324
- ci: input.ci ?? false,
325
- errorCount: report.summary.errorCount,
326
- failOn: input.failOn,
327
- findingCount: report.summary.findingCount,
328
- format: input.format,
329
- infoCount: report.summary.infoCount,
330
- outputWritten: input.outputWritten ?? false,
331
- packManifestPresent: report.summary.packManifestPresent,
332
- pageCount: report.summary.pageCount,
333
- projectScope: inferProjectScope(projectRoot),
334
- reviewPackPresent: report.summary.reviewPackPresent,
335
- routeCount: report.routes.declared.length,
336
- runtimeAuditChecked: report.summary.runtimeAuditChecked,
337
- runtimeMatchedCount: report.routes.runtimeMatched,
338
- runtimePassed: report.summary.runtimePassed,
339
- runtimeRouteCheckedCount: report.routes.runtimeChecked.length,
340
- warnCount: report.summary.warnCount,
341
- workflowMode: normalizeWorkflowMode(report.summary.workflowMode)
342
- };
343
- }
344
- function collectMetrics(essence, issues) {
345
- const dna = essence.dna ?? {};
346
- const blueprint = essence.blueprint ?? {};
347
- const meta = essence.meta ?? {};
348
- const guard = meta.guard ?? {};
349
- const theme = dna.theme ?? {};
350
- const sections = blueprint.sections ?? [];
351
- const routes = blueprint.routes ?? {};
352
- const byRule = {};
353
- let dnaCount = 0;
354
- let blueprintCount = 0;
355
- for (const issue of issues) {
356
- byRule[issue.rule] = (byRule[issue.rule] ?? 0) + 1;
357
- if (DNA_RULES.has(issue.rule)) {
358
- dnaCount++;
359
- } else {
360
- blueprintCount++;
336
+ function segmentToRoute(segment) {
337
+ if (segment.startsWith("(") && segment.endsWith(")")) {
338
+ return null;
339
+ }
340
+ if (segment.startsWith("[") && segment.endsWith("]")) {
341
+ const param = segment.slice(1, -1);
342
+ if (param.startsWith("...")) {
343
+ return `:${param.slice(3)}*`;
361
344
  }
345
+ if (param.startsWith("[...") && param.endsWith("]")) {
346
+ return `:${param.slice(4, -1)}*`;
347
+ }
348
+ return `:${param}`;
362
349
  }
363
- return {
364
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
365
- cli_version: getCliVersion(),
366
- essence_version: essence.version ?? "unknown",
367
- guard_mode: guard.mode ?? "unknown",
368
- violations: {
369
- dna: dnaCount,
370
- blueprint: blueprintCount,
371
- by_rule: byRule
372
- },
373
- resolution_rate: 0,
374
- sections_count: sections.length,
375
- routes_count: Object.keys(routes).length,
376
- theme: theme.id ?? "unknown"
377
- };
378
- }
379
- function getCliTelemetryIdentityStatus(projectRoot, options = {}) {
380
- const projectJsonPath = join2(projectRoot, ".decantr", "project.json");
381
- const hasProjectConfig = existsSync2(projectJsonPath);
382
- const identities = options.create ? ensureTelemetryIdentities(projectRoot) : null;
383
- const projectData = readProjectJson(projectRoot);
384
- return {
385
- enabled: projectData?.telemetry === true,
386
- hasProjectConfig,
387
- installId: identities?.installId ?? readExistingInstallId(),
388
- projectId: identities?.projectId ?? readStringProperty(projectData, "telemetryProjectId"),
389
- projectRoot
390
- };
350
+ return segment;
391
351
  }
392
- function ensureTelemetryIdentities(projectRoot) {
393
- const installId = getOrCreateInstallId();
394
- const projectJsonPath = join2(projectRoot, ".decantr", "project.json");
395
- if (!existsSync2(projectJsonPath)) {
396
- return null;
397
- }
352
+ function walkAppDir(dir, baseDir, segments) {
353
+ const routes = [];
354
+ let entries;
398
355
  try {
399
- const data = JSON.parse(readFileSync2(projectJsonPath, "utf-8"));
400
- let projectId = typeof data.telemetryProjectId === "string" ? data.telemetryProjectId : void 0;
401
- if (!projectId) {
402
- projectId = `project_${randomUUID()}`;
403
- data.telemetryProjectId = projectId;
404
- writeFileSync(projectJsonPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
405
- }
406
- return { installId, projectId };
356
+ entries = readdirSync2(dir);
407
357
  } catch {
408
- return null;
358
+ return routes;
359
+ }
360
+ const hasPage = entries.some(
361
+ (e) => e === "page.tsx" || e === "page.ts" || e === "page.jsx" || e === "page.js"
362
+ );
363
+ const hasLayout = entries.some(
364
+ (e) => e === "layout.tsx" || e === "layout.ts" || e === "layout.jsx" || e === "layout.js"
365
+ );
366
+ if (hasPage) {
367
+ const routePath = "/" + segments.filter((s) => s !== "").join("/");
368
+ const pageFile = entries.find((e) => e.startsWith("page."));
369
+ routes.push({
370
+ path: routePath || "/",
371
+ file: relative2(baseDir, join2(dir, pageFile)),
372
+ hasLayout
373
+ });
374
+ }
375
+ for (const entry of entries) {
376
+ if (shouldSkipDir2(entry)) continue;
377
+ const fullPath = join2(dir, entry);
378
+ try {
379
+ if (!statSync2(fullPath).isDirectory()) continue;
380
+ } catch {
381
+ continue;
382
+ }
383
+ const routeSegment = segmentToRoute(entry);
384
+ const nextSegments = routeSegment === null ? [...segments] : [...segments, routeSegment];
385
+ routes.push(...walkAppDir(fullPath, baseDir, nextSegments));
409
386
  }
387
+ return routes;
410
388
  }
411
- function getOrCreateInstallId() {
412
- const configDir = getConfigDir();
413
- const configPath = join2(configDir, "config.json");
389
+ function walkPagesDir(dir, baseDir, segments, extensions = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "md", "mdx"])) {
390
+ const routes = [];
391
+ let entries;
414
392
  try {
415
- if (existsSync2(configPath)) {
416
- const data = JSON.parse(readFileSync2(configPath, "utf-8"));
417
- if (typeof data.telemetryInstallId === "string") {
418
- return data.telemetryInstallId;
393
+ entries = readdirSync2(dir);
394
+ } catch {
395
+ return routes;
396
+ }
397
+ for (const entry of entries) {
398
+ if (shouldSkipDir2(entry)) continue;
399
+ const fullPath = join2(dir, entry);
400
+ try {
401
+ const stat = statSync2(fullPath);
402
+ if (stat.isDirectory()) {
403
+ const routeSegment = segmentToRoute(entry);
404
+ const nextSegments = routeSegment === null ? [...segments] : [...segments, routeSegment];
405
+ routes.push(...walkPagesDir(fullPath, baseDir, nextSegments, extensions));
406
+ } else if (stat.isFile()) {
407
+ const match = entry.match(/^(.+)\.([^.]+)$/);
408
+ if (!match) continue;
409
+ const name = match[1];
410
+ const extension = match[2];
411
+ if (!extensions.has(extension)) continue;
412
+ if (name.startsWith("_")) continue;
413
+ const routeSegment = name === "index" ? "" : segmentToRoute(name) ?? name;
414
+ const routePath = "/" + [...segments, routeSegment].filter((s) => s !== "").join("/");
415
+ routes.push({
416
+ path: routePath || "/",
417
+ file: relative2(baseDir, fullPath),
418
+ hasLayout: false
419
+ });
419
420
  }
420
- const installId2 = `install_${randomUUID()}`;
421
- data.telemetryInstallId = installId2;
422
- writeFileSync(configPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
423
- return installId2;
421
+ } catch {
424
422
  }
425
- mkdirSync(configDir, { recursive: true });
426
- const installId = `install_${randomUUID()}`;
427
- writeFileSync(
428
- configPath,
429
- JSON.stringify({ telemetryInstallId: installId }, null, 2) + "\n",
430
- "utf-8"
431
- );
432
- return installId;
433
- } catch {
434
- return `install_${randomUUID()}`;
435
423
  }
424
+ return routes;
436
425
  }
437
- function readExistingInstallId() {
438
- const configPath = join2(getConfigDir(), "config.json");
439
- if (!existsSync2(configPath)) return void 0;
426
+ function walkSvelteKitRoutes(dir, baseDir, segments) {
427
+ const routes = [];
428
+ let entries;
440
429
  try {
441
- const data = JSON.parse(readFileSync2(configPath, "utf-8"));
442
- return readStringProperty(data, "telemetryInstallId");
430
+ entries = readdirSync2(dir);
443
431
  } catch {
444
- return void 0;
432
+ return routes;
433
+ }
434
+ const pageFile = entries.find((entry) => /^\+page\.(svelte|ts|js)$/.test(entry));
435
+ const hasLayout = entries.some((entry) => /^\+layout\.(svelte|ts|js)$/.test(entry));
436
+ if (pageFile) {
437
+ const routePath = "/" + segments.filter((segment) => segment !== "").join("/");
438
+ routes.push({
439
+ path: routePath || "/",
440
+ file: relative2(baseDir, join2(dir, pageFile)),
441
+ hasLayout
442
+ });
443
+ }
444
+ for (const entry of entries) {
445
+ if (shouldSkipDir2(entry)) continue;
446
+ const fullPath = join2(dir, entry);
447
+ try {
448
+ if (!statSync2(fullPath).isDirectory()) continue;
449
+ } catch {
450
+ continue;
451
+ }
452
+ const routeSegment = segmentToRoute(entry);
453
+ const nextSegments = routeSegment === null ? [...segments] : [...segments, routeSegment];
454
+ routes.push(...walkSvelteKitRoutes(fullPath, baseDir, nextSegments));
445
455
  }
456
+ return routes;
446
457
  }
447
- function getConfigDir() {
448
- return process.env.DECANTR_CONFIG_DIR || join2(homedir(), ".config", "decantr");
458
+ var ROUTER_FILE_EXTENSIONS = /* @__PURE__ */ new Set([".tsx", ".ts", ".jsx", ".js"]);
459
+ function collectRouteCandidateFiles(dir, files, depth = 0) {
460
+ if (depth > 5) return;
461
+ let entries;
462
+ try {
463
+ entries = readdirSync2(dir);
464
+ } catch {
465
+ return;
466
+ }
467
+ for (const entry of entries) {
468
+ if (entry.startsWith(".") || entry === "node_modules") continue;
469
+ const fullPath = join2(dir, entry);
470
+ try {
471
+ const stat = statSync2(fullPath);
472
+ if (stat.isDirectory()) {
473
+ collectRouteCandidateFiles(fullPath, files, depth + 1);
474
+ } else if (stat.isFile()) {
475
+ const ext = entry.slice(entry.lastIndexOf("."));
476
+ if (ROUTER_FILE_EXTENSIONS.has(ext)) {
477
+ files.push(fullPath);
478
+ }
479
+ }
480
+ } catch {
481
+ }
482
+ }
449
483
  }
450
- function getTelemetryEventsEndpoint() {
451
- return process.env.DECANTR_TELEMETRY_ENDPOINT || DEFAULT_TELEMETRY_EVENTS_ENDPOINT;
452
- }
453
- function getTelemetryActorType() {
454
- const configured = process.env.DECANTR_TELEMETRY_ACTOR_TYPE;
455
- return isTelemetryActorType(configured) ? configured : "customer";
456
- }
457
- function getRegistrySourceProperty(properties) {
458
- const value = properties.registrySource;
459
- return isRegistrySource(value) ? value : void 0;
460
- }
461
- function readProjectTelemetryMetadata(projectRoot) {
462
- const data = readProjectJson(projectRoot);
463
- const initialized = isRecord(data?.initialized) ? data.initialized : void 0;
464
- return {
465
- adoptionMode: normalizeAdoptionMode(initialized?.adoptionMode),
466
- projectScope: normalizeProjectScope(initialized?.projectScope),
467
- workflowMode: normalizeWorkflowMode(initialized?.workflowMode)
468
- };
484
+ function scanReactRouter(projectRoot) {
485
+ const candidateDirs = [join2(projectRoot, "src"), projectRoot];
486
+ const candidateFiles = [];
487
+ for (const dir of candidateDirs) {
488
+ if (existsSync2(dir)) collectRouteCandidateFiles(dir, candidateFiles);
489
+ }
490
+ const routeMap = /* @__PURE__ */ new Map();
491
+ for (const absolutePath of candidateFiles) {
492
+ let content;
493
+ try {
494
+ content = readFileSync2(absolutePath, "utf-8");
495
+ } catch {
496
+ continue;
497
+ }
498
+ const isReactRouterFile = content.includes("react-router-dom") || content.includes("react-router") || content.includes("<Routes") || content.includes("createBrowserRouter") || content.includes("createHashRouter") || content.includes("RouterProvider") || content.includes("HashRouter") || content.includes("BrowserRouter");
499
+ if (!isReactRouterFile) continue;
500
+ const relativePath = relative2(projectRoot, absolutePath);
501
+ const pathMatches = /* @__PURE__ */ new Set();
502
+ for (const match of content.matchAll(/<Route\b[^>]*\bpath=["'`]([^"'`]+)["'`]/g)) {
503
+ pathMatches.add(match[1]);
504
+ }
505
+ for (const match of content.matchAll(/\bpath\s*:\s*["'`]([^"'`]+)["'`]/g)) {
506
+ pathMatches.add(match[1]);
507
+ }
508
+ if (pathMatches.size === 0 && (content.includes("<Routes") || content.includes("RouterProvider"))) {
509
+ pathMatches.add("/");
510
+ }
511
+ for (const path of pathMatches) {
512
+ if (!routeMap.has(path)) {
513
+ routeMap.set(path, {
514
+ path,
515
+ file: relativePath,
516
+ hasLayout: false
517
+ });
518
+ }
519
+ }
520
+ }
521
+ return [...routeMap.values()];
469
522
  }
470
- function resolveCliTelemetryProjectRoot(projectRoot, args) {
471
- const projectFlag = inferFlagValue(args, "--project");
472
- if (!projectFlag) return projectRoot;
473
- const candidate = resolve(projectRoot, projectFlag);
474
- return existsSync2(join2(candidate, ".decantr", "project.json")) ? candidate : projectRoot;
523
+ function hasReactRouterDependency(projectRoot) {
524
+ return hasDependency(projectRoot, ["react-router", "react-router-dom"]);
475
525
  }
476
- function readProjectJson(projectRoot) {
477
- const projectJsonPath = join2(projectRoot, ".decantr", "project.json");
478
- if (!existsSync2(projectJsonPath)) return null;
526
+ function hasDependency(projectRoot, names) {
527
+ const packageJsonPath = join2(projectRoot, "package.json");
528
+ if (!existsSync2(packageJsonPath)) return false;
479
529
  try {
480
- return JSON.parse(readFileSync2(projectJsonPath, "utf-8"));
530
+ const pkg = JSON.parse(readFileSync2(packageJsonPath, "utf-8"));
531
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
532
+ return names.some((name) => Boolean(deps[name]));
481
533
  } catch {
482
- return null;
534
+ return false;
483
535
  }
484
536
  }
485
- function readStringProperty(value, key) {
486
- const property = value?.[key];
487
- return typeof property === "string" && property.trim() ? property : void 0;
488
- }
489
- function isRecord(value) {
490
- return typeof value === "object" && value !== null && !Array.isArray(value);
491
- }
492
- function normalizeCommand(command) {
493
- if (!command) return null;
494
- if (command === "--help" || command === "-h") return "help";
495
- if (command === "--version" || command === "-v") return "version";
496
- return command;
537
+ function hasAnyFile(projectRoot, relPaths) {
538
+ return relPaths.some((relPath) => existsSync2(join2(projectRoot, relPath)));
497
539
  }
498
- function isHelpOrVersionProbe(args) {
499
- if (args.some((arg) => arg === "--help" || arg === "-h")) return true;
500
- if (args[1] === "help") return true;
501
- return false;
540
+ function normalizeRoutePath(path) {
541
+ const cleaned = path.trim();
542
+ if (!cleaned || cleaned === "/") return "/";
543
+ if (cleaned === "**" || cleaned.startsWith("#")) return null;
544
+ return cleaned.startsWith("/") ? cleaned : `/${cleaned}`;
502
545
  }
503
- function inferFlagValue(args, flag) {
504
- const equalsPrefix = `${flag}=`;
505
- const inline = args.find((arg) => arg.startsWith(equalsPrefix));
506
- if (inline) {
507
- return inline.slice(equalsPrefix.length) || void 0;
546
+ function scanAngularRouter(projectRoot) {
547
+ const candidateDirs = [join2(projectRoot, "src", "app"), join2(projectRoot, "src")];
548
+ const candidateFiles = [];
549
+ for (const dir of candidateDirs) {
550
+ if (existsSync2(dir)) collectRouteCandidateFiles(dir, candidateFiles);
508
551
  }
509
- const index = args.indexOf(flag);
510
- if (index !== -1 && args[index + 1] && !args[index + 1].startsWith("-")) {
511
- return args[index + 1];
552
+ const routeMap = /* @__PURE__ */ new Map();
553
+ for (const absolutePath of candidateFiles) {
554
+ let content;
555
+ try {
556
+ content = readFileSync2(absolutePath, "utf-8");
557
+ } catch {
558
+ continue;
559
+ }
560
+ const isRouterFile = content.includes("@angular/router") || content.includes("RouterModule.forRoot") || content.includes("provideRouter") || content.includes("Routes =");
561
+ if (!isRouterFile) continue;
562
+ const relativePath = relative2(projectRoot, absolutePath);
563
+ for (const match of content.matchAll(/\bpath\s*:\s*["'`]([^"'`]*)["'`]/g)) {
564
+ const routePath = normalizeRoutePath(match[1]);
565
+ if (!routePath || routeMap.has(routePath)) continue;
566
+ routeMap.set(routePath, {
567
+ path: routePath,
568
+ file: relativePath,
569
+ hasLayout: false
570
+ });
571
+ }
512
572
  }
513
- return void 0;
514
- }
515
- function inferAdoptionMode(args) {
516
- const value = inferFlagValue(args, "--adoption");
517
- return normalizeAdoptionMode(value);
518
- }
519
- function inferWorkflowMode(args) {
520
- const value = inferFlagValue(args, "--workflow");
521
- return normalizeWorkflowMode(value);
573
+ return [...routeMap.values()];
522
574
  }
523
- function normalizeAdoptionMode(value) {
524
- if (value === "contract-only" || value === "decantr-css" || value === "style-bridge") {
525
- return value;
575
+ function scanVueRouter(projectRoot) {
576
+ const candidateDirs = [join2(projectRoot, "src"), projectRoot];
577
+ const candidateFiles = [];
578
+ for (const dir of candidateDirs) {
579
+ if (existsSync2(dir)) collectRouteCandidateFiles(dir, candidateFiles);
526
580
  }
527
- return void 0;
581
+ const routeMap = /* @__PURE__ */ new Map();
582
+ for (const absolutePath of candidateFiles) {
583
+ let content;
584
+ try {
585
+ content = readFileSync2(absolutePath, "utf-8");
586
+ } catch {
587
+ continue;
588
+ }
589
+ const isRouterFile = content.includes("vue-router") || content.includes("createRouter") || content.includes("createWebHistory") || content.includes("createWebHashHistory");
590
+ if (!isRouterFile) continue;
591
+ const relativePath = relative2(projectRoot, absolutePath);
592
+ for (const match of content.matchAll(/\bpath\s*:\s*["'`]([^"'`]+)["'`]/g)) {
593
+ const routePath = normalizeRoutePath(match[1]);
594
+ if (!routePath || routeMap.has(routePath)) continue;
595
+ routeMap.set(routePath, {
596
+ path: routePath,
597
+ file: relativePath,
598
+ hasLayout: false
599
+ });
600
+ }
601
+ }
602
+ return [...routeMap.values()];
528
603
  }
529
- function normalizeWorkflowMode(value) {
530
- if (value === "greenfield" || value === "greenfield-scaffold") {
531
- return "greenfield-scaffold";
604
+ function scanRoutes(projectRoot) {
605
+ const hasNext = hasDependency(projectRoot, ["next"]) || hasAnyFile(projectRoot, ["next.config.js", "next.config.ts", "next.config.mjs"]);
606
+ const hasSvelteKit = hasDependency(projectRoot, ["@sveltejs/kit", "svelte"]) || hasAnyFile(projectRoot, ["svelte.config.js", "svelte.config.ts"]);
607
+ const hasNuxt = hasDependency(projectRoot, ["nuxt"]) || hasAnyFile(projectRoot, ["nuxt.config.js", "nuxt.config.ts"]);
608
+ const hasAngular = hasDependency(projectRoot, ["@angular/core", "@angular/router"]) || hasAnyFile(projectRoot, ["angular.json"]);
609
+ const hasVue = hasDependency(projectRoot, ["vue", "vue-router"]) || hasAnyFile(projectRoot, ["vite.config.js", "vite.config.ts"]);
610
+ const appDirs = [join2(projectRoot, "src", "app"), join2(projectRoot, "app")];
611
+ const appRoutes = appDirs.flatMap(
612
+ (appDir) => existsSync2(appDir) ? walkAppDir(appDir, projectRoot, []) : []
613
+ );
614
+ const pagesDirs = [join2(projectRoot, "src", "pages"), join2(projectRoot, "pages")];
615
+ const pagesRoutes = pagesDirs.flatMap(
616
+ (pagesDir) => existsSync2(pagesDir) ? walkPagesDir(pagesDir, projectRoot, []) : []
617
+ );
618
+ if (hasNext) {
619
+ if (appRoutes.length > 0 && pagesRoutes.length > 0) {
620
+ return { strategy: "mixed-next-router", routes: [...appRoutes, ...pagesRoutes] };
621
+ }
622
+ if (appRoutes.length > 0) return { strategy: "app-router", routes: appRoutes };
623
+ if (pagesRoutes.length > 0) return { strategy: "pages-router", routes: pagesRoutes };
624
+ } else if (appRoutes.length > 0) {
625
+ return { strategy: "app-router", routes: appRoutes };
532
626
  }
533
- if (value === "contract" || value === "greenfield-contract-only") {
534
- return "greenfield-contract-only";
627
+ if (hasSvelteKit) {
628
+ const svelteRoutesDir = join2(projectRoot, "src", "routes");
629
+ if (existsSync2(svelteRoutesDir)) {
630
+ const routes = walkSvelteKitRoutes(svelteRoutesDir, projectRoot, []);
631
+ if (routes.length > 0) return { strategy: "sveltekit-router", routes };
632
+ }
535
633
  }
536
- if (value === "brownfield" || value === "brownfield-attach") {
537
- return "brownfield-attach";
634
+ if (hasNuxt) {
635
+ const nuxtPagesDirs = [join2(projectRoot, "pages"), join2(projectRoot, "app", "pages")];
636
+ const routes = nuxtPagesDirs.flatMap(
637
+ (pagesDir) => existsSync2(pagesDir) ? walkPagesDir(pagesDir, projectRoot, [], /* @__PURE__ */ new Set(["vue"])) : []
638
+ );
639
+ if (routes.length > 0) return { strategy: "nuxt-router", routes };
538
640
  }
539
- if (value === "hybrid" || value === "hybrid-compose") {
540
- return "hybrid-compose";
641
+ const reactRouterRoutes = scanReactRouter(projectRoot);
642
+ if (reactRouterRoutes.length > 0 && hasReactRouterDependency(projectRoot)) {
643
+ return { strategy: "react-router", routes: reactRouterRoutes };
541
644
  }
542
- return void 0;
543
- }
544
- function normalizeProjectScope(value) {
545
- if (value === "single-app" || value === "workspace-app") return value;
546
- return void 0;
547
- }
548
- function normalizeFindingSeverity(value) {
549
- if (value === "error" || value === "info" || value === "warn") return value;
550
- return void 0;
551
- }
552
- function normalizeFindingSource(value) {
553
- if (value === "audit" || value === "brownfield" || value === "check" || value === "interaction" || value === "pack" || value === "runtime") {
554
- return value;
645
+ if (hasAngular) {
646
+ const routes = scanAngularRouter(projectRoot);
647
+ if (routes.length > 0) return { strategy: "angular-router", routes };
555
648
  }
556
- return void 0;
557
- }
558
- function inferRegistrySource(args) {
559
- if (args.includes("--offline")) {
560
- return "cache";
649
+ if (hasVue) {
650
+ const routes = scanVueRouter(projectRoot);
651
+ if (routes.length > 0) return { strategy: "vue-router", routes };
561
652
  }
562
- if (args.some((arg) => arg === "--registry" || arg.startsWith("--registry="))) {
563
- return "custom";
653
+ if (pagesRoutes.length > 0) {
654
+ return { strategy: "pages-router", routes: pagesRoutes };
564
655
  }
565
- return "official";
566
- }
567
- function isRegistrySource(value) {
568
- return value === "cache" || value === "custom" || value === "none" || value === "official" || value === "private";
569
- }
570
- function inferProjectScope(projectRoot) {
571
- return existsSync2(join2(projectRoot, "pnpm-workspace.yaml")) || existsSync2(join2(projectRoot, "turbo.json")) || existsSync2(join2(projectRoot, "lerna.json")) ? "workspace-app" : "single-app";
572
- }
573
- function getCliVersion() {
574
- try {
575
- const here = dirname2(fileURLToPath2(import.meta.url));
576
- const candidates = [join2(here, "..", "package.json"), join2(here, "..", "..", "package.json")];
577
- for (const candidate of candidates) {
578
- if (existsSync2(candidate)) {
579
- const pkg = JSON.parse(readFileSync2(candidate, "utf-8"));
580
- if (pkg.version) {
581
- return pkg.version;
582
- }
583
- }
584
- }
585
- } catch {
656
+ if (reactRouterRoutes.length > 0) {
657
+ return { strategy: "react-router", routes: reactRouterRoutes };
586
658
  }
587
- return "unknown";
588
- }
589
- function isLoopbackHost(host) {
590
- return host === "127.0.0.1" || host === "localhost" || host === "::1";
659
+ return { strategy: "none", routes: [] };
591
660
  }
592
661
 
593
- // src/guard-context.ts
594
- import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
662
+ // src/analyzers/styling.ts
663
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
595
664
  import { join as join3 } from "path";
596
- function loadJsonEntries(dir) {
597
- if (!existsSync3(dir)) return [];
598
- try {
599
- return readdirSync2(dir).filter((file) => file.endsWith(".json") && file !== "index.json").map((file) => JSON.parse(readFileSync3(join3(dir, file), "utf-8")));
600
- } catch {
601
- return [];
602
- }
603
- }
604
- function buildGuardRegistryContext(projectRoot = process.cwd()) {
605
- const themeRegistry = /* @__PURE__ */ new Map();
606
- const patternRegistry = /* @__PURE__ */ new Map();
607
- const cacheDir = join3(projectRoot, ".decantr", "cache");
608
- const customDir = join3(projectRoot, ".decantr", "custom");
609
- for (const data of loadJsonEntries(join3(cacheDir, "@official", "themes"))) {
610
- if (typeof data.id === "string" && !themeRegistry.has(data.id)) {
611
- themeRegistry.set(data.id, {
612
- modes: Array.isArray(data.modes) ? data.modes.filter((mode) => typeof mode === "string") : ["light", "dark"]
613
- });
665
+ var TAILWIND_CONFIGS = [
666
+ "tailwind.config.js",
667
+ "tailwind.config.ts",
668
+ "tailwind.config.mjs",
669
+ "tailwind.config.cjs"
670
+ ];
671
+ var GLOBALS_CSS_PATHS = [
672
+ "src/app/globals.css",
673
+ "app/globals.css",
674
+ "src/styles/global.css",
675
+ "src/styles/globals.css",
676
+ "src/styles/main.css",
677
+ "src/styles.css",
678
+ "styles/globals.css",
679
+ "styles.css",
680
+ "assets/css/main.css",
681
+ "src/index.css",
682
+ "src/app.css",
683
+ "src/global.css"
684
+ ];
685
+ var DECANTR_STYLE_PATHS = [
686
+ "src/styles/tokens.css",
687
+ "src/styles/treatments.css",
688
+ "src/styles/global.css"
689
+ ];
690
+ function extractCSSVariables(content) {
691
+ const colors = {};
692
+ const variables = [];
693
+ const varRegex = /--([\w-]+)\s*:\s*([^;]+)/g;
694
+ let match;
695
+ while ((match = varRegex.exec(content)) !== null) {
696
+ const name = match[1];
697
+ const value = match[2].trim();
698
+ variables.push(`--${name}`);
699
+ const colorPatterns = [
700
+ "primary",
701
+ "secondary",
702
+ "accent",
703
+ "bg",
704
+ "fg",
705
+ "border",
706
+ "success",
707
+ "warning",
708
+ "error",
709
+ "surface",
710
+ "muted"
711
+ ];
712
+ if (value.startsWith("#") || value.startsWith("rgb") || value.startsWith("hsl") || colorPatterns.some((p) => name.includes(p))) {
713
+ colors[name] = value;
614
714
  }
615
715
  }
616
- for (const data of loadJsonEntries(join3(customDir, "themes"))) {
617
- if (typeof data.id === "string") {
618
- themeRegistry.set(`custom:${data.id}`, {
619
- modes: Array.isArray(data.modes) ? data.modes.filter((mode) => typeof mode === "string") : ["light", "dark"]
620
- });
716
+ return { colors, variables };
717
+ }
718
+ function detectDarkMode(projectRoot, cssContents) {
719
+ for (const cssContent of cssContents) {
720
+ if (cssContent.includes(".dark") || cssContent.includes('[data-theme="dark"]') || cssContent.includes("prefers-color-scheme: dark") || cssContent.includes("color-scheme: dark")) {
721
+ return true;
621
722
  }
622
723
  }
623
- for (const data of loadJsonEntries(join3(cacheDir, "@official", "patterns"))) {
624
- if (typeof data.id === "string" && !patternRegistry.has(data.id)) {
625
- patternRegistry.set(data.id, data);
724
+ const layoutPaths = [
725
+ "src/app/layout.tsx",
726
+ "app/layout.tsx",
727
+ "src/app/layout.jsx",
728
+ "app/layout.jsx"
729
+ ];
730
+ for (const rel of layoutPaths) {
731
+ const fullPath = join3(projectRoot, rel);
732
+ if (existsSync3(fullPath)) {
733
+ try {
734
+ const layoutContent = readFileSync3(fullPath, "utf-8");
735
+ if (layoutContent.includes('className="dark"') || layoutContent.includes("className='dark'") || layoutContent.includes('class="dark"')) {
736
+ return true;
737
+ }
738
+ } catch {
739
+ }
626
740
  }
627
741
  }
628
- for (const entry of loadBundledContentList("patterns")) {
629
- const data = entry.data;
630
- const id = typeof data.id === "string" ? data.id : entry.id;
631
- if (!patternRegistry.has(id)) {
632
- patternRegistry.set(id, data);
742
+ const pkgPath = join3(projectRoot, "package.json");
743
+ if (existsSync3(pkgPath)) {
744
+ try {
745
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf-8"));
746
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
747
+ if (allDeps["next-themes"] || allDeps["theme-toggle"] || allDeps["use-dark-mode"]) {
748
+ return true;
749
+ }
750
+ } catch {
633
751
  }
634
752
  }
635
- for (const data of loadJsonEntries(join3(customDir, "patterns"))) {
636
- if (typeof data.id === "string") {
637
- patternRegistry.set(data.id, data);
753
+ const essencePath = join3(projectRoot, "decantr.essence.json");
754
+ if (existsSync3(essencePath)) {
755
+ try {
756
+ const essence = JSON.parse(readFileSync3(essencePath, "utf-8"));
757
+ const mode = essence.dna?.theme?.mode;
758
+ if (mode === "dark" || mode === "auto") {
759
+ return true;
760
+ }
761
+ } catch {
638
762
  }
639
763
  }
640
- return { themeRegistry, patternRegistry };
764
+ return false;
641
765
  }
642
-
643
- // src/lib/scan-interactions.ts
644
- import { existsSync as existsSync4, readdirSync as readdirSync3, readFileSync as readFileSync4, statSync } from "fs";
645
- import { extname, join as join4, relative } from "path";
646
- import { verifyInteractionsInSource } from "@decantr/verifier";
647
- var SCAN_EXTENSIONS = /* @__PURE__ */ new Set([".tsx", ".jsx", ".ts", ".js", ".html", ".mdx"]);
648
- var SKIP_DIRECTORIES = /* @__PURE__ */ new Set([
649
- "node_modules",
650
- ".decantr",
651
- ".git",
652
- "dist",
653
- "build",
654
- ".next",
655
- ".turbo",
656
- "coverage",
657
- ".cache"
658
- ]);
659
- var MAX_FILE_SIZE = 1024 * 1024;
660
- function walkSourceTree(rootDir) {
661
- const sources = /* @__PURE__ */ new Map();
662
- function walk2(dir) {
663
- let entries;
766
+ function scanStyling(projectRoot) {
767
+ let approach = "unknown";
768
+ let configFile;
769
+ let packageDeps = {};
770
+ const pkgPath = join3(projectRoot, "package.json");
771
+ if (existsSync3(pkgPath)) {
664
772
  try {
665
- entries = readdirSync3(dir);
773
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf-8"));
774
+ packageDeps = { ...pkg.dependencies, ...pkg.devDependencies };
666
775
  } catch {
667
- return;
776
+ packageDeps = {};
668
777
  }
669
- for (const entry of entries) {
670
- if (SKIP_DIRECTORIES.has(entry)) continue;
671
- const fullPath = join4(dir, entry);
672
- let s;
673
- try {
674
- s = statSync(fullPath);
675
- } catch {
676
- continue;
677
- }
678
- if (s.isDirectory()) {
679
- walk2(fullPath);
680
- } else if (s.isFile() && SCAN_EXTENSIONS.has(extname(entry))) {
681
- if (s.size > MAX_FILE_SIZE) continue;
682
- try {
683
- sources.set(relative(rootDir, fullPath) || entry, readFileSync4(fullPath, "utf8"));
684
- } catch {
685
- }
686
- }
778
+ }
779
+ for (const cfg of TAILWIND_CONFIGS) {
780
+ if (existsSync3(join3(projectRoot, cfg))) {
781
+ approach = "tailwind";
782
+ configFile = cfg;
783
+ break;
687
784
  }
688
785
  }
689
- walk2(rootDir);
690
- return sources;
691
- }
692
- function collectDeclaredInteractions(projectRoot) {
693
- const manifestPath = join4(projectRoot, ".decantr", "context", "pack-manifest.json");
694
- if (!existsSync4(manifestPath)) return [];
695
- let manifest;
696
- try {
697
- manifest = JSON.parse(readFileSync4(manifestPath, "utf8"));
698
- } catch {
699
- return [];
786
+ if (approach === "unknown") {
787
+ if (packageDeps["@decantr/css"]) {
788
+ approach = "decantr-css";
789
+ configFile = "src/styles/tokens.css";
790
+ }
791
+ if (packageDeps.tailwindcss || packageDeps["@tailwindcss/postcss"] || packageDeps["@tailwindcss/vite"]) {
792
+ approach = "tailwind";
793
+ configFile = configFile ?? "package.json";
794
+ }
700
795
  }
701
- const all = [];
702
- const pages = manifest.pages ?? [];
703
- const contextDir = join4(projectRoot, ".decantr", "context");
704
- for (const page of pages) {
705
- const packPath = join4(contextDir, page.json);
706
- if (!existsSync4(packPath)) continue;
707
- let pack;
708
- try {
709
- pack = JSON.parse(readFileSync4(packPath, "utf8"));
710
- } catch {
711
- continue;
796
+ if (approach === "unknown") {
797
+ if (packageDeps.bootstrap || packageDeps["react-bootstrap"]) {
798
+ approach = "bootstrap";
799
+ configFile = "package.json";
800
+ } else if (packageDeps["@mui/material"] || packageDeps["@mui/system"] || packageDeps["@mui/joy"]) {
801
+ approach = "mui";
802
+ configFile = "package.json";
803
+ } else if (packageDeps["@chakra-ui/react"] || packageDeps["@chakra-ui/vue-next"] || packageDeps["@chakra-ui/system"]) {
804
+ approach = "chakra";
805
+ configFile = "package.json";
712
806
  }
713
- const patterns = pack.data?.patterns ?? [];
714
- for (const pat of patterns) {
715
- if (Array.isArray(pat.interactions)) {
716
- all.push(...pat.interactions);
807
+ }
808
+ const decantrStyleFiles = DECANTR_STYLE_PATHS.filter((rel) => existsSync3(join3(projectRoot, rel)));
809
+ if (decantrStyleFiles.length >= 2) {
810
+ approach = "decantr-css";
811
+ configFile = decantrStyleFiles.join(" + ");
812
+ }
813
+ const cssContents = [];
814
+ for (const rel of GLOBALS_CSS_PATHS) {
815
+ const fullPath = join3(projectRoot, rel);
816
+ if (existsSync3(fullPath)) {
817
+ try {
818
+ cssContents.push(readFileSync3(fullPath, "utf-8"));
819
+ } catch {
717
820
  }
718
821
  }
719
822
  }
720
- return all;
721
- }
722
- function scanProjectInteractions(projectRoot) {
723
- const declared = collectDeclaredInteractions(projectRoot);
724
- if (declared.length === 0) return [];
725
- const sources = walkSourceTree(projectRoot);
726
- if (sources.size === 0) return [];
727
- const missing = verifyInteractionsInSource(declared, sources);
728
- return missing.map(
729
- ({ interaction, suggestion, scannedFiles, scannedLocations, expectedSignals }) => {
730
- const evidence = [
731
- scannedFiles ? `scanned ${scannedFiles} source file(s)` : null,
732
- scannedLocations?.length ? `checked: ${scannedLocations.slice(0, 4).map((location) => `${location.file}:${location.startLine}-${location.endLine}`).join(", ")}` : null,
733
- expectedSignals?.length ? `expected signals: ${expectedSignals.slice(0, 4).join(", ")}` : null
734
- ].filter((entry) => Boolean(entry)).join("; ");
735
- return `${interaction} \u2192 ${suggestion}${evidence ? ` (${evidence})` : ""}`;
823
+ for (const rel of DECANTR_STYLE_PATHS) {
824
+ if (GLOBALS_CSS_PATHS.includes(rel)) continue;
825
+ const fullPath = join3(projectRoot, rel);
826
+ if (existsSync3(fullPath)) {
827
+ try {
828
+ cssContents.push(readFileSync3(fullPath, "utf-8"));
829
+ } catch {
830
+ }
736
831
  }
737
- );
832
+ }
833
+ let colors = {};
834
+ let cssVariables = [];
835
+ for (const cssContent of cssContents) {
836
+ const extracted = extractCSSVariables(cssContent);
837
+ colors = { ...colors, ...extracted.colors };
838
+ cssVariables.push(...extracted.variables);
839
+ }
840
+ cssVariables = [...new Set(cssVariables)];
841
+ const darkMode = detectDarkMode(projectRoot, cssContents);
842
+ if (approach === "unknown" && cssContents.length > 0) {
843
+ approach = "css";
844
+ configFile = GLOBALS_CSS_PATHS.find((rel) => existsSync3(join3(projectRoot, rel)));
845
+ }
846
+ return {
847
+ approach,
848
+ configFile,
849
+ colors,
850
+ darkMode,
851
+ cssVariables
852
+ };
738
853
  }
739
854
 
740
- // src/ambient-context.ts
741
- import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as readFileSync5, statSync as statSync2 } from "fs";
742
- import { basename, extname as extname2, join as join5, relative as relative2, sep } from "path";
743
- var SKIP_DIRS = /* @__PURE__ */ new Set([
744
- ".decantr",
745
- ".git",
746
- ".next",
747
- ".nuxt",
748
- ".svelte-kit",
749
- ".turbo",
750
- ".vercel",
751
- "build",
752
- "coverage",
753
- "dist",
754
- "node_modules",
755
- "playwright-report"
756
- ]);
757
- var ROOT_CONTEXT_FILES = /* @__PURE__ */ new Set([
758
- "AGENTS.md",
759
- "CLAUDE.md",
760
- "GEMINI.md",
761
- "README.md",
762
- "copilot-instructions.md",
763
- ".cursorrules",
764
- ".windsurfrules",
765
- ".cursorignore",
766
- ".claudeignore",
767
- "components.json",
768
- "tailwind.config.js",
769
- "tailwind.config.ts",
770
- "tailwind.config.mjs",
771
- "tailwind.config.cjs",
772
- "next.config.js",
773
- "next.config.ts",
774
- "next.config.mjs",
775
- "nuxt.config.js",
776
- "nuxt.config.ts",
777
- "astro.config.mjs",
778
- "astro.config.ts",
779
- "svelte.config.js",
780
- "svelte.config.ts",
781
- "angular.json",
782
- "vite.config.js",
783
- "vite.config.ts",
784
- "vitest.config.ts",
785
- "vitest.config.js",
786
- "playwright.config.ts",
787
- "playwright.config.js",
788
- "tsconfig.json",
789
- "package.json",
790
- "decantr.essence.json"
791
- ]);
792
- var CONTEXT_DIRECTORIES = /* @__PURE__ */ new Set([
793
- ".agents",
794
- ".claude",
795
- ".claude/initiatives",
796
- ".claude/rules",
797
- ".codex",
798
- ".cursor",
799
- ".cursor/rules",
800
- ".github/workflows",
801
- "docs",
802
- "docs/initiatives",
803
- "initiatives",
804
- "memory",
805
- "memories",
806
- "project-memory",
807
- "supabase"
808
- ]);
809
- function shouldSkipDir(name) {
810
- return SKIP_DIRS.has(name);
811
- }
812
- function normalizedPath(relPath) {
813
- return relPath.split(sep).join("/");
855
+ // src/doctrine-map.ts
856
+ import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync } from "fs";
857
+ import { join as join4 } from "path";
858
+ var PRECEDENCE_ORDER = [
859
+ "security-data",
860
+ "architecture",
861
+ "design-system",
862
+ "workflow-ci",
863
+ "feature-business",
864
+ "assistant-specific",
865
+ "stale-or-historical",
866
+ "unknown"
867
+ ];
868
+ var BASE_PRECEDENCE = {
869
+ "security-data": 100,
870
+ architecture: 88,
871
+ "design-system": 82,
872
+ "workflow-ci": 74,
873
+ "feature-business": 66,
874
+ "assistant-specific": 58,
875
+ "stale-or-historical": 24,
876
+ unknown: 12
877
+ };
878
+ function normalized(path) {
879
+ return path.toLowerCase();
814
880
  }
815
- function isPotentialContextFile(relPath, name) {
816
- const normalized2 = normalizedPath(relPath);
817
- if (ROOT_CONTEXT_FILES.has(name)) return true;
818
- if (name.startsWith(".env")) return true;
819
- if (normalized2.startsWith(".claude/")) return true;
820
- if (normalized2.startsWith(".agents/")) return true;
821
- if (normalized2.startsWith(".codex/")) return true;
822
- if (normalized2.startsWith(".cursor/")) return true;
823
- if (normalized2.startsWith(".github/workflows/")) return true;
824
- if (normalized2.startsWith("docs/")) return true;
825
- if (normalized2.startsWith("initiatives/")) return true;
826
- if (normalized2.startsWith("memory/")) return true;
827
- if (normalized2.startsWith("memories/")) return true;
828
- if (normalized2.startsWith("project-memory/")) return true;
829
- if (normalized2.startsWith("supabase/")) return true;
830
- if (normalized2.startsWith("migrations/")) return true;
831
- if (normalized2.startsWith("db/")) return true;
832
- if (normalized2.startsWith("ROLEMIGRATIONS/")) return true;
833
- if (normalized2 === "src/middleware.ts" || normalized2 === "middleware.ts") return true;
834
- if (normalized2.includes("/middleware.")) return true;
835
- const ext = extname2(name).toLowerCase();
836
- return ext === ".md" || ext === ".mdx" || ext === ".sql" || ext === ".yml" || ext === ".yaml";
881
+ function isStalePath(path, staleRisks) {
882
+ const lower = normalized(path);
883
+ if (/complete|summary|legacy|deprecated/.test(lower)) return true;
884
+ return staleRisks.some((risk) => risk.toLowerCase().startsWith(lower));
837
885
  }
838
- function classifyContext(relPath) {
839
- const normalized2 = normalizedPath(relPath);
840
- const lower = normalized2.toLowerCase();
841
- const name = basename(normalized2);
842
- if (lower === "decantr.essence.json") {
843
- return {
844
- role: "architecture",
845
- confidence: 0.82,
846
- reason: "existing Decantr contract evidence"
847
- };
848
- }
849
- if (lower === ".claude/initiatives" || lower === "docs/initiatives" || lower === "initiatives" || lower === "memory" || lower === "memories" || lower === "project-memory" || lower.startsWith(".claude/initiatives/") || lower.startsWith("docs/initiatives/") || lower.startsWith("initiatives/") || lower.startsWith("memory/") || lower.startsWith("memories/") || lower.startsWith("project-memory/") || lower.includes("/feature/") || lower.includes("feature") || lower.includes("rbac") || lower.includes("billing") || lower.includes("admin") || lower.includes("dashboard")) {
850
- return {
851
- role: "feature-business",
852
- confidence: 0.78,
853
- reason: "feature, initiative, memory, or business-domain evidence"
854
- };
855
- }
856
- if (lower === ".agents" || lower === ".claude" || lower === ".codex" || lower === ".cursor" || lower === "claude.md" || lower === "agents.md" || lower === "gemini.md" || lower === "copilot-instructions.md" || lower === ".cursorrules" || lower === ".windsurfrules" || lower.startsWith(".claude/") || lower.startsWith(".agents/") || lower.startsWith(".codex/") || lower.startsWith(".cursor/rules/")) {
857
- return {
858
- role: "assistant-specific",
859
- confidence: 0.98,
860
- reason: "assistant or AI-agent instruction surface"
861
- };
862
- }
863
- if (lower.includes("security") || lower.includes("auth") || lower.includes("rls") || lower.includes("schema") || lower.includes("migration") || lower.startsWith("supabase/") || lower.startsWith("migrations/") || lower.startsWith("db/") || lower.startsWith("rolemigrations/") || lower.includes("middleware.")) {
864
- return {
865
- role: "security-data",
866
- confidence: 0.9,
867
- reason: "security, auth, schema, middleware, or data-governance evidence"
868
- };
869
- }
870
- if (lower.includes("design-system") || lower === "components.json" || lower.startsWith("tailwind.config") || lower.includes("ui-components") || lower.includes("colors") || lower.includes("typography") || lower.includes("spacing")) {
871
- return {
872
- role: "design-system",
873
- confidence: 0.88,
874
- reason: "design system or styling convention evidence"
875
- };
886
+ function inferArea(item) {
887
+ const lower = normalized(item.path);
888
+ if (lower.includes("security") || lower.includes("auth") || lower.includes("rls") || lower.includes("schema") || lower.includes("database") || lower.includes("data-layer") || lower.includes("middleware") || lower.startsWith("supabase/") || lower.startsWith("migrations/") || lower.startsWith("rolemigrations/")) {
889
+ return "security-data";
876
890
  }
877
- if (lower.startsWith(".github/workflows/") || lower.includes("workflow") || lower.includes("testing") || lower.includes("deployment") || lower.includes("vitest.config") || lower.includes("playwright.config") || lower === "package.json") {
878
- return {
879
- role: "workflow-ci",
880
- confidence: 0.84,
881
- reason: "workflow, CI, deployment, or validation command evidence"
882
- };
891
+ if (lower.includes("design-system") || lower.includes("ui-components") || lower.includes("colors") || lower.includes("typography") || lower.includes("spacing") || lower.includes("components.json") || lower.includes("tailwind.config")) {
892
+ return "design-system";
883
893
  }
884
- if (lower === "docs" || lower.includes("architecture") || lower === "readme.md" || lower.includes("setup") || lower.includes("contributing") || name === "tsconfig.json" || lower.endsWith("config.ts") || lower.endsWith("config.js") || lower.endsWith("config.mjs")) {
885
- return {
886
- role: "architecture",
887
- confidence: 0.72,
888
- reason: "architecture, setup, or framework configuration evidence"
889
- };
894
+ if (lower.includes("architecture") || lower.includes("state-management") || lower.includes("setup") || lower.includes("readme") || lower.endsWith("config.ts") || lower.endsWith("config.js") || lower.endsWith("config.mjs")) {
895
+ return "architecture";
890
896
  }
891
- if (lower.includes("complete") || lower.includes("summary") || lower.includes("deprecated") || lower.includes("legacy") || lower.includes("migration")) {
892
- return {
893
- role: "stale-or-historical",
894
- confidence: 0.64,
895
- reason: "historical or possibly stale project documentation"
896
- };
897
+ if (lower.includes("workflow") || lower.includes("deployment") || lower.includes("quality") || lower.includes("testing") || lower.includes("vitest") || lower.includes("playwright") || lower.startsWith(".github/workflows/")) {
898
+ return "workflow-ci";
897
899
  }
898
- return { role: "unknown", confidence: 0.35, reason: "unclassified context candidate" };
900
+ return item.role;
899
901
  }
900
- function isSafeToCite(relPath) {
901
- const lower = normalizedPath(relPath).toLowerCase();
902
- if (lower.startsWith(".env") && lower !== ".env.example" && lower !== ".env.sample") return false;
903
- if (lower.includes("secret") || lower.includes("private-key") || lower.includes("credentials")) {
904
- return false;
902
+ function precedenceFor(item, area, staleRisks) {
903
+ let score = BASE_PRECEDENCE[area];
904
+ const lower = normalized(item.path);
905
+ if (lower.startsWith(".claude/rules/") || lower.startsWith(".cursor/rules/")) score += 6;
906
+ if (lower === "claude.md" || lower === "agents.md" || lower === "copilot-instructions.md") {
907
+ score += 3;
905
908
  }
906
- return true;
909
+ if (item.type === "directory") score -= 8;
910
+ if (isStalePath(item.path, staleRisks)) score -= 35;
911
+ if (!item.safeToCite) score -= 20;
912
+ return Math.max(0, Math.min(100, score));
907
913
  }
908
- function addDirectoryContext(items, projectRoot, relPath) {
909
- const fullPath = join5(projectRoot, relPath);
910
- if (!existsSync5(fullPath)) return;
911
- const stats = statSync2(fullPath);
912
- const classified = classifyContext(relPath);
913
- items.push({
914
- path: normalizedPath(relPath),
915
- type: "directory",
916
- role: classified.role,
917
- confidence: classified.confidence,
918
- sizeBytes: stats.size,
919
- safeToCite: isSafeToCite(relPath),
920
- reason: classified.reason
921
- });
914
+ function summarize2(sources) {
915
+ const summary = Object.fromEntries(PRECEDENCE_ORDER.map((area) => [area, 0]));
916
+ for (const source of sources) summary[source.area] += 1;
917
+ return summary;
922
918
  }
923
- function walk(projectRoot, dir, items, depth) {
924
- if (depth > 6) return;
925
- let entries;
926
- try {
927
- entries = readdirSync4(dir);
928
- } catch {
929
- return;
930
- }
931
- for (const entry of entries) {
932
- if (shouldSkipDir(entry)) continue;
933
- const fullPath = join5(dir, entry);
934
- const relPath = normalizedPath(relative2(projectRoot, fullPath));
935
- let stats;
936
- try {
937
- stats = statSync2(fullPath);
938
- } catch {
919
+ function topSources(sources, areas, limit = 5) {
920
+ return sources.filter((source) => source.currency === "current" && areas.includes(source.area)).slice(0, limit).map((source) => source.path);
921
+ }
922
+ function buildResolutions(conflicts, staleRisks, sources) {
923
+ const resolutions = [];
924
+ for (const conflict of conflicts) {
925
+ const lower = conflict.toLowerCase();
926
+ if (lower.includes("framework")) {
927
+ resolutions.push({
928
+ kind: "conflict",
929
+ issue: conflict,
930
+ recommendation: "Prefer package/config detection and current architecture sources over stale docs or assistant memory when deciding framework/runtime conventions.",
931
+ preferredSources: topSources(sources, ["architecture", "workflow-ci"]),
932
+ confidence: 0.78
933
+ });
939
934
  continue;
940
935
  }
941
- if (stats.isDirectory()) {
942
- if (CONTEXT_DIRECTORIES.has(relPath)) {
943
- addDirectoryContext(items, projectRoot, relPath);
944
- }
945
- walk(projectRoot, fullPath, items, depth + 1);
936
+ if (lower.includes("tailwind")) {
937
+ resolutions.push({
938
+ kind: "conflict",
939
+ issue: conflict,
940
+ recommendation: "Preserve the existing styling system until the user approves migration; treat current design-system docs and Tailwind/shadcn config as the styling authority.",
941
+ preferredSources: topSources(sources, ["design-system", "architecture"]),
942
+ confidence: 0.82
943
+ });
946
944
  continue;
947
945
  }
948
- if (!stats.isFile() || !isPotentialContextFile(relPath, entry)) continue;
949
- const classified = classifyContext(relPath);
950
- items.push({
951
- path: relPath,
952
- type: "file",
953
- role: classified.role,
954
- confidence: classified.confidence,
955
- sizeBytes: stats.size,
956
- safeToCite: isSafeToCite(relPath),
957
- reason: classified.reason
958
- });
959
- }
960
- }
961
- function summarize(items) {
962
- const summary = {
963
- "assistant-specific": 0,
964
- "security-data": 0,
965
- architecture: 0,
966
- "design-system": 0,
967
- "workflow-ci": 0,
968
- "feature-business": 0,
969
- "stale-or-historical": 0,
970
- unknown: 0
946
+ if (lower.includes("client") && lower.includes("server")) {
947
+ resolutions.push({
948
+ kind: "conflict",
949
+ issue: conflict,
950
+ recommendation: "Prefer current framework architecture and security/data boundaries; stop and ask for review before moving client/server responsibilities.",
951
+ preferredSources: topSources(sources, ["architecture", "security-data"]),
952
+ confidence: 0.76
953
+ });
954
+ continue;
955
+ }
956
+ resolutions.push({
957
+ kind: "conflict",
958
+ issue: conflict,
959
+ recommendation: "Use the highest-precedence current sources in the doctrine map and report the conflict before enforcing either side.",
960
+ preferredSources: sources.filter((source) => source.currency === "current").slice(0, 5).map((source) => source.path),
961
+ confidence: 0.62
962
+ });
963
+ }
964
+ if (staleRisks.length > 0) {
965
+ resolutions.push({
966
+ kind: "stale-risk",
967
+ issue: `${staleRisks.length} stale or historical source(s) detected.`,
968
+ recommendation: "Treat stale-risk sources as historical evidence until confirmed by current security/data, architecture, design-system, workflow, or feature doctrine.",
969
+ preferredSources: sources.filter((source) => source.currency === "current" && source.area !== "assistant-specific").slice(0, 5).map((source) => source.path),
970
+ confidence: 0.84
971
+ });
972
+ }
973
+ return resolutions;
974
+ }
975
+ function createDoctrineMap(ambient) {
976
+ const sources = ambient.items.map((item) => {
977
+ const area = inferArea(item);
978
+ const stale = isStalePath(item.path, ambient.staleRisks);
979
+ return {
980
+ path: item.path,
981
+ type: item.type,
982
+ area: stale ? "stale-or-historical" : area,
983
+ originalRole: item.role,
984
+ precedence: precedenceFor(item, area, ambient.staleRisks),
985
+ confidence: item.confidence,
986
+ currency: !item.safeToCite ? "unsafe-to-cite" : stale ? "stale-risk" : "current",
987
+ safeToCite: item.safeToCite,
988
+ rationale: item.reason
989
+ };
990
+ }).sort((a, b) => b.precedence - a.precedence || a.path.localeCompare(b.path));
991
+ return {
992
+ version: 1,
993
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
994
+ precedenceOrder: PRECEDENCE_ORDER,
995
+ sources,
996
+ summary: summarize2(sources),
997
+ conflicts: ambient.conflicts,
998
+ staleRisks: ambient.staleRisks,
999
+ resolutions: buildResolutions(ambient.conflicts, ambient.staleRisks, sources),
1000
+ guidance: [
1001
+ "Treat security/data doctrine as highest precedence for implementation safety.",
1002
+ "Treat architecture and design-system sources as product conventions, not Decantr defaults.",
1003
+ "Treat workflow/CI sources as validation evidence for commands and release gates.",
1004
+ "Treat stale-risk sources as historical evidence until a current source confirms them.",
1005
+ "Do not cite unsafe sources directly in assistant context."
1006
+ ]
971
1007
  };
972
- for (const item of items) summary[item.role] += 1;
973
- return summary;
974
1008
  }
975
- function readSmallText(projectRoot, relPath) {
976
- const fullPath = join5(projectRoot, relPath);
1009
+ function doctrineMapPath(projectRoot) {
1010
+ return join4(projectRoot, ".decantr", "doctrine-map.json");
1011
+ }
1012
+ function writeDoctrineMap(projectRoot, doctrine) {
1013
+ writeFileSync(doctrineMapPath(projectRoot), JSON.stringify(doctrine, null, 2) + "\n", "utf-8");
1014
+ }
1015
+ function readDoctrineMap(projectRoot) {
1016
+ const path = doctrineMapPath(projectRoot);
1017
+ if (!existsSync4(path)) return null;
977
1018
  try {
978
- const stat = statSync2(fullPath);
979
- if (stat.size > 64e3) return "";
980
- return readFileSync5(fullPath, "utf-8");
1019
+ const parsed = JSON.parse(readFileSync4(path, "utf-8"));
1020
+ if (parsed.version !== 1 || !Array.isArray(parsed.sources)) return null;
1021
+ return parsed;
981
1022
  } catch {
982
- return "";
983
- }
984
- }
985
- function detectConflicts(projectRoot, items) {
986
- const text = items.filter(
987
- (item) => item.type === "file" && item.safeToCite && item.path.match(/\.(md|mdx|json|ts|js|yml|yaml)$/)
988
- ).slice(0, 80).map((item) => readSmallText(projectRoot, item.path)).join("\n").toLowerCase();
989
- const conflicts = [];
990
- const frameworkSignals = [
991
- ["next", /\bnext\.?js\b|\bapp router\b|\bpages router\b/],
992
- ["angular", /\bangular\b/],
993
- ["svelte", /\bsvelte\b|\bsveltekit\b/],
994
- ["vue", /\bvue\b|\bnuxt\b/]
995
- ].filter(([, pattern]) => pattern.test(text));
996
- if (frameworkSignals.length > 1) {
997
- conflicts.push(
998
- `Multiple framework doctrines appear in ambient docs: ${frameworkSignals.map(([name]) => name).join(", ")}.`
999
- );
1000
- }
1001
- const forbidsTailwind = /\b(do not|don't|avoid|forbid|forbidden)\s+use\s+tailwind\b|\bno\s+tailwind\b/.test(text);
1002
- const endorsesTailwind = /\btailwind\.config\b|\btailwindcss\b|\b@tailwind\b|\btailwind\s+classes\b/.test(text);
1003
- if (forbidsTailwind && endorsesTailwind) {
1004
- conflicts.push("Ambient docs contain both Tailwind usage and anti-Tailwind language.");
1005
- }
1006
- if (/\bclient component\b/.test(text) && /\bserver components? only\b/.test(text)) {
1007
- conflicts.push("Ambient docs may conflict on client vs server component boundaries.");
1023
+ return null;
1008
1024
  }
1009
- return conflicts;
1010
1025
  }
1011
- function detectDecantrEssenceStaleRisk(projectRoot, items) {
1012
- if (!items.some((item) => item.path === "decantr.essence.json")) return [];
1013
- const content = readSmallText(projectRoot, "decantr.essence.json");
1014
- if (!content) return [];
1026
+
1027
+ // src/brownfield-check.ts
1028
+ function readProjectJson(projectRoot) {
1029
+ const path = join5(projectRoot, ".decantr", "project.json");
1030
+ if (!existsSync5(path)) return {};
1015
1031
  try {
1016
- const essence = JSON.parse(content);
1017
- const risks = [];
1018
- if (essence.version !== "4.0.0") {
1019
- risks.push(
1020
- `decantr.essence.json uses Decantr essence version ${essence.version ?? "unknown"}; run decantr migrate --to v4 or review before treating it as current brownfield doctrine.`
1021
- );
1022
- }
1023
- if (essence.dna?.theme?.id === "luminarum" && essence.structure) {
1024
- risks.push(
1025
- "decantr.essence.json looks like an older Decantr default scaffold; verify before importing its theme or page layout as brownfield truth."
1026
- );
1027
- }
1028
- return risks;
1032
+ return JSON.parse(readFileSync5(path, "utf-8"));
1029
1033
  } catch {
1030
- return [
1031
- "decantr.essence.json could not be parsed during ambient inventory; review before treating it as current doctrine."
1032
- ];
1034
+ return {};
1033
1035
  }
1034
1036
  }
1035
- function detectStaleRisks(projectRoot, items) {
1036
- const pathRisks = items.filter(
1037
- (item) => item.role === "stale-or-historical" || /complete|summary|legacy|deprecated/i.test(item.path)
1038
- ).slice(0, 12).map(
1039
- (item) => `${item.path} may be historical; verify before treating it as current doctrine.`
1040
- );
1041
- return [...pathRisks, ...detectDecantrEssenceStaleRisk(projectRoot, items)];
1037
+ function essenceRoutes(essence) {
1038
+ const fromRouteMap = Object.keys(essence.blueprint.routes ?? {});
1039
+ const fromPages = essence.blueprint.sections?.flatMap(
1040
+ (section) => section.pages.map((page) => page.route).filter((route) => Boolean(route))
1041
+ ) ?? essence.blueprint.pages?.map((page) => page.route).filter((route) => Boolean(route)) ?? [];
1042
+ return /* @__PURE__ */ new Set([...fromRouteMap, ...fromPages]);
1042
1043
  }
1043
- function scanAmbientContext(projectRoot) {
1044
- const items = [];
1045
- walk(projectRoot, projectRoot, items, 0);
1046
- const deduped = [...new Map(items.map((item) => [item.path, item])).values()].sort(
1047
- (a, b) => a.path.localeCompare(b.path)
1044
+ function routeLabel(routes) {
1045
+ if (routes.length <= 6) return routes.join(", ");
1046
+ return `${routes.slice(0, 6).join(", ")} (+${routes.length - 6} more)`;
1047
+ }
1048
+ function hasDoctrineEffect(essence, key) {
1049
+ const effects = essence.dna.constraints?.effects;
1050
+ return Boolean(effects && effects[key]);
1051
+ }
1052
+ function hasActionableDoctrineSource(doctrine, area) {
1053
+ return doctrine.sources.some(
1054
+ (source) => source.area === area && source.currency === "current" && source.safeToCite && source.confidence >= 0.72 && source.precedence >= 75
1048
1055
  );
1049
- return {
1050
- version: 1,
1051
- scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
1052
- items: deduped,
1053
- summary: summarize(deduped),
1054
- conflicts: detectConflicts(projectRoot, deduped),
1055
- staleRisks: detectStaleRisks(projectRoot, deduped)
1056
- };
1057
1056
  }
1058
-
1059
- // src/analyzers/routes.ts
1060
- import { existsSync as existsSync6, readdirSync as readdirSync5, readFileSync as readFileSync6, statSync as statSync3 } from "fs";
1061
- import { join as join6, relative as relative3 } from "path";
1062
- var SKIP_DIRS2 = /* @__PURE__ */ new Set(["node_modules", ".next", ".git", "api", "_app", "_document"]);
1063
- function shouldSkipDir2(name) {
1064
- return name.startsWith("_") || name.startsWith(".") || SKIP_DIRS2.has(name);
1057
+ function hasAssistantBridge(projectRoot) {
1058
+ const previewPath = join5(projectRoot, ".decantr", "context", "assistant-bridge.md");
1059
+ if (existsSync5(previewPath)) return true;
1060
+ const candidateFiles = [
1061
+ "CLAUDE.md",
1062
+ "AGENTS.md",
1063
+ "GEMINI.md",
1064
+ "copilot-instructions.md",
1065
+ ".github/copilot-instructions.md",
1066
+ ".cursorrules",
1067
+ ".windsurfrules",
1068
+ ".claude/rules/decantr.md",
1069
+ ".cursor/rules/decantr.mdc"
1070
+ ];
1071
+ return candidateFiles.some((rel) => {
1072
+ const path = join5(projectRoot, rel);
1073
+ if (!existsSync5(path)) return false;
1074
+ try {
1075
+ return readFileSync5(path, "utf-8").includes("decantr:assistant-bridge:start");
1076
+ } catch {
1077
+ return false;
1078
+ }
1079
+ });
1065
1080
  }
1066
- function segmentToRoute(segment) {
1067
- if (segment.startsWith("(") && segment.endsWith(")")) {
1068
- return null;
1081
+ function scanBrownfieldIssues(projectRoot, essence) {
1082
+ const projectJson = readProjectJson(projectRoot);
1083
+ const routes = scanRoutes(projectRoot);
1084
+ const styling = scanStyling(projectRoot);
1085
+ const ambient = scanAmbientContext(projectRoot);
1086
+ const doctrine = readDoctrineMap(projectRoot) ?? createDoctrineMap(ambient);
1087
+ const issues = [];
1088
+ const declaredRoutes = essenceRoutes(essence);
1089
+ const observedRoutes = new Set(routes.routes.map((route) => route.path));
1090
+ const missingFromEssence = [...observedRoutes].filter((route) => !declaredRoutes.has(route));
1091
+ const missingFromSource = [...declaredRoutes].filter((route) => !observedRoutes.has(route));
1092
+ if (routes.routes.length > 0 && declaredRoutes.size === 0) {
1093
+ issues.push({
1094
+ type: "error",
1095
+ rule: "brownfield-route-coverage",
1096
+ message: `The app has ${routes.routes.length} observed route(s), but the Decantr essence declares no routes.`,
1097
+ suggestion: "Run `decantr analyze`, review the proposal, then `decantr init --existing --accept-proposal` or `--merge-proposal`."
1098
+ });
1099
+ } else if (missingFromEssence.length > 0) {
1100
+ issues.push({
1101
+ type: "error",
1102
+ rule: "brownfield-route-drift",
1103
+ message: `Observed routes are missing from the Decantr contract: ${routeLabel(missingFromEssence)}.`,
1104
+ suggestion: "Regenerate a brownfield proposal and merge the missing routes into the essence."
1105
+ });
1069
1106
  }
1070
- if (segment.startsWith("[") && segment.endsWith("]")) {
1071
- const param = segment.slice(1, -1);
1072
- if (param.startsWith("...")) {
1073
- return `:${param.slice(3)}*`;
1074
- }
1075
- if (param.startsWith("[...") && param.endsWith("]")) {
1076
- return `:${param.slice(4, -1)}*`;
1107
+ if (routes.routes.length > 0 && declaredRoutes.size === 1 && declaredRoutes.has("/") && routes.routes.length > 1) {
1108
+ issues.push({
1109
+ type: "error",
1110
+ rule: "brownfield-generic-contract",
1111
+ message: "The essence only declares `/` while the app has multiple observed routes.",
1112
+ suggestion: "Accept or merge an observed brownfield proposal instead of using a generic scaffold contract."
1113
+ });
1114
+ }
1115
+ if (missingFromSource.length > 0 && routes.routes.length > 0) {
1116
+ issues.push({
1117
+ type: "warning",
1118
+ rule: "brownfield-stale-route",
1119
+ message: `Essence routes were not observed in source: ${routeLabel(missingFromSource)}.`,
1120
+ suggestion: "Confirm whether these are generated/dynamic routes or stale contract entries."
1121
+ });
1122
+ }
1123
+ const adoptionMode = projectJson.initialized?.adoptionMode;
1124
+ const themeId = essence.dna.theme.id;
1125
+ if (adoptionMode === "contract-only" && themeId === "luminarum" && (styling.approach !== "unknown" || styling.cssVariables.length > 0)) {
1126
+ issues.push({
1127
+ type: "warning",
1128
+ rule: "brownfield-theme-default",
1129
+ message: "Contract-only brownfield essence still uses Decantr theme `luminarum` while the app has an existing styling system.",
1130
+ suggestion: 'Use an observed proposal with `theme.id = "existing"` unless the user explicitly opts into a Decantr theme.'
1131
+ });
1132
+ }
1133
+ for (const conflict of ambient.conflicts) {
1134
+ issues.push({
1135
+ type: "warning",
1136
+ rule: "brownfield-doctrine-conflict",
1137
+ message: conflict,
1138
+ suggestion: "Resolve or document precedence before treating these rules as enforceable contract."
1139
+ });
1140
+ }
1141
+ if (ambient.items.length === 0) {
1142
+ issues.push({
1143
+ type: "warning",
1144
+ rule: "brownfield-context-missing",
1145
+ message: "No ambient project context was detected for this brownfield check.",
1146
+ suggestion: "Run `decantr analyze` to create `.decantr/ambient-context.json` and a proposal-backed report."
1147
+ });
1148
+ }
1149
+ const hasBrownfieldArtifacts = Boolean(
1150
+ projectJson.initialized?.workflowMode === "brownfield-attach"
1151
+ );
1152
+ if (hasBrownfieldArtifacts && !existsSync5(join5(projectRoot, ".decantr", "doctrine-map.json"))) {
1153
+ issues.push({
1154
+ type: "warning",
1155
+ rule: "brownfield-doctrine-map-missing",
1156
+ message: "Brownfield attach metadata exists, but `.decantr/doctrine-map.json` is missing.",
1157
+ suggestion: "Run `decantr analyze` to regenerate ranked doctrine evidence."
1158
+ });
1159
+ }
1160
+ if (hasActionableDoctrineSource(doctrine, "security-data") && !hasDoctrineEffect(essence, "doctrine-security-data")) {
1161
+ issues.push({
1162
+ type: "warning",
1163
+ rule: "brownfield-doctrine-coverage",
1164
+ message: "Security/data doctrine was detected, but the essence does not record a security/data preservation constraint.",
1165
+ suggestion: "Regenerate and merge a brownfield proposal so security/data doctrine is represented in `dna.constraints.effects`."
1166
+ });
1167
+ }
1168
+ if (hasActionableDoctrineSource(doctrine, "design-system") && !hasDoctrineEffect(essence, "doctrine-design-system")) {
1169
+ issues.push({
1170
+ type: "warning",
1171
+ rule: "brownfield-doctrine-coverage",
1172
+ message: "Design-system doctrine was detected, but the essence does not record a design-system preservation constraint.",
1173
+ suggestion: "Regenerate and merge a brownfield proposal so design-system doctrine is represented in `dna.constraints.effects`."
1174
+ });
1175
+ }
1176
+ if (styling.approach !== "unknown") {
1177
+ const palette = String(essence.dna.color.palette ?? "");
1178
+ const observedPalette = palette === "observed" || palette === styling.approach;
1179
+ if (themeId === "existing" && !observedPalette) {
1180
+ issues.push({
1181
+ type: "warning",
1182
+ rule: "brownfield-style-drift",
1183
+ message: `Observed styling approach is ${styling.approach}, but the essence color palette is ${palette || "unset"}.`,
1184
+ suggestion: "Regenerate and merge a brownfield proposal so the contract reflects the existing styling system."
1185
+ });
1077
1186
  }
1078
- return `:${param}`;
1079
1187
  }
1080
- return segment;
1188
+ if (ambient.items.some((item) => item.role === "assistant-specific") && hasBrownfieldArtifacts && !hasAssistantBridge(projectRoot)) {
1189
+ issues.push({
1190
+ type: "warning",
1191
+ rule: "brownfield-assistant-bridge-missing",
1192
+ message: "Assistant-specific rule files were detected, but no Decantr assistant bridge preview or applied bridge block was found.",
1193
+ suggestion: "Run `decantr rules preview` first, then `decantr rules apply` if the user explicitly approves rule-file mutation."
1194
+ });
1195
+ }
1196
+ const unsafeSources = doctrine.sources.filter((source) => !source.safeToCite);
1197
+ if (unsafeSources.length > 0) {
1198
+ issues.push({
1199
+ type: "warning",
1200
+ rule: "brownfield-unsafe-context",
1201
+ message: `Some ambient context should not be cited directly: ${unsafeSources.slice(0, 4).map((source) => source.path).join(", ")}${unsafeSources.length > 4 ? ` (+${unsafeSources.length - 4} more)` : ""}.`,
1202
+ suggestion: "Keep unsafe source paths in the inventory, but do not paste their contents into assistant context."
1203
+ });
1204
+ }
1205
+ return issues;
1081
1206
  }
1082
- function walkAppDir(dir, baseDir, segments) {
1083
- const routes = [];
1084
- let entries;
1207
+
1208
+ // src/guard-context.ts
1209
+ import { existsSync as existsSync7, readdirSync as readdirSync4, readFileSync as readFileSync7 } from "fs";
1210
+ import { join as join7 } from "path";
1211
+
1212
+ // src/bundled-content.ts
1213
+ import { existsSync as existsSync6, readdirSync as readdirSync3, readFileSync as readFileSync6 } from "fs";
1214
+ import { dirname, join as join6 } from "path";
1215
+ import { fileURLToPath } from "url";
1216
+ function bundledDirCandidates(contentType) {
1217
+ const currentDir = dirname(fileURLToPath(import.meta.url));
1218
+ return [
1219
+ join6(currentDir, "bundled", contentType),
1220
+ join6(currentDir, "..", "src", "bundled", contentType),
1221
+ join6(currentDir, "..", "bundled", contentType)
1222
+ ];
1223
+ }
1224
+ function getBundledContentPath(contentType, id) {
1225
+ for (const dir of bundledDirCandidates(contentType)) {
1226
+ const candidate = join6(dir, `${id}.json`);
1227
+ if (existsSync6(candidate)) return candidate;
1228
+ }
1229
+ return null;
1230
+ }
1231
+ function loadBundledContentItem(contentType, id) {
1232
+ const path = getBundledContentPath(contentType, id);
1233
+ if (!path) return null;
1085
1234
  try {
1086
- entries = readdirSync5(dir);
1235
+ const data = JSON.parse(readFileSync6(path, "utf-8"));
1236
+ return { id, data, path };
1087
1237
  } catch {
1088
- return routes;
1089
- }
1090
- const hasPage = entries.some(
1091
- (e) => e === "page.tsx" || e === "page.ts" || e === "page.jsx" || e === "page.js"
1092
- );
1093
- const hasLayout = entries.some(
1094
- (e) => e === "layout.tsx" || e === "layout.ts" || e === "layout.jsx" || e === "layout.js"
1095
- );
1096
- if (hasPage) {
1097
- const routePath = "/" + segments.filter((s) => s !== "").join("/");
1098
- const pageFile = entries.find((e) => e.startsWith("page."));
1099
- routes.push({
1100
- path: routePath || "/",
1101
- file: relative3(baseDir, join6(dir, pageFile)),
1102
- hasLayout
1103
- });
1238
+ return null;
1104
1239
  }
1105
- for (const entry of entries) {
1106
- if (shouldSkipDir2(entry)) continue;
1107
- const fullPath = join6(dir, entry);
1240
+ }
1241
+ function loadBundledContentList(contentType) {
1242
+ const entries = [];
1243
+ const seen = /* @__PURE__ */ new Set();
1244
+ for (const dir of bundledDirCandidates(contentType)) {
1245
+ if (!existsSync6(dir)) continue;
1108
1246
  try {
1109
- if (!statSync3(fullPath).isDirectory()) continue;
1247
+ for (const file of readdirSync3(dir).filter((name) => name.endsWith(".json"))) {
1248
+ const id = file.replace(/\.json$/, "");
1249
+ if (seen.has(id)) continue;
1250
+ const path = join6(dir, file);
1251
+ const data = JSON.parse(readFileSync6(path, "utf-8"));
1252
+ entries.push({ id, data, path });
1253
+ seen.add(id);
1254
+ }
1110
1255
  } catch {
1111
- continue;
1112
1256
  }
1113
- const routeSegment = segmentToRoute(entry);
1114
- const nextSegments = routeSegment === null ? [...segments] : [...segments, routeSegment];
1115
- routes.push(...walkAppDir(fullPath, baseDir, nextSegments));
1116
1257
  }
1117
- return routes;
1258
+ return entries;
1118
1259
  }
1119
- function walkPagesDir(dir, baseDir, segments, extensions = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "md", "mdx"])) {
1120
- const routes = [];
1121
- let entries;
1260
+
1261
+ // src/guard-context.ts
1262
+ function loadJsonEntries(dir) {
1263
+ if (!existsSync7(dir)) return [];
1122
1264
  try {
1123
- entries = readdirSync5(dir);
1265
+ return readdirSync4(dir).filter((file) => file.endsWith(".json") && file !== "index.json").map((file) => JSON.parse(readFileSync7(join7(dir, file), "utf-8")));
1124
1266
  } catch {
1125
- return routes;
1267
+ return [];
1126
1268
  }
1127
- for (const entry of entries) {
1128
- if (shouldSkipDir2(entry)) continue;
1129
- const fullPath = join6(dir, entry);
1130
- try {
1131
- const stat = statSync3(fullPath);
1132
- if (stat.isDirectory()) {
1133
- const routeSegment = segmentToRoute(entry);
1134
- const nextSegments = routeSegment === null ? [...segments] : [...segments, routeSegment];
1135
- routes.push(...walkPagesDir(fullPath, baseDir, nextSegments, extensions));
1136
- } else if (stat.isFile()) {
1137
- const match = entry.match(/^(.+)\.([^.]+)$/);
1138
- if (!match) continue;
1139
- const name = match[1];
1140
- const extension = match[2];
1141
- if (!extensions.has(extension)) continue;
1142
- if (name.startsWith("_")) continue;
1143
- const routeSegment = name === "index" ? "" : segmentToRoute(name) ?? name;
1144
- const routePath = "/" + [...segments, routeSegment].filter((s) => s !== "").join("/");
1145
- routes.push({
1146
- path: routePath || "/",
1147
- file: relative3(baseDir, fullPath),
1148
- hasLayout: false
1149
- });
1150
- }
1151
- } catch {
1269
+ }
1270
+ function buildGuardRegistryContext(projectRoot = process.cwd()) {
1271
+ const themeRegistry = /* @__PURE__ */ new Map();
1272
+ const patternRegistry = /* @__PURE__ */ new Map();
1273
+ const cacheDir = join7(projectRoot, ".decantr", "cache");
1274
+ const customDir = join7(projectRoot, ".decantr", "custom");
1275
+ for (const data of loadJsonEntries(join7(cacheDir, "@official", "themes"))) {
1276
+ if (typeof data.id === "string" && !themeRegistry.has(data.id)) {
1277
+ themeRegistry.set(data.id, {
1278
+ modes: Array.isArray(data.modes) ? data.modes.filter((mode) => typeof mode === "string") : ["light", "dark"]
1279
+ });
1152
1280
  }
1153
1281
  }
1154
- return routes;
1155
- }
1156
- function walkSvelteKitRoutes(dir, baseDir, segments) {
1157
- const routes = [];
1158
- let entries;
1159
- try {
1160
- entries = readdirSync5(dir);
1161
- } catch {
1162
- return routes;
1282
+ for (const data of loadJsonEntries(join7(customDir, "themes"))) {
1283
+ if (typeof data.id === "string") {
1284
+ themeRegistry.set(`custom:${data.id}`, {
1285
+ modes: Array.isArray(data.modes) ? data.modes.filter((mode) => typeof mode === "string") : ["light", "dark"]
1286
+ });
1287
+ }
1163
1288
  }
1164
- const pageFile = entries.find((entry) => /^\+page\.(svelte|ts|js)$/.test(entry));
1165
- const hasLayout = entries.some((entry) => /^\+layout\.(svelte|ts|js)$/.test(entry));
1166
- if (pageFile) {
1167
- const routePath = "/" + segments.filter((segment) => segment !== "").join("/");
1168
- routes.push({
1169
- path: routePath || "/",
1170
- file: relative3(baseDir, join6(dir, pageFile)),
1171
- hasLayout
1172
- });
1289
+ for (const data of loadJsonEntries(join7(cacheDir, "@official", "patterns"))) {
1290
+ if (typeof data.id === "string" && !patternRegistry.has(data.id)) {
1291
+ patternRegistry.set(data.id, data);
1292
+ }
1173
1293
  }
1174
- for (const entry of entries) {
1175
- if (shouldSkipDir2(entry)) continue;
1176
- const fullPath = join6(dir, entry);
1177
- try {
1178
- if (!statSync3(fullPath).isDirectory()) continue;
1179
- } catch {
1180
- continue;
1294
+ for (const entry of loadBundledContentList("patterns")) {
1295
+ const data = entry.data;
1296
+ const id = typeof data.id === "string" ? data.id : entry.id;
1297
+ if (!patternRegistry.has(id)) {
1298
+ patternRegistry.set(id, data);
1181
1299
  }
1182
- const routeSegment = segmentToRoute(entry);
1183
- const nextSegments = routeSegment === null ? [...segments] : [...segments, routeSegment];
1184
- routes.push(...walkSvelteKitRoutes(fullPath, baseDir, nextSegments));
1185
1300
  }
1186
- return routes;
1187
- }
1188
- var ROUTER_FILE_EXTENSIONS = /* @__PURE__ */ new Set([".tsx", ".ts", ".jsx", ".js"]);
1189
- function collectRouteCandidateFiles(dir, files, depth = 0) {
1190
- if (depth > 5) return;
1191
- let entries;
1192
- try {
1193
- entries = readdirSync5(dir);
1194
- } catch {
1195
- return;
1301
+ for (const data of loadJsonEntries(join7(customDir, "patterns"))) {
1302
+ if (typeof data.id === "string") {
1303
+ patternRegistry.set(data.id, data);
1304
+ }
1196
1305
  }
1197
- for (const entry of entries) {
1198
- if (entry.startsWith(".") || entry === "node_modules") continue;
1199
- const fullPath = join6(dir, entry);
1306
+ return { themeRegistry, patternRegistry };
1307
+ }
1308
+
1309
+ // src/lib/scan-interactions.ts
1310
+ import { existsSync as existsSync8, readdirSync as readdirSync5, readFileSync as readFileSync8, statSync as statSync3 } from "fs";
1311
+ import { extname as extname2, join as join8, relative as relative3 } from "path";
1312
+ import { verifyInteractionsInSource } from "@decantr/verifier";
1313
+ var SCAN_EXTENSIONS = /* @__PURE__ */ new Set([".tsx", ".jsx", ".ts", ".js", ".html", ".mdx"]);
1314
+ var SKIP_DIRECTORIES = /* @__PURE__ */ new Set([
1315
+ "node_modules",
1316
+ ".decantr",
1317
+ ".git",
1318
+ "dist",
1319
+ "build",
1320
+ ".next",
1321
+ ".turbo",
1322
+ "coverage",
1323
+ ".cache"
1324
+ ]);
1325
+ var MAX_FILE_SIZE = 1024 * 1024;
1326
+ function walkSourceTree(rootDir) {
1327
+ const sources = /* @__PURE__ */ new Map();
1328
+ function walk2(dir) {
1329
+ let entries;
1200
1330
  try {
1201
- const stat = statSync3(fullPath);
1202
- if (stat.isDirectory()) {
1203
- collectRouteCandidateFiles(fullPath, files, depth + 1);
1204
- } else if (stat.isFile()) {
1205
- const ext = entry.slice(entry.lastIndexOf("."));
1206
- if (ROUTER_FILE_EXTENSIONS.has(ext)) {
1207
- files.push(fullPath);
1331
+ entries = readdirSync5(dir);
1332
+ } catch {
1333
+ return;
1334
+ }
1335
+ for (const entry of entries) {
1336
+ if (SKIP_DIRECTORIES.has(entry)) continue;
1337
+ const fullPath = join8(dir, entry);
1338
+ let s;
1339
+ try {
1340
+ s = statSync3(fullPath);
1341
+ } catch {
1342
+ continue;
1343
+ }
1344
+ if (s.isDirectory()) {
1345
+ walk2(fullPath);
1346
+ } else if (s.isFile() && SCAN_EXTENSIONS.has(extname2(entry))) {
1347
+ if (s.size > MAX_FILE_SIZE) continue;
1348
+ try {
1349
+ sources.set(relative3(rootDir, fullPath) || entry, readFileSync8(fullPath, "utf8"));
1350
+ } catch {
1208
1351
  }
1209
1352
  }
1210
- } catch {
1211
1353
  }
1212
1354
  }
1355
+ walk2(rootDir);
1356
+ return sources;
1213
1357
  }
1214
- function scanReactRouter(projectRoot) {
1215
- const candidateDirs = [join6(projectRoot, "src"), projectRoot];
1216
- const candidateFiles = [];
1217
- for (const dir of candidateDirs) {
1218
- if (existsSync6(dir)) collectRouteCandidateFiles(dir, candidateFiles);
1358
+ function collectDeclaredInteractions(projectRoot) {
1359
+ const manifestPath = join8(projectRoot, ".decantr", "context", "pack-manifest.json");
1360
+ if (!existsSync8(manifestPath)) return [];
1361
+ let manifest;
1362
+ try {
1363
+ manifest = JSON.parse(readFileSync8(manifestPath, "utf8"));
1364
+ } catch {
1365
+ return [];
1219
1366
  }
1220
- const routeMap = /* @__PURE__ */ new Map();
1221
- for (const absolutePath of candidateFiles) {
1222
- let content;
1367
+ const all = [];
1368
+ const pages = manifest.pages ?? [];
1369
+ const contextDir = join8(projectRoot, ".decantr", "context");
1370
+ for (const page of pages) {
1371
+ const packPath = join8(contextDir, page.json);
1372
+ if (!existsSync8(packPath)) continue;
1373
+ let pack;
1223
1374
  try {
1224
- content = readFileSync6(absolutePath, "utf-8");
1375
+ pack = JSON.parse(readFileSync8(packPath, "utf8"));
1225
1376
  } catch {
1226
1377
  continue;
1227
1378
  }
1228
- const isReactRouterFile = content.includes("react-router-dom") || content.includes("react-router") || content.includes("<Routes") || content.includes("createBrowserRouter") || content.includes("createHashRouter") || content.includes("RouterProvider") || content.includes("HashRouter") || content.includes("BrowserRouter");
1229
- if (!isReactRouterFile) continue;
1230
- const relativePath = relative3(projectRoot, absolutePath);
1231
- const pathMatches = /* @__PURE__ */ new Set();
1232
- for (const match of content.matchAll(/<Route\b[^>]*\bpath=["'`]([^"'`]+)["'`]/g)) {
1233
- pathMatches.add(match[1]);
1234
- }
1235
- for (const match of content.matchAll(/\bpath\s*:\s*["'`]([^"'`]+)["'`]/g)) {
1236
- pathMatches.add(match[1]);
1237
- }
1238
- if (pathMatches.size === 0 && (content.includes("<Routes") || content.includes("RouterProvider"))) {
1239
- pathMatches.add("/");
1240
- }
1241
- for (const path of pathMatches) {
1242
- if (!routeMap.has(path)) {
1243
- routeMap.set(path, {
1244
- path,
1245
- file: relativePath,
1246
- hasLayout: false
1247
- });
1379
+ const patterns = pack.data?.patterns ?? [];
1380
+ for (const pat of patterns) {
1381
+ if (Array.isArray(pat.interactions)) {
1382
+ all.push(...pat.interactions);
1248
1383
  }
1249
1384
  }
1250
1385
  }
1251
- return [...routeMap.values()];
1386
+ return all;
1252
1387
  }
1253
- function hasReactRouterDependency(projectRoot) {
1254
- return hasDependency(projectRoot, ["react-router", "react-router-dom"]);
1388
+ function scanProjectInteractions(projectRoot) {
1389
+ const declared = collectDeclaredInteractions(projectRoot);
1390
+ if (declared.length === 0) return [];
1391
+ const sources = walkSourceTree(projectRoot);
1392
+ if (sources.size === 0) return [];
1393
+ const missing = verifyInteractionsInSource(declared, sources);
1394
+ return missing.map(
1395
+ ({ interaction, suggestion, scannedFiles, scannedLocations, expectedSignals }) => {
1396
+ const evidence = [
1397
+ scannedFiles ? `scanned ${scannedFiles} source file(s)` : null,
1398
+ scannedLocations?.length ? `checked: ${scannedLocations.slice(0, 4).map((location) => `${location.file}:${location.startLine}-${location.endLine}`).join(", ")}` : null,
1399
+ expectedSignals?.length ? `expected signals: ${expectedSignals.slice(0, 4).join(", ")}` : null
1400
+ ].filter((entry) => Boolean(entry)).join("; ");
1401
+ return `${interaction} \u2192 ${suggestion}${evidence ? ` (${evidence})` : ""}`;
1402
+ }
1403
+ );
1255
1404
  }
1256
- function hasDependency(projectRoot, names) {
1257
- const packageJsonPath = join6(projectRoot, "package.json");
1258
- if (!existsSync6(packageJsonPath)) return false;
1405
+
1406
+ // src/telemetry.ts
1407
+ import { randomUUID } from "crypto";
1408
+ import { existsSync as existsSync9, mkdirSync, readFileSync as readFileSync9, writeFileSync as writeFileSync2 } from "fs";
1409
+ import { homedir } from "os";
1410
+ import { dirname as dirname2, join as join9, resolve } from "path";
1411
+ import { fileURLToPath as fileURLToPath2 } from "url";
1412
+ import {
1413
+ createFetchTelemetrySink,
1414
+ createTelemetryClient,
1415
+ isTelemetryActorType
1416
+ } from "@decantr/telemetry";
1417
+ var TELEMETRY_ENDPOINT = "https://api.decantr.ai/v1/telemetry/guard";
1418
+ var DEFAULT_TELEMETRY_EVENTS_ENDPOINT = "https://api.decantr.ai/v1/telemetry/events";
1419
+ var TELEMETRY_TIMEOUT_MS = 3e3;
1420
+ var DNA_RULES = /* @__PURE__ */ new Set(["theme", "style", "density", "accessibility", "theme-mode"]);
1421
+ async function sendGuardMetrics(metrics) {
1259
1422
  try {
1260
- const pkg = JSON.parse(readFileSync6(packageJsonPath, "utf-8"));
1261
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
1262
- return names.some((name) => Boolean(deps[name]));
1423
+ const controller = new AbortController();
1424
+ const timer = setTimeout(() => controller.abort(), TELEMETRY_TIMEOUT_MS);
1425
+ await fetch(TELEMETRY_ENDPOINT, {
1426
+ method: "POST",
1427
+ headers: { "Content-Type": "application/json" },
1428
+ body: JSON.stringify(metrics),
1429
+ signal: controller.signal
1430
+ });
1431
+ clearTimeout(timer);
1263
1432
  } catch {
1264
- return false;
1265
1433
  }
1266
1434
  }
1267
- function hasAnyFile(projectRoot, relPaths) {
1268
- return relPaths.some((relPath) => existsSync6(join6(projectRoot, relPath)));
1269
- }
1270
- function normalizeRoutePath(path) {
1271
- const cleaned = path.trim();
1272
- if (!cleaned || cleaned === "/") return "/";
1273
- if (cleaned === "**" || cleaned.startsWith("#")) return null;
1274
- return cleaned.startsWith("/") ? cleaned : `/${cleaned}`;
1275
- }
1276
- function scanAngularRouter(projectRoot) {
1277
- const candidateDirs = [join6(projectRoot, "src", "app"), join6(projectRoot, "src")];
1278
- const candidateFiles = [];
1279
- for (const dir of candidateDirs) {
1280
- if (existsSync6(dir)) collectRouteCandidateFiles(dir, candidateFiles);
1435
+ function isOptedIn(projectRoot) {
1436
+ const projectJsonPath = join9(projectRoot, ".decantr", "project.json");
1437
+ if (!existsSync9(projectJsonPath)) return false;
1438
+ try {
1439
+ const data = JSON.parse(readFileSync9(projectJsonPath, "utf-8"));
1440
+ return data.telemetry === true;
1441
+ } catch {
1442
+ return false;
1281
1443
  }
1282
- const routeMap = /* @__PURE__ */ new Map();
1283
- for (const absolutePath of candidateFiles) {
1284
- let content;
1444
+ }
1445
+ function optIn(projectRoot) {
1446
+ const projectJsonPath = join9(projectRoot, ".decantr", "project.json");
1447
+ let data = {};
1448
+ if (existsSync9(projectJsonPath)) {
1285
1449
  try {
1286
- content = readFileSync6(absolutePath, "utf-8");
1450
+ data = JSON.parse(readFileSync9(projectJsonPath, "utf-8"));
1287
1451
  } catch {
1288
- continue;
1289
- }
1290
- const isRouterFile = content.includes("@angular/router") || content.includes("RouterModule.forRoot") || content.includes("provideRouter") || content.includes("Routes =");
1291
- if (!isRouterFile) continue;
1292
- const relativePath = relative3(projectRoot, absolutePath);
1293
- for (const match of content.matchAll(/\bpath\s*:\s*["'`]([^"'`]*)["'`]/g)) {
1294
- const routePath = normalizeRoutePath(match[1]);
1295
- if (!routePath || routeMap.has(routePath)) continue;
1296
- routeMap.set(routePath, {
1297
- path: routePath,
1298
- file: relativePath,
1299
- hasLayout: false
1300
- });
1301
1452
  }
1302
1453
  }
1303
- return [...routeMap.values()];
1454
+ data.telemetry = true;
1455
+ mkdirSync(dirname2(projectJsonPath), { recursive: true });
1456
+ writeFileSync2(projectJsonPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
1304
1457
  }
1305
- function scanVueRouter(projectRoot) {
1306
- const candidateDirs = [join6(projectRoot, "src"), projectRoot];
1307
- const candidateFiles = [];
1308
- for (const dir of candidateDirs) {
1309
- if (existsSync6(dir)) collectRouteCandidateFiles(dir, candidateFiles);
1310
- }
1311
- const routeMap = /* @__PURE__ */ new Map();
1312
- for (const absolutePath of candidateFiles) {
1313
- let content;
1314
- try {
1315
- content = readFileSync6(absolutePath, "utf-8");
1316
- } catch {
1317
- continue;
1318
- }
1319
- const isRouterFile = content.includes("vue-router") || content.includes("createRouter") || content.includes("createWebHistory") || content.includes("createWebHashHistory");
1320
- if (!isRouterFile) continue;
1321
- const relativePath = relative3(projectRoot, absolutePath);
1322
- for (const match of content.matchAll(/\bpath\s*:\s*["'`]([^"'`]+)["'`]/g)) {
1323
- const routePath = normalizeRoutePath(match[1]);
1324
- if (!routePath || routeMap.has(routePath)) continue;
1325
- routeMap.set(routePath, {
1326
- path: routePath,
1327
- file: relativePath,
1328
- hasLayout: false
1329
- });
1330
- }
1331
- }
1332
- return [...routeMap.values()];
1333
- }
1334
- function scanRoutes(projectRoot) {
1335
- const hasNext = hasDependency(projectRoot, ["next"]) || hasAnyFile(projectRoot, ["next.config.js", "next.config.ts", "next.config.mjs"]);
1336
- const hasSvelteKit = hasDependency(projectRoot, ["@sveltejs/kit", "svelte"]) || hasAnyFile(projectRoot, ["svelte.config.js", "svelte.config.ts"]);
1337
- const hasNuxt = hasDependency(projectRoot, ["nuxt"]) || hasAnyFile(projectRoot, ["nuxt.config.js", "nuxt.config.ts"]);
1338
- const hasAngular = hasDependency(projectRoot, ["@angular/core", "@angular/router"]) || hasAnyFile(projectRoot, ["angular.json"]);
1339
- const hasVue = hasDependency(projectRoot, ["vue", "vue-router"]) || hasAnyFile(projectRoot, ["vite.config.js", "vite.config.ts"]);
1340
- const appDirs = [join6(projectRoot, "src", "app"), join6(projectRoot, "app")];
1341
- const appRoutes = appDirs.flatMap(
1342
- (appDir) => existsSync6(appDir) ? walkAppDir(appDir, projectRoot, []) : []
1343
- );
1344
- const pagesDirs = [join6(projectRoot, "src", "pages"), join6(projectRoot, "pages")];
1345
- const pagesRoutes = pagesDirs.flatMap(
1346
- (pagesDir) => existsSync6(pagesDir) ? walkPagesDir(pagesDir, projectRoot, []) : []
1458
+ async function captureCliTelemetryEvent(input) {
1459
+ const projectRoot = resolveCliTelemetryProjectRoot(
1460
+ input.projectRoot ?? process.cwd(),
1461
+ input.args ?? []
1347
1462
  );
1348
- if (hasNext) {
1349
- if (appRoutes.length > 0 && pagesRoutes.length > 0) {
1350
- return { strategy: "mixed-next-router", routes: [...appRoutes, ...pagesRoutes] };
1351
- }
1352
- if (appRoutes.length > 0) return { strategy: "app-router", routes: appRoutes };
1353
- if (pagesRoutes.length > 0) return { strategy: "pages-router", routes: pagesRoutes };
1354
- } else if (appRoutes.length > 0) {
1355
- return { strategy: "app-router", routes: appRoutes };
1356
- }
1357
- if (hasSvelteKit) {
1358
- const svelteRoutesDir = join6(projectRoot, "src", "routes");
1359
- if (existsSync6(svelteRoutesDir)) {
1360
- const routes = walkSvelteKitRoutes(svelteRoutesDir, projectRoot, []);
1361
- if (routes.length > 0) return { strategy: "sveltekit-router", routes };
1362
- }
1363
- }
1364
- if (hasNuxt) {
1365
- const nuxtPagesDirs = [join6(projectRoot, "pages"), join6(projectRoot, "app", "pages")];
1366
- const routes = nuxtPagesDirs.flatMap(
1367
- (pagesDir) => existsSync6(pagesDir) ? walkPagesDir(pagesDir, projectRoot, [], /* @__PURE__ */ new Set(["vue"])) : []
1368
- );
1369
- if (routes.length > 0) return { strategy: "nuxt-router", routes };
1370
- }
1371
- const reactRouterRoutes = scanReactRouter(projectRoot);
1372
- if (reactRouterRoutes.length > 0 && hasReactRouterDependency(projectRoot)) {
1373
- return { strategy: "react-router", routes: reactRouterRoutes };
1463
+ if (!isOptedIn(projectRoot)) {
1464
+ return;
1374
1465
  }
1375
- if (hasAngular) {
1376
- const routes = scanAngularRouter(projectRoot);
1377
- if (routes.length > 0) return { strategy: "angular-router", routes };
1466
+ const identities = ensureTelemetryIdentities(projectRoot);
1467
+ if (!identities) {
1468
+ return;
1378
1469
  }
1379
- if (hasVue) {
1380
- const routes = scanVueRouter(projectRoot);
1381
- if (routes.length > 0) return { strategy: "vue-router", routes };
1470
+ const registrySource = input.registrySource ?? getRegistrySourceProperty(input.properties) ?? inferRegistrySource(input.args ?? []);
1471
+ const client = createTelemetryClient({
1472
+ sink: createFetchTelemetrySink({
1473
+ endpoint: getTelemetryEventsEndpoint(),
1474
+ timeoutMs: TELEMETRY_TIMEOUT_MS
1475
+ })
1476
+ });
1477
+ const event = {
1478
+ name: input.name,
1479
+ context: {
1480
+ source: "cli",
1481
+ actorType: getTelemetryActorType(),
1482
+ environment: "production",
1483
+ decantrVersion: getCliVersion(),
1484
+ installId: identities.installId,
1485
+ projectId: identities.projectId,
1486
+ registrySource
1487
+ },
1488
+ properties: input.properties
1489
+ };
1490
+ try {
1491
+ await client.capture(event);
1492
+ } catch {
1382
1493
  }
1383
- if (pagesRoutes.length > 0) {
1384
- return { strategy: "pages-router", routes: pagesRoutes };
1494
+ }
1495
+ async function sendCliCommandTelemetry(input) {
1496
+ const projectRoot = resolveCliTelemetryProjectRoot(input.projectRoot ?? process.cwd(), input.args);
1497
+ const command = normalizeCommand(input.args[0]);
1498
+ if (!isOptedIn(projectRoot) || !command || command === "help" || command === "version" || isHelpOrVersionProbe(input.args)) {
1499
+ return;
1385
1500
  }
1386
- if (reactRouterRoutes.length > 0) {
1387
- return { strategy: "react-router", routes: reactRouterRoutes };
1501
+ const properties = buildCliLifecycleProperties({
1502
+ args: input.args,
1503
+ command,
1504
+ durationMs: input.durationMs,
1505
+ projectRoot,
1506
+ success: input.success
1507
+ });
1508
+ await captureCliTelemetryEvent({
1509
+ args: input.args,
1510
+ name: "cli.command.completed",
1511
+ projectRoot,
1512
+ properties,
1513
+ registrySource: properties.registrySource
1514
+ });
1515
+ const lifecycleEventName = lifecycleTelemetryEventName(command);
1516
+ if (lifecycleEventName) {
1517
+ await captureCliTelemetryEvent({
1518
+ args: input.args,
1519
+ name: lifecycleEventName,
1520
+ projectRoot,
1521
+ properties,
1522
+ registrySource: properties.registrySource
1523
+ });
1388
1524
  }
1389
- return { strategy: "none", routes: [] };
1390
1525
  }
1391
-
1392
- // src/analyzers/styling.ts
1393
- import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
1394
- import { join as join7 } from "path";
1395
- var TAILWIND_CONFIGS = [
1396
- "tailwind.config.js",
1397
- "tailwind.config.ts",
1398
- "tailwind.config.mjs",
1399
- "tailwind.config.cjs"
1400
- ];
1401
- var GLOBALS_CSS_PATHS = [
1402
- "src/app/globals.css",
1403
- "app/globals.css",
1404
- "src/styles/global.css",
1405
- "src/styles/globals.css",
1406
- "src/styles/main.css",
1407
- "src/styles.css",
1408
- "styles/globals.css",
1409
- "styles.css",
1410
- "assets/css/main.css",
1411
- "src/index.css",
1412
- "src/app.css",
1413
- "src/global.css"
1414
- ];
1415
- var DECANTR_STYLE_PATHS = [
1416
- "src/styles/tokens.css",
1417
- "src/styles/treatments.css",
1418
- "src/styles/global.css"
1419
- ];
1420
- function extractCSSVariables(content) {
1421
- const colors = {};
1422
- const variables = [];
1423
- const varRegex = /--([\w-]+)\s*:\s*([^;]+)/g;
1424
- let match;
1425
- while ((match = varRegex.exec(content)) !== null) {
1426
- const name = match[1];
1427
- const value = match[2].trim();
1428
- variables.push(`--${name}`);
1429
- const colorPatterns = [
1430
- "primary",
1431
- "secondary",
1432
- "accent",
1433
- "bg",
1434
- "fg",
1435
- "border",
1436
- "success",
1437
- "warning",
1438
- "error",
1439
- "surface",
1440
- "muted"
1441
- ];
1442
- if (value.startsWith("#") || value.startsWith("rgb") || value.startsWith("hsl") || colorPatterns.some((p) => name.includes(p))) {
1443
- colors[name] = value;
1444
- }
1526
+ async function sendProjectHealthReportTelemetry(input) {
1527
+ const projectRoot = input.projectRoot ?? process.cwd();
1528
+ const properties = buildProjectHealthTelemetryProperties(input, projectRoot);
1529
+ await captureCliTelemetryEvent({
1530
+ name: "health.report.generated",
1531
+ projectRoot,
1532
+ properties
1533
+ });
1534
+ if (input.report.status === "healthy") {
1535
+ await captureCliTelemetryEvent({
1536
+ name: "decantr.health.healthy",
1537
+ projectRoot,
1538
+ properties
1539
+ });
1445
1540
  }
1446
- return { colors, variables };
1447
1541
  }
1448
- function detectDarkMode(projectRoot, cssContents) {
1449
- for (const cssContent of cssContents) {
1450
- if (cssContent.includes(".dark") || cssContent.includes('[data-theme="dark"]') || cssContent.includes("prefers-color-scheme: dark") || cssContent.includes("color-scheme: dark")) {
1451
- return true;
1542
+ async function sendAnalyzeCompletedTelemetry(input) {
1543
+ const projectRoot = input.projectRoot ?? process.cwd();
1544
+ const metadata = readProjectTelemetryMetadata(projectRoot);
1545
+ const properties = {
1546
+ command: "analyze",
1547
+ success: input.success,
1548
+ durationMs: input.durationMs,
1549
+ adoptionMode: metadata.adoptionMode ?? "contract-only",
1550
+ componentCount: input.componentCount,
1551
+ dependencyCategoryCount: input.dependencyCategoryCount,
1552
+ errorCode: input.success ? void 0 : "analyze_failed",
1553
+ pageCount: input.pageCount,
1554
+ projectScope: metadata.projectScope ?? inferProjectScope(projectRoot),
1555
+ routeCount: input.routeCount,
1556
+ targetFramework: input.targetFramework,
1557
+ workflowMode: metadata.workflowMode ?? "brownfield-attach"
1558
+ };
1559
+ await captureCliTelemetryEvent({
1560
+ args: ["analyze"],
1561
+ name: "decantr.analyze.completed",
1562
+ projectRoot,
1563
+ properties
1564
+ });
1565
+ }
1566
+ async function sendNewProjectCompletedTelemetry(input) {
1567
+ const projectRoot = input.projectRoot ?? process.cwd();
1568
+ const args = input.args ?? ["new"];
1569
+ const base = buildCliLifecycleProperties({
1570
+ args,
1571
+ command: "new",
1572
+ durationMs: input.durationMs,
1573
+ projectRoot,
1574
+ success: input.success
1575
+ });
1576
+ const properties = {
1577
+ ...base,
1578
+ command: "new"
1579
+ };
1580
+ await captureCliTelemetryEvent({
1581
+ args,
1582
+ name: "decantr.new.completed",
1583
+ projectRoot,
1584
+ properties,
1585
+ registrySource: properties.registrySource
1586
+ });
1587
+ }
1588
+ async function sendProjectHealthPromptTelemetry(input) {
1589
+ const projectRoot = input.projectRoot ?? process.cwd();
1590
+ const finding = input.finding;
1591
+ const properties = {
1592
+ success: Boolean(finding),
1593
+ findingFound: Boolean(finding),
1594
+ adoptionMode: normalizeAdoptionMode(input.report.summary.adoptionMode),
1595
+ ci: input.ci ?? false,
1596
+ findingSeverity: normalizeFindingSeverity(finding?.severity),
1597
+ findingSource: normalizeFindingSource(finding?.source),
1598
+ projectScope: inferProjectScope(projectRoot),
1599
+ workflowMode: normalizeWorkflowMode(input.report.summary.workflowMode)
1600
+ };
1601
+ await captureCliTelemetryEvent({
1602
+ name: "health.finding.prompt_requested",
1603
+ projectRoot,
1604
+ properties
1605
+ });
1606
+ }
1607
+ async function sendProjectHealthCiFailedTelemetry(input) {
1608
+ const projectRoot = input.projectRoot ?? process.cwd();
1609
+ const properties = {
1610
+ ...buildProjectHealthTelemetryProperties(input, projectRoot),
1611
+ errorCode: "project_health_ci_failed",
1612
+ failOn: input.failOn,
1613
+ success: false
1614
+ };
1615
+ await captureCliTelemetryEvent({
1616
+ name: "health.ci.failed",
1617
+ projectRoot,
1618
+ properties
1619
+ });
1620
+ }
1621
+ async function sendStudioStartedTelemetry(input) {
1622
+ const projectRoot = input.projectRoot ?? process.cwd();
1623
+ const metadata = readProjectTelemetryMetadata(projectRoot);
1624
+ const properties = {
1625
+ success: true,
1626
+ hostMode: isLoopbackHost(input.host) ? "loopback" : "custom",
1627
+ port: input.port,
1628
+ adoptionMode: metadata.adoptionMode,
1629
+ projectScope: inferProjectScope(projectRoot),
1630
+ workflowMode: metadata.workflowMode
1631
+ };
1632
+ await captureCliTelemetryEvent({
1633
+ name: "studio.started",
1634
+ projectRoot,
1635
+ properties
1636
+ });
1637
+ }
1638
+ async function sendStudioHealthRefreshedTelemetry(input) {
1639
+ const projectRoot = input.projectRoot ?? process.cwd();
1640
+ const properties = {
1641
+ ...buildProjectHealthTelemetryProperties(input, projectRoot),
1642
+ trigger: input.trigger ?? "api-refresh"
1643
+ };
1644
+ await captureCliTelemetryEvent({
1645
+ name: "studio.health_refreshed",
1646
+ projectRoot,
1647
+ properties
1648
+ });
1649
+ }
1650
+ function buildCliLifecycleProperties(input) {
1651
+ const metadata = readProjectTelemetryMetadata(input.projectRoot);
1652
+ const registrySource = inferRegistrySource(input.args);
1653
+ return {
1654
+ command: input.command,
1655
+ success: input.success,
1656
+ durationMs: input.durationMs,
1657
+ adoptionMode: inferAdoptionMode(input.args) ?? metadata.adoptionMode,
1658
+ errorCode: input.success ? void 0 : "cli_command_failed",
1659
+ offline: input.args.includes("--offline"),
1660
+ projectScope: metadata.projectScope ?? inferProjectScope(input.projectRoot),
1661
+ registrySource,
1662
+ targetFramework: inferFlagValue(input.args, "--target"),
1663
+ workflowMode: inferWorkflowMode(input.args) ?? metadata.workflowMode
1664
+ };
1665
+ }
1666
+ function lifecycleTelemetryEventName(command) {
1667
+ if (command === "check") return "decantr.check.completed";
1668
+ if (command === "init") return "decantr.init.completed";
1669
+ if (command === "refresh") return "decantr.refresh.completed";
1670
+ return null;
1671
+ }
1672
+ function buildProjectHealthTelemetryProperties(input, projectRoot) {
1673
+ const { report } = input;
1674
+ return {
1675
+ success: true,
1676
+ status: report.status,
1677
+ score: report.score,
1678
+ durationMs: input.durationMs,
1679
+ adoptionMode: normalizeAdoptionMode(report.summary.adoptionMode),
1680
+ ci: input.ci ?? false,
1681
+ errorCount: report.summary.errorCount,
1682
+ failOn: input.failOn,
1683
+ findingCount: report.summary.findingCount,
1684
+ format: input.format,
1685
+ infoCount: report.summary.infoCount,
1686
+ outputWritten: input.outputWritten ?? false,
1687
+ packManifestPresent: report.summary.packManifestPresent,
1688
+ pageCount: report.summary.pageCount,
1689
+ projectScope: inferProjectScope(projectRoot),
1690
+ reviewPackPresent: report.summary.reviewPackPresent,
1691
+ routeCount: report.routes.declared.length,
1692
+ runtimeAuditChecked: report.summary.runtimeAuditChecked,
1693
+ runtimeMatchedCount: report.routes.runtimeMatched,
1694
+ runtimePassed: report.summary.runtimePassed,
1695
+ runtimeRouteCheckedCount: report.routes.runtimeChecked.length,
1696
+ warnCount: report.summary.warnCount,
1697
+ workflowMode: normalizeWorkflowMode(report.summary.workflowMode)
1698
+ };
1699
+ }
1700
+ function collectMetrics(essence, issues) {
1701
+ const dna = essence.dna ?? {};
1702
+ const blueprint = essence.blueprint ?? {};
1703
+ const meta = essence.meta ?? {};
1704
+ const guard = meta.guard ?? {};
1705
+ const theme = dna.theme ?? {};
1706
+ const sections = blueprint.sections ?? [];
1707
+ const routes = blueprint.routes ?? {};
1708
+ const byRule = {};
1709
+ let dnaCount = 0;
1710
+ let blueprintCount = 0;
1711
+ for (const issue of issues) {
1712
+ byRule[issue.rule] = (byRule[issue.rule] ?? 0) + 1;
1713
+ if (DNA_RULES.has(issue.rule)) {
1714
+ dnaCount++;
1715
+ } else {
1716
+ blueprintCount++;
1452
1717
  }
1453
1718
  }
1454
- const layoutPaths = [
1455
- "src/app/layout.tsx",
1456
- "app/layout.tsx",
1457
- "src/app/layout.jsx",
1458
- "app/layout.jsx"
1459
- ];
1460
- for (const rel of layoutPaths) {
1461
- const fullPath = join7(projectRoot, rel);
1462
- if (existsSync7(fullPath)) {
1463
- try {
1464
- const layoutContent = readFileSync7(fullPath, "utf-8");
1465
- if (layoutContent.includes('className="dark"') || layoutContent.includes("className='dark'") || layoutContent.includes('class="dark"')) {
1466
- return true;
1467
- }
1468
- } catch {
1469
- }
1470
- }
1719
+ return {
1720
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1721
+ cli_version: getCliVersion(),
1722
+ essence_version: essence.version ?? "unknown",
1723
+ guard_mode: guard.mode ?? "unknown",
1724
+ violations: {
1725
+ dna: dnaCount,
1726
+ blueprint: blueprintCount,
1727
+ by_rule: byRule
1728
+ },
1729
+ resolution_rate: 0,
1730
+ sections_count: sections.length,
1731
+ routes_count: Object.keys(routes).length,
1732
+ theme: theme.id ?? "unknown"
1733
+ };
1734
+ }
1735
+ function getCliTelemetryIdentityStatus(projectRoot, options = {}) {
1736
+ const projectJsonPath = join9(projectRoot, ".decantr", "project.json");
1737
+ const hasProjectConfig = existsSync9(projectJsonPath);
1738
+ const identities = options.create ? ensureTelemetryIdentities(projectRoot) : null;
1739
+ const projectData = readProjectJson2(projectRoot);
1740
+ return {
1741
+ enabled: projectData?.telemetry === true,
1742
+ hasProjectConfig,
1743
+ installId: identities?.installId ?? readExistingInstallId(),
1744
+ projectId: identities?.projectId ?? readStringProperty(projectData, "telemetryProjectId"),
1745
+ projectRoot
1746
+ };
1747
+ }
1748
+ function ensureTelemetryIdentities(projectRoot) {
1749
+ const installId = getOrCreateInstallId();
1750
+ const projectJsonPath = join9(projectRoot, ".decantr", "project.json");
1751
+ if (!existsSync9(projectJsonPath)) {
1752
+ return null;
1471
1753
  }
1472
- const pkgPath = join7(projectRoot, "package.json");
1473
- if (existsSync7(pkgPath)) {
1474
- try {
1475
- const pkg = JSON.parse(readFileSync7(pkgPath, "utf-8"));
1476
- const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
1477
- if (allDeps["next-themes"] || allDeps["theme-toggle"] || allDeps["use-dark-mode"]) {
1478
- return true;
1479
- }
1480
- } catch {
1754
+ try {
1755
+ const data = JSON.parse(readFileSync9(projectJsonPath, "utf-8"));
1756
+ let projectId = typeof data.telemetryProjectId === "string" ? data.telemetryProjectId : void 0;
1757
+ if (!projectId) {
1758
+ projectId = `project_${randomUUID()}`;
1759
+ data.telemetryProjectId = projectId;
1760
+ writeFileSync2(projectJsonPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
1481
1761
  }
1762
+ return { installId, projectId };
1763
+ } catch {
1764
+ return null;
1482
1765
  }
1483
- const essencePath = join7(projectRoot, "decantr.essence.json");
1484
- if (existsSync7(essencePath)) {
1485
- try {
1486
- const essence = JSON.parse(readFileSync7(essencePath, "utf-8"));
1487
- const mode = essence.dna?.theme?.mode;
1488
- if (mode === "dark" || mode === "auto") {
1489
- return true;
1766
+ }
1767
+ function getOrCreateInstallId() {
1768
+ const configDir = getConfigDir();
1769
+ const configPath = join9(configDir, "config.json");
1770
+ try {
1771
+ if (existsSync9(configPath)) {
1772
+ const data = JSON.parse(readFileSync9(configPath, "utf-8"));
1773
+ if (typeof data.telemetryInstallId === "string") {
1774
+ return data.telemetryInstallId;
1490
1775
  }
1491
- } catch {
1776
+ const installId2 = `install_${randomUUID()}`;
1777
+ data.telemetryInstallId = installId2;
1778
+ writeFileSync2(configPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
1779
+ return installId2;
1492
1780
  }
1781
+ mkdirSync(configDir, { recursive: true });
1782
+ const installId = `install_${randomUUID()}`;
1783
+ writeFileSync2(
1784
+ configPath,
1785
+ JSON.stringify({ telemetryInstallId: installId }, null, 2) + "\n",
1786
+ "utf-8"
1787
+ );
1788
+ return installId;
1789
+ } catch {
1790
+ return `install_${randomUUID()}`;
1493
1791
  }
1494
- return false;
1495
1792
  }
1496
- function scanStyling(projectRoot) {
1497
- let approach = "unknown";
1498
- let configFile;
1499
- let packageDeps = {};
1500
- const pkgPath = join7(projectRoot, "package.json");
1501
- if (existsSync7(pkgPath)) {
1502
- try {
1503
- const pkg = JSON.parse(readFileSync7(pkgPath, "utf-8"));
1504
- packageDeps = { ...pkg.dependencies, ...pkg.devDependencies };
1505
- } catch {
1506
- packageDeps = {};
1507
- }
1793
+ function readExistingInstallId() {
1794
+ const configPath = join9(getConfigDir(), "config.json");
1795
+ if (!existsSync9(configPath)) return void 0;
1796
+ try {
1797
+ const data = JSON.parse(readFileSync9(configPath, "utf-8"));
1798
+ return readStringProperty(data, "telemetryInstallId");
1799
+ } catch {
1800
+ return void 0;
1508
1801
  }
1509
- for (const cfg of TAILWIND_CONFIGS) {
1510
- if (existsSync7(join7(projectRoot, cfg))) {
1511
- approach = "tailwind";
1512
- configFile = cfg;
1513
- break;
1514
- }
1802
+ }
1803
+ function getConfigDir() {
1804
+ return process.env.DECANTR_CONFIG_DIR || join9(homedir(), ".config", "decantr");
1805
+ }
1806
+ function getTelemetryEventsEndpoint() {
1807
+ return process.env.DECANTR_TELEMETRY_ENDPOINT || DEFAULT_TELEMETRY_EVENTS_ENDPOINT;
1808
+ }
1809
+ function getTelemetryActorType() {
1810
+ const configured = process.env.DECANTR_TELEMETRY_ACTOR_TYPE;
1811
+ return isTelemetryActorType(configured) ? configured : "customer";
1812
+ }
1813
+ function getRegistrySourceProperty(properties) {
1814
+ const value = properties.registrySource;
1815
+ return isRegistrySource(value) ? value : void 0;
1816
+ }
1817
+ function readProjectTelemetryMetadata(projectRoot) {
1818
+ const data = readProjectJson2(projectRoot);
1819
+ const initialized = isRecord(data?.initialized) ? data.initialized : void 0;
1820
+ return {
1821
+ adoptionMode: normalizeAdoptionMode(initialized?.adoptionMode),
1822
+ projectScope: normalizeProjectScope(initialized?.projectScope),
1823
+ workflowMode: normalizeWorkflowMode(initialized?.workflowMode)
1824
+ };
1825
+ }
1826
+ function resolveCliTelemetryProjectRoot(projectRoot, args) {
1827
+ const projectFlag = inferFlagValue(args, "--project");
1828
+ if (!projectFlag) return projectRoot;
1829
+ const candidate = resolve(projectRoot, projectFlag);
1830
+ return existsSync9(join9(candidate, ".decantr", "project.json")) ? candidate : projectRoot;
1831
+ }
1832
+ function readProjectJson2(projectRoot) {
1833
+ const projectJsonPath = join9(projectRoot, ".decantr", "project.json");
1834
+ if (!existsSync9(projectJsonPath)) return null;
1835
+ try {
1836
+ return JSON.parse(readFileSync9(projectJsonPath, "utf-8"));
1837
+ } catch {
1838
+ return null;
1515
1839
  }
1516
- if (approach === "unknown") {
1517
- if (packageDeps["@decantr/css"]) {
1518
- approach = "decantr-css";
1519
- configFile = "src/styles/tokens.css";
1520
- }
1521
- if (packageDeps.tailwindcss || packageDeps["@tailwindcss/postcss"] || packageDeps["@tailwindcss/vite"]) {
1522
- approach = "tailwind";
1523
- configFile = configFile ?? "package.json";
1524
- }
1840
+ }
1841
+ function readStringProperty(value, key) {
1842
+ const property = value?.[key];
1843
+ return typeof property === "string" && property.trim() ? property : void 0;
1844
+ }
1845
+ function isRecord(value) {
1846
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1847
+ }
1848
+ function normalizeCommand(command) {
1849
+ if (!command) return null;
1850
+ if (command === "--help" || command === "-h") return "help";
1851
+ if (command === "--version" || command === "-v") return "version";
1852
+ return command;
1853
+ }
1854
+ function isHelpOrVersionProbe(args) {
1855
+ if (args.some((arg) => arg === "--help" || arg === "-h")) return true;
1856
+ if (args[1] === "help") return true;
1857
+ return false;
1858
+ }
1859
+ function inferFlagValue(args, flag) {
1860
+ const equalsPrefix = `${flag}=`;
1861
+ const inline = args.find((arg) => arg.startsWith(equalsPrefix));
1862
+ if (inline) {
1863
+ return inline.slice(equalsPrefix.length) || void 0;
1525
1864
  }
1526
- if (approach === "unknown") {
1527
- if (packageDeps.bootstrap || packageDeps["react-bootstrap"]) {
1528
- approach = "bootstrap";
1529
- configFile = "package.json";
1530
- } else if (packageDeps["@mui/material"] || packageDeps["@mui/system"] || packageDeps["@mui/joy"]) {
1531
- approach = "mui";
1532
- configFile = "package.json";
1533
- } else if (packageDeps["@chakra-ui/react"] || packageDeps["@chakra-ui/vue-next"] || packageDeps["@chakra-ui/system"]) {
1534
- approach = "chakra";
1535
- configFile = "package.json";
1536
- }
1865
+ const index = args.indexOf(flag);
1866
+ if (index !== -1 && args[index + 1] && !args[index + 1].startsWith("-")) {
1867
+ return args[index + 1];
1537
1868
  }
1538
- const decantrStyleFiles = DECANTR_STYLE_PATHS.filter((rel) => existsSync7(join7(projectRoot, rel)));
1539
- if (decantrStyleFiles.length >= 2) {
1540
- approach = "decantr-css";
1541
- configFile = decantrStyleFiles.join(" + ");
1869
+ return void 0;
1870
+ }
1871
+ function inferAdoptionMode(args) {
1872
+ const value = inferFlagValue(args, "--adoption");
1873
+ return normalizeAdoptionMode(value);
1874
+ }
1875
+ function inferWorkflowMode(args) {
1876
+ const value = inferFlagValue(args, "--workflow");
1877
+ return normalizeWorkflowMode(value);
1878
+ }
1879
+ function normalizeAdoptionMode(value) {
1880
+ if (value === "contract-only" || value === "decantr-css" || value === "style-bridge") {
1881
+ return value;
1542
1882
  }
1543
- const cssContents = [];
1544
- for (const rel of GLOBALS_CSS_PATHS) {
1545
- const fullPath = join7(projectRoot, rel);
1546
- if (existsSync7(fullPath)) {
1547
- try {
1548
- cssContents.push(readFileSync7(fullPath, "utf-8"));
1549
- } catch {
1550
- }
1551
- }
1883
+ return void 0;
1884
+ }
1885
+ function normalizeWorkflowMode(value) {
1886
+ if (value === "greenfield" || value === "greenfield-scaffold") {
1887
+ return "greenfield-scaffold";
1552
1888
  }
1553
- for (const rel of DECANTR_STYLE_PATHS) {
1554
- if (GLOBALS_CSS_PATHS.includes(rel)) continue;
1555
- const fullPath = join7(projectRoot, rel);
1556
- if (existsSync7(fullPath)) {
1557
- try {
1558
- cssContents.push(readFileSync7(fullPath, "utf-8"));
1559
- } catch {
1560
- }
1561
- }
1889
+ if (value === "contract" || value === "greenfield-contract-only") {
1890
+ return "greenfield-contract-only";
1562
1891
  }
1563
- let colors = {};
1564
- let cssVariables = [];
1565
- for (const cssContent of cssContents) {
1566
- const extracted = extractCSSVariables(cssContent);
1567
- colors = { ...colors, ...extracted.colors };
1568
- cssVariables.push(...extracted.variables);
1892
+ if (value === "brownfield" || value === "brownfield-attach") {
1893
+ return "brownfield-attach";
1569
1894
  }
1570
- cssVariables = [...new Set(cssVariables)];
1571
- const darkMode = detectDarkMode(projectRoot, cssContents);
1572
- if (approach === "unknown" && cssContents.length > 0) {
1573
- approach = "css";
1574
- configFile = GLOBALS_CSS_PATHS.find((rel) => existsSync7(join7(projectRoot, rel)));
1895
+ if (value === "hybrid" || value === "hybrid-compose") {
1896
+ return "hybrid-compose";
1575
1897
  }
1576
- return {
1577
- approach,
1578
- configFile,
1579
- colors,
1580
- darkMode,
1581
- cssVariables
1582
- };
1898
+ return void 0;
1583
1899
  }
1584
-
1585
- // src/doctrine-map.ts
1586
- import { existsSync as existsSync8, readFileSync as readFileSync8, writeFileSync as writeFileSync2 } from "fs";
1587
- import { join as join8 } from "path";
1588
- var PRECEDENCE_ORDER = [
1589
- "security-data",
1590
- "architecture",
1591
- "design-system",
1592
- "workflow-ci",
1593
- "feature-business",
1594
- "assistant-specific",
1595
- "stale-or-historical",
1596
- "unknown"
1597
- ];
1598
- var BASE_PRECEDENCE = {
1599
- "security-data": 100,
1600
- architecture: 88,
1601
- "design-system": 82,
1602
- "workflow-ci": 74,
1603
- "feature-business": 66,
1604
- "assistant-specific": 58,
1605
- "stale-or-historical": 24,
1606
- unknown: 12
1607
- };
1608
- function normalized(path) {
1609
- return path.toLowerCase();
1900
+ function normalizeProjectScope(value) {
1901
+ if (value === "single-app" || value === "workspace-app") return value;
1902
+ return void 0;
1610
1903
  }
1611
- function isStalePath(path, staleRisks) {
1612
- const lower = normalized(path);
1613
- if (/complete|summary|legacy|deprecated/.test(lower)) return true;
1614
- return staleRisks.some((risk) => risk.toLowerCase().startsWith(lower));
1904
+ function normalizeFindingSeverity(value) {
1905
+ if (value === "error" || value === "info" || value === "warn") return value;
1906
+ return void 0;
1615
1907
  }
1616
- function inferArea(item) {
1617
- const lower = normalized(item.path);
1618
- if (lower.includes("security") || lower.includes("auth") || lower.includes("rls") || lower.includes("schema") || lower.includes("database") || lower.includes("data-layer") || lower.includes("middleware") || lower.startsWith("supabase/") || lower.startsWith("migrations/") || lower.startsWith("rolemigrations/")) {
1619
- return "security-data";
1620
- }
1621
- if (lower.includes("design-system") || lower.includes("ui-components") || lower.includes("colors") || lower.includes("typography") || lower.includes("spacing") || lower.includes("components.json") || lower.includes("tailwind.config")) {
1622
- return "design-system";
1908
+ function normalizeFindingSource(value) {
1909
+ if (value === "audit" || value === "brownfield" || value === "check" || value === "interaction" || value === "pack" || value === "runtime") {
1910
+ return value;
1623
1911
  }
1624
- if (lower.includes("architecture") || lower.includes("state-management") || lower.includes("setup") || lower.includes("readme") || lower.endsWith("config.ts") || lower.endsWith("config.js") || lower.endsWith("config.mjs")) {
1625
- return "architecture";
1912
+ return void 0;
1913
+ }
1914
+ function inferRegistrySource(args) {
1915
+ if (args.includes("--offline")) {
1916
+ return "cache";
1626
1917
  }
1627
- if (lower.includes("workflow") || lower.includes("deployment") || lower.includes("quality") || lower.includes("testing") || lower.includes("vitest") || lower.includes("playwright") || lower.startsWith(".github/workflows/")) {
1628
- return "workflow-ci";
1918
+ if (args.some((arg) => arg === "--registry" || arg.startsWith("--registry="))) {
1919
+ return "custom";
1629
1920
  }
1630
- return item.role;
1921
+ return "official";
1631
1922
  }
1632
- function precedenceFor(item, area, staleRisks) {
1633
- let score = BASE_PRECEDENCE[area];
1634
- const lower = normalized(item.path);
1635
- if (lower.startsWith(".claude/rules/") || lower.startsWith(".cursor/rules/")) score += 6;
1636
- if (lower === "claude.md" || lower === "agents.md" || lower === "copilot-instructions.md") {
1637
- score += 3;
1638
- }
1639
- if (item.type === "directory") score -= 8;
1640
- if (isStalePath(item.path, staleRisks)) score -= 35;
1641
- if (!item.safeToCite) score -= 20;
1642
- return Math.max(0, Math.min(100, score));
1923
+ function isRegistrySource(value) {
1924
+ return value === "cache" || value === "custom" || value === "none" || value === "official" || value === "private";
1643
1925
  }
1644
- function summarize2(sources) {
1645
- const summary = Object.fromEntries(PRECEDENCE_ORDER.map((area) => [area, 0]));
1646
- for (const source of sources) summary[source.area] += 1;
1647
- return summary;
1926
+ function inferProjectScope(projectRoot) {
1927
+ return existsSync9(join9(projectRoot, "pnpm-workspace.yaml")) || existsSync9(join9(projectRoot, "turbo.json")) || existsSync9(join9(projectRoot, "lerna.json")) ? "workspace-app" : "single-app";
1648
1928
  }
1649
- function topSources(sources, areas, limit = 5) {
1650
- return sources.filter((source) => source.currency === "current" && areas.includes(source.area)).slice(0, limit).map((source) => source.path);
1929
+ function getCliVersion() {
1930
+ try {
1931
+ const here = dirname2(fileURLToPath2(import.meta.url));
1932
+ const candidates = [join9(here, "..", "package.json"), join9(here, "..", "..", "package.json")];
1933
+ for (const candidate of candidates) {
1934
+ if (existsSync9(candidate)) {
1935
+ const pkg = JSON.parse(readFileSync9(candidate, "utf-8"));
1936
+ if (pkg.version) {
1937
+ return pkg.version;
1938
+ }
1939
+ }
1940
+ }
1941
+ } catch {
1942
+ }
1943
+ return "unknown";
1651
1944
  }
1652
- function buildResolutions(conflicts, staleRisks, sources) {
1653
- const resolutions = [];
1654
- for (const conflict of conflicts) {
1655
- const lower = conflict.toLowerCase();
1656
- if (lower.includes("framework")) {
1657
- resolutions.push({
1658
- kind: "conflict",
1659
- issue: conflict,
1660
- recommendation: "Prefer package/config detection and current architecture sources over stale docs or assistant memory when deciding framework/runtime conventions.",
1661
- preferredSources: topSources(sources, ["architecture", "workflow-ci"]),
1662
- confidence: 0.78
1945
+ function isLoopbackHost(host) {
1946
+ return host === "127.0.0.1" || host === "localhost" || host === "::1";
1947
+ }
1948
+
1949
+ // src/commands/heal.ts
1950
+ var GREEN = "\x1B[32m";
1951
+ var RED = "\x1B[31m";
1952
+ var YELLOW = "\x1B[33m";
1953
+ var CYAN = "\x1B[36m";
1954
+ var RESET = "\x1B[0m";
1955
+ var DIM = "\x1B[2m";
1956
+ function collectCheckIssues(projectRoot = process.cwd(), options = {}) {
1957
+ const essencePath = join10(projectRoot, "decantr.essence.json");
1958
+ if (!existsSync10(essencePath)) {
1959
+ return {
1960
+ essence: null,
1961
+ issues: [
1962
+ {
1963
+ type: "error",
1964
+ rule: "essence-missing",
1965
+ message: "No decantr.essence.json found. Run `decantr init` first."
1966
+ }
1967
+ ],
1968
+ missingEssence: true
1969
+ };
1970
+ }
1971
+ const essence = JSON.parse(readFileSync10(essencePath, "utf-8"));
1972
+ const issues = [];
1973
+ const validation = validateEssence(essence);
1974
+ if (!validation.valid) {
1975
+ for (const err of validation.errors) {
1976
+ issues.push({
1977
+ type: "error",
1978
+ rule: "schema",
1979
+ message: err
1663
1980
  });
1664
- continue;
1665
1981
  }
1666
- if (lower.includes("tailwind")) {
1667
- resolutions.push({
1668
- kind: "conflict",
1669
- issue: conflict,
1670
- recommendation: "Preserve the existing styling system until the user approves migration; treat current design-system docs and Tailwind/shadcn config as the styling authority.",
1671
- preferredSources: topSources(sources, ["design-system", "architecture"]),
1672
- confidence: 0.82
1982
+ return { essence, issues, missingEssence: false };
1983
+ }
1984
+ let interactionIssues = [];
1985
+ try {
1986
+ interactionIssues = scanProjectInteractions(projectRoot);
1987
+ } catch {
1988
+ }
1989
+ try {
1990
+ const guardContext = buildGuardRegistryContext(projectRoot);
1991
+ const violations = evaluateGuard(essence, {
1992
+ ...guardContext,
1993
+ interaction_issues: interactionIssues
1994
+ });
1995
+ for (const v of violations) {
1996
+ issues.push({
1997
+ type: v.severity === "error" ? "error" : "warning",
1998
+ rule: v.rule,
1999
+ message: v.message,
2000
+ suggestion: v.suggestion
1673
2001
  });
1674
- continue;
1675
2002
  }
1676
- if (lower.includes("client") && lower.includes("server")) {
1677
- resolutions.push({
1678
- kind: "conflict",
1679
- issue: conflict,
1680
- recommendation: "Prefer current framework architecture and security/data boundaries; stop and ask for review before moving client/server responsibilities.",
1681
- preferredSources: topSources(sources, ["architecture", "security-data"]),
1682
- confidence: 0.76
2003
+ } catch {
2004
+ }
2005
+ if (options.brownfield) {
2006
+ try {
2007
+ if (isV4(essence)) {
2008
+ const brownfieldIssues = scanBrownfieldIssues(projectRoot, essence);
2009
+ issues.push(...brownfieldIssues);
2010
+ } else {
2011
+ issues.push({
2012
+ type: "warning",
2013
+ rule: "brownfield-check",
2014
+ message: "Brownfield checks require an Essence v4.0.0 Decantr contract."
2015
+ });
2016
+ }
2017
+ } catch (e) {
2018
+ issues.push({
2019
+ type: "warning",
2020
+ rule: "brownfield-check",
2021
+ message: `Brownfield check could not complete: ${e.message}`
1683
2022
  });
1684
- continue;
1685
2023
  }
1686
- resolutions.push({
1687
- kind: "conflict",
1688
- issue: conflict,
1689
- recommendation: "Use the highest-precedence current sources in the doctrine map and report the conflict before enforcing either side.",
1690
- preferredSources: sources.filter((source) => source.currency === "current").slice(0, 5).map((source) => source.path),
1691
- confidence: 0.62
1692
- });
1693
2024
  }
1694
- if (staleRisks.length > 0) {
1695
- resolutions.push({
1696
- kind: "stale-risk",
1697
- issue: `${staleRisks.length} stale or historical source(s) detected.`,
1698
- recommendation: "Treat stale-risk sources as historical evidence until confirmed by current security/data, architecture, design-system, workflow, or feature doctrine.",
1699
- preferredSources: sources.filter((source) => source.currency === "current" && source.area !== "assistant-specific").slice(0, 5).map((source) => source.path),
1700
- confidence: 0.84
1701
- });
1702
- }
1703
- return resolutions;
1704
- }
1705
- function createDoctrineMap(ambient) {
1706
- const sources = ambient.items.map((item) => {
1707
- const area = inferArea(item);
1708
- const stale = isStalePath(item.path, ambient.staleRisks);
1709
- return {
1710
- path: item.path,
1711
- type: item.type,
1712
- area: stale ? "stale-or-historical" : area,
1713
- originalRole: item.role,
1714
- precedence: precedenceFor(item, area, ambient.staleRisks),
1715
- confidence: item.confidence,
1716
- currency: !item.safeToCite ? "unsafe-to-cite" : stale ? "stale-risk" : "current",
1717
- safeToCite: item.safeToCite,
1718
- rationale: item.reason
1719
- };
1720
- }).sort((a, b) => b.precedence - a.precedence || a.path.localeCompare(b.path));
1721
- return {
1722
- version: 1,
1723
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1724
- precedenceOrder: PRECEDENCE_ORDER,
1725
- sources,
1726
- summary: summarize2(sources),
1727
- conflicts: ambient.conflicts,
1728
- staleRisks: ambient.staleRisks,
1729
- resolutions: buildResolutions(ambient.conflicts, ambient.staleRisks, sources),
1730
- guidance: [
1731
- "Treat security/data doctrine as highest precedence for implementation safety.",
1732
- "Treat architecture and design-system sources as product conventions, not Decantr defaults.",
1733
- "Treat workflow/CI sources as validation evidence for commands and release gates.",
1734
- "Treat stale-risk sources as historical evidence until a current source confirms them.",
1735
- "Do not cite unsafe sources directly in assistant context."
1736
- ]
1737
- };
2025
+ return { essence, issues, missingEssence: false };
1738
2026
  }
1739
- function doctrineMapPath(projectRoot) {
1740
- return join8(projectRoot, ".decantr", "doctrine-map.json");
1741
- }
1742
- function writeDoctrineMap(projectRoot, doctrine) {
1743
- writeFileSync2(doctrineMapPath(projectRoot), JSON.stringify(doctrine, null, 2) + "\n", "utf-8");
2027
+ async function cmdHeal(projectRoot = process.cwd(), options = {}) {
2028
+ const result = collectCheckIssues(projectRoot, options);
2029
+ console.log("Scanning for issues...\n");
2030
+ if (result.missingEssence) {
2031
+ console.error(result.issues[0]?.message ?? "No decantr.essence.json found.");
2032
+ process.exitCode = 1;
2033
+ return;
2034
+ }
2035
+ const issues = result.issues;
2036
+ const essence = result.essence ?? {};
2037
+ if (issues.length === 0) {
2038
+ console.log(`${GREEN}No issues found. Project is healthy.${RESET}`);
2039
+ await maybeSendTelemetry(projectRoot, essence, issues, options);
2040
+ return;
2041
+ }
2042
+ console.log(`Found ${issues.length} issue(s):
2043
+ `);
2044
+ for (const issue of issues) {
2045
+ const icon = issue.type === "error" ? `${RED}x${RESET}` : `${YELLOW}!${RESET}`;
2046
+ console.log(`${icon} [${issue.rule}] ${issue.message}`);
2047
+ if (issue.suggestion) {
2048
+ console.log(` ${DIM}Suggestion: ${issue.suggestion}${RESET}`);
2049
+ }
2050
+ }
2051
+ console.log(`
2052
+ ${YELLOW}Manual fixes required. Review the issues above.${RESET}`);
2053
+ const hasError = issues.some((i) => i.type === "error");
2054
+ if (hasError) {
2055
+ process.exitCode = 1;
2056
+ }
2057
+ await maybeSendTelemetry(projectRoot, essence, issues, options);
1744
2058
  }
1745
- function readDoctrineMap(projectRoot) {
1746
- const path = doctrineMapPath(projectRoot);
1747
- if (!existsSync8(path)) return null;
1748
- try {
1749
- const parsed = JSON.parse(readFileSync8(path, "utf-8"));
1750
- if (parsed.version !== 1 || !Array.isArray(parsed.sources)) return null;
1751
- return parsed;
1752
- } catch {
1753
- return null;
2059
+ async function maybeSendTelemetry(projectRoot, essence, issues, options) {
2060
+ if (options.telemetry && !isOptedIn(projectRoot)) {
2061
+ optIn(projectRoot);
2062
+ console.log(
2063
+ `
2064
+ ${CYAN}Telemetry enabled.${RESET} Decantr will send privacy-filtered CLI product telemetry for this project.`
2065
+ );
2066
+ console.log(`${DIM}Set "telemetry": false in .decantr/project.json to opt out.${RESET}`);
2067
+ }
2068
+ if (isOptedIn(projectRoot)) {
2069
+ const metrics = collectMetrics(essence, issues);
2070
+ sendGuardMetrics(metrics);
1754
2071
  }
1755
2072
  }
1756
2073
 
@@ -1762,8 +2079,6 @@ export {
1762
2079
  scanStyling,
1763
2080
  createDoctrineMap,
1764
2081
  writeDoctrineMap,
1765
- readDoctrineMap,
1766
- sendGuardMetrics,
1767
2082
  isOptedIn,
1768
2083
  optIn,
1769
2084
  sendCliCommandTelemetry,
@@ -1774,8 +2089,9 @@ export {
1774
2089
  sendProjectHealthCiFailedTelemetry,
1775
2090
  sendStudioStartedTelemetry,
1776
2091
  sendStudioHealthRefreshedTelemetry,
1777
- collectMetrics,
1778
2092
  getCliTelemetryIdentityStatus,
1779
2093
  buildGuardRegistryContext,
1780
- scanProjectInteractions
2094
+ scanProjectInteractions,
2095
+ collectCheckIssues,
2096
+ cmdHeal
1781
2097
  };