@clue-ai/cli 0.0.5 → 0.0.7

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,8 +1,9 @@
1
1
  import { access, readFile } from "node:fs/promises";
2
2
  import { join, relative, resolve } from "node:path";
3
+ import { validateSemanticCiRequest } from "./contracts.mjs";
3
4
  import {
4
- findLifecycleCallApiNames,
5
- findLifecycleGuardViolations,
5
+ findLifecycleCallApiNames,
6
+ findLifecycleGuardViolations,
6
7
  } from "./lifecycle-guard.mjs";
7
8
  import { listAllowedSourceFiles } from "./path-policy.mjs";
8
9
  import { runSemanticInventory } from "./semantic-ci.mjs";
@@ -10,426 +11,452 @@ import { runSemanticInventory } from "./semantic-ci.mjs";
10
11
  const DEFAULT_WORKFLOW_PATH = ".github/workflows/clue-semantic-snapshot.yml";
11
12
 
12
13
  const disallowedWorkflowMetadataPattern =
13
- /github\.(actor|triggering_actor|repository_owner)|github\.event\.sender|github\.event\.repository\.name|"default_branch"\s*:/;
14
+ /github\.(actor|triggering_actor|repository_owner)|github\.event\.sender|github\.event\.repository\.name|"default_branch"\s*:/;
14
15
  const SETUP_SKILLS = [
15
- "clue-setup-orchestrator",
16
- "clue-route-semantic-snapshot",
17
- "clue-semantic-ci",
18
- "clue-sdk-instrumentation",
19
- "clue-setup-audit",
20
- "clue-local-verification",
21
- "clue-setup-report",
16
+ "clue-setup-orchestrator",
17
+ "clue-route-semantic-snapshot",
18
+ "clue-semantic-gen",
19
+ "clue-sdk-instrumentation",
20
+ "clue-setup-audit",
21
+ "clue-local-verification",
22
+ "clue-setup-report",
22
23
  ];
23
24
  const TARGET_SKILL_ROOTS = {
24
- codex: [".agents", "skills"],
25
- claude_code: [".claude", "skills"],
25
+ codex: [".agents", "skills"],
26
+ claude_code: [".claude", "skills"],
26
27
  };
27
28
  const SOURCE_EXTENSIONS = [".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
28
29
  const REQUIRED_LIFECYCLE_APIS = [
29
- "ClueInit",
30
- "ClueIdentify",
31
- "ClueSetAccount",
32
- "ClueLogout",
30
+ "ClueInit",
31
+ "ClueIdentify",
32
+ "ClueSetAccount",
33
+ "ClueLogout",
33
34
  ];
35
+
36
+ const semanticRequestFromWorkflow = (workflow) => {
37
+ const match = workflow.match(
38
+ /CLUE_SEMANTIC_REQUEST_JSON: \|\n([\s\S]*?)\n\s*run: \|/,
39
+ );
40
+ if (!match) {
41
+ return null;
42
+ }
43
+ const requestJson = match[1]
44
+ .split("\n")
45
+ .map((line) => line.replace(/^ {12}/, ""))
46
+ .join("\n");
47
+ try {
48
+ return validateSemanticCiRequest(JSON.parse(requestJson));
49
+ } catch {
50
+ return null;
51
+ }
52
+ };
34
53
  const BACKEND_SDK_MARKERS = [
35
- "clue-fastapi-sdk",
36
- "clue-django-sdk",
37
- "clue-python-sdk-core",
38
- "clue_fastapi_sdk",
39
- "clue_django_sdk",
40
- "clue_python_sdk_core",
54
+ "clue-fastapi-sdk",
55
+ "clue-django-sdk",
56
+ "clue-python-sdk-core",
57
+ "clue_fastapi_sdk",
58
+ "clue_django_sdk",
59
+ "clue_python_sdk_core",
41
60
  ];
42
61
  const DEPENDENCY_FILE_CANDIDATES = [
43
- "package.json",
44
- "pnpm-lock.yaml",
45
- "package-lock.json",
46
- "yarn.lock",
47
- "requirements.txt",
48
- "requirements-dev.txt",
49
- "pyproject.toml",
50
- "poetry.lock",
51
- "Pipfile",
62
+ "package.json",
63
+ "pnpm-lock.yaml",
64
+ "package-lock.json",
65
+ "yarn.lock",
66
+ "requirements.txt",
67
+ "requirements-dev.txt",
68
+ "pyproject.toml",
69
+ "poetry.lock",
70
+ "Pipfile",
52
71
  ];
53
72
  const exists = async (path) => {
54
- try {
55
- await access(path);
56
- return true;
57
- } catch {
58
- return false;
59
- }
73
+ try {
74
+ await access(path);
75
+ return true;
76
+ } catch {
77
+ return false;
78
+ }
60
79
  };
61
80
 
62
81
  const normalizeTarget = (target) => {
63
- if (typeof target !== "string" || target.trim() === "") return undefined;
64
- const normalized = target
65
- .trim()
66
- .toLowerCase()
67
- .replace(/[\s-]+/g, "_");
68
- if (!TARGET_SKILL_ROOTS[normalized]) {
69
- throw new Error("target must be codex or claude_code");
70
- }
71
- return normalized;
82
+ if (typeof target !== "string" || target.trim() === "") return undefined;
83
+ const normalized = target
84
+ .trim()
85
+ .toLowerCase()
86
+ .replace(/[\s-]+/g, "_");
87
+ if (!TARGET_SKILL_ROOTS[normalized]) {
88
+ throw new Error("target must be codex or claude_code");
89
+ }
90
+ return normalized;
72
91
  };
73
92
 
74
93
  const addCheck = (checks, id, passed, summary, details = {}) => {
75
- checks.push({ id, passed, summary, ...details });
94
+ checks.push({ id, passed, summary, ...details });
76
95
  };
77
96
 
78
97
  const readAllowedSourceText = async ({
79
- repoRoot,
80
- allowedSourcePaths,
81
- excludedSourcePaths,
98
+ repoRoot,
99
+ allowedSourcePaths,
100
+ excludedSourcePaths,
82
101
  }) => {
83
- const files = await listAllowedSourceFiles({
84
- repoRoot,
85
- allowedSourcePaths,
86
- excludedSourcePaths,
87
- extensions: SOURCE_EXTENSIONS,
88
- });
89
- const sources = [];
90
- for (const absolutePath of files) {
91
- sources.push({
92
- file_path: relative(resolve(repoRoot), absolutePath),
93
- text: await readFile(absolutePath, "utf8"),
94
- });
95
- }
96
- return sources;
102
+ const files = await listAllowedSourceFiles({
103
+ repoRoot,
104
+ allowedSourcePaths,
105
+ excludedSourcePaths,
106
+ extensions: SOURCE_EXTENSIONS,
107
+ });
108
+ const sources = [];
109
+ for (const absolutePath of files) {
110
+ sources.push({
111
+ file_path: relative(resolve(repoRoot), absolutePath),
112
+ text: await readFile(absolutePath, "utf8"),
113
+ });
114
+ }
115
+ return sources;
97
116
  };
98
117
 
99
118
  const readDependencyText = async ({ repoRoot, roots }) => {
100
- const candidatePaths = [
101
- ...DEPENDENCY_FILE_CANDIDATES,
102
- ...roots.flatMap((root) =>
103
- DEPENDENCY_FILE_CANDIDATES.map((file) => join(root, file)),
104
- ),
105
- ];
106
- const sources = [];
107
- for (const path of [...new Set(candidatePaths)]) {
108
- const absolutePath = join(repoRoot, path);
109
- if (!(await exists(absolutePath))) continue;
110
- sources.push({
111
- file_path: path,
112
- text: await readFile(absolutePath, "utf8"),
113
- });
114
- }
115
- return sources;
119
+ const candidatePaths = [
120
+ ...DEPENDENCY_FILE_CANDIDATES,
121
+ ...roots.flatMap((root) =>
122
+ DEPENDENCY_FILE_CANDIDATES.map((file) => join(root, file)),
123
+ ),
124
+ ];
125
+ const sources = [];
126
+ for (const path of [...new Set(candidatePaths)]) {
127
+ const absolutePath = join(repoRoot, path);
128
+ if (!(await exists(absolutePath))) continue;
129
+ sources.push({
130
+ file_path: path,
131
+ text: await readFile(absolutePath, "utf8"),
132
+ });
133
+ }
134
+ return sources;
116
135
  };
117
136
 
118
137
  const setupSourcePaths = async ({ repoRoot, request, includeFrontend }) => {
119
- const requested = request?.allowed_source_paths?.length
120
- ? request.allowed_source_paths
121
- : ["."];
122
- if (!includeFrontend) return requested;
123
- const candidates = [
124
- ...requested,
125
- "frontend/src",
126
- "src",
127
- "app",
128
- "apps/web/src",
129
- "packages/frontend",
130
- ];
131
- const existing = [];
132
- for (const candidate of [...new Set(candidates)]) {
133
- if (await exists(join(repoRoot, candidate))) {
134
- existing.push(candidate);
135
- }
136
- }
137
- return existing.length ? existing : requested;
138
+ const requested = request?.allowed_source_paths?.length
139
+ ? request.allowed_source_paths
140
+ : ["."];
141
+ if (!includeFrontend) return requested;
142
+ const candidates = [
143
+ ...requested,
144
+ "frontend/src",
145
+ "src",
146
+ "app",
147
+ "apps/web/src",
148
+ "packages/frontend",
149
+ ];
150
+ const existing = [];
151
+ for (const candidate of [...new Set(candidates)]) {
152
+ if (await exists(join(repoRoot, candidate))) {
153
+ existing.push(candidate);
154
+ }
155
+ }
156
+ return existing.length ? existing : requested;
138
157
  };
139
158
 
140
159
  const secretLeakPatterns = [
141
- /pk_(live|test)_[A-Za-z0-9_-]+/,
142
- /sk_(live|test)_[A-Za-z0-9_-]+/,
143
- /npm_[A-Za-z0-9]{20,}/,
144
- /CLUE_API_KEY\s*[:=]\s*["'][^"']+["']/,
145
- /AI_PROVIDER_API_KEY\s*[:=]\s*["'][^"']+["']/,
160
+ /pk_(live|test)_[A-Za-z0-9_-]+/,
161
+ /sk_(live|test)_[A-Za-z0-9_-]+/,
162
+ /npm_[A-Za-z0-9]{20,}/,
163
+ /CLUE_API_KEY\s*[:=]\s*["'][^"']+["']/,
164
+ /CLUE_AI_PROVIDER_API_KEY\s*[:=]\s*["'][^"']+["']/,
146
165
  ];
147
166
 
148
167
  const findSecretLeaks = (sources) =>
149
- sources.flatMap((source) =>
150
- secretLeakPatterns.some((pattern) => pattern.test(source.text))
151
- ? [source.file_path]
152
- : [],
153
- );
168
+ sources.flatMap((source) =>
169
+ secretLeakPatterns.some((pattern) => pattern.test(source.text))
170
+ ? [source.file_path]
171
+ : [],
172
+ );
154
173
 
155
174
  const startsWithRoot = (filePath, root) =>
156
- filePath === root || filePath.startsWith(`${root.replace(/\/+$/, "")}/`);
175
+ filePath === root || filePath.startsWith(`${root.replace(/\/+$/, "")}/`);
157
176
 
158
177
  const hasAnyMarker = (text, markers) =>
159
- markers.some((marker) => text.includes(marker));
178
+ markers.some((marker) => text.includes(marker));
160
179
 
161
180
  const checkSdkLifecycle = ({
162
- backendRootPaths = [],
163
- dependencySources = [],
164
- sources,
181
+ backendRootPaths = [],
182
+ dependencySources = [],
183
+ sources,
165
184
  }) => {
166
- const combined = sources.map((source) => source.text).join("\n");
167
- const backendSources = sources.filter((source) =>
168
- backendRootPaths.some((root) => startsWithRoot(source.file_path, root)),
169
- );
170
- const backendCombined = backendSources
171
- .map((source) => source.text)
172
- .join("\n");
173
- const dependencyCombined = dependencySources
174
- .map((source) => source.text)
175
- .join("\n");
176
- const foundApiNames = findLifecycleCallApiNames(combined);
177
- const backendFoundApiNames = findLifecycleCallApiNames(backendCombined);
178
- const foundApis = REQUIRED_LIFECYCLE_APIS.filter((api) =>
179
- foundApiNames.includes(api),
180
- );
181
- const missingApis = REQUIRED_LIFECYCLE_APIS.filter(
182
- (api) => !foundApis.includes(api),
183
- );
184
- const noOpPattern =
185
- /window\.Clue(?:Init|Identify|SetAccount|Logout)|(?:function|const|let|var)\s+Clue(?:Init|Identify|SetAccount|Logout)\b/;
186
- const componentLifecycleInitFiles = sources
187
- .filter((source) =>
188
- /useEffect\s*\([\s\S]{0,1200}ClueInit/.test(source.text),
189
- )
190
- .map((source) => source.file_path);
191
- const unguardedLifecycleCalls = sources.flatMap((source) =>
192
- findLifecycleGuardViolations(source.text).map((violation) => ({
193
- file_path: source.file_path,
194
- ...violation,
195
- })),
196
- );
197
- const unguardedLifecycleFiles = [
198
- ...new Set(unguardedLifecycleCalls.map((violation) => violation.file_path)),
199
- ];
200
- const backendPresent = backendSources.length > 0;
201
- const backendSdkPresent =
202
- !backendPresent ||
203
- hasAnyMarker(
204
- `${backendCombined}\n${dependencyCombined}`,
205
- BACKEND_SDK_MARKERS,
206
- );
207
- const backendInitPresent =
208
- !backendPresent ||
209
- /clue_init_fastapi|clue_init_django|configure_settings|CluePythonBootstrapConfig/.test(
210
- backendCombined,
211
- );
212
- const backendIdentityRequired =
213
- backendPresent &&
214
- /\b(login|signin|sign_in|auth|token|session)\b/i.test(backendCombined);
215
- const backendLogoutRequired =
216
- backendPresent && /\b(logout|signout|sign_out)\b/i.test(backendCombined);
217
- const backendAccountRequired =
218
- backendPresent &&
219
- /\b(account|workspace|organization|tenant)\b/i.test(backendCombined);
220
- const backendMissingApis = [
221
- ...(backendIdentityRequired &&
222
- !backendFoundApiNames.includes("ClueIdentify")
223
- ? ["ClueIdentify"]
224
- : []),
225
- ...(backendLogoutRequired && !backendFoundApiNames.includes("ClueLogout")
226
- ? ["ClueLogout"]
227
- : []),
228
- ...(backendAccountRequired &&
229
- !backendFoundApiNames.includes("ClueSetAccount")
230
- ? ["ClueSetAccount"]
231
- : []),
232
- ];
233
- return {
234
- foundApis,
235
- missingApis,
236
- backend_lifecycle: {
237
- backend_present: backendPresent,
238
- backend_root_paths: backendRootPaths,
239
- dependency_files: dependencySources.map((source) => source.file_path),
240
- sdk_dependency_or_import_present: backendSdkPresent,
241
- sdk_init_present: backendInitPresent,
242
- required_apis: [
243
- ...(backendIdentityRequired ? ["ClueIdentify"] : []),
244
- ...(backendAccountRequired ? ["ClueSetAccount"] : []),
245
- ...(backendLogoutRequired ? ["ClueLogout"] : []),
246
- ],
247
- missing_apis: backendMissingApis,
248
- },
249
- has_noop_wrapper: noOpPattern.test(combined),
250
- component_lifecycle_init_files: componentLifecycleInitFiles,
251
- unguarded_lifecycle_files: unguardedLifecycleFiles,
252
- unguarded_lifecycle_calls: unguardedLifecycleCalls,
253
- passed:
254
- missingApis.length === 0 &&
255
- backendSdkPresent &&
256
- backendInitPresent &&
257
- backendMissingApis.length === 0 &&
258
- !noOpPattern.test(combined) &&
259
- componentLifecycleInitFiles.length === 0 &&
260
- unguardedLifecycleFiles.length === 0,
261
- };
185
+ const combined = sources.map((source) => source.text).join("\n");
186
+ const backendSources = sources.filter((source) =>
187
+ backendRootPaths.some((root) => startsWithRoot(source.file_path, root)),
188
+ );
189
+ const backendCombined = backendSources
190
+ .map((source) => source.text)
191
+ .join("\n");
192
+ const dependencyCombined = dependencySources
193
+ .map((source) => source.text)
194
+ .join("\n");
195
+ const foundApiNames = findLifecycleCallApiNames(combined);
196
+ const backendFoundApiNames = findLifecycleCallApiNames(backendCombined);
197
+ const foundApis = REQUIRED_LIFECYCLE_APIS.filter((api) =>
198
+ foundApiNames.includes(api),
199
+ );
200
+ const missingApis = REQUIRED_LIFECYCLE_APIS.filter(
201
+ (api) => !foundApis.includes(api),
202
+ );
203
+ const noOpPattern =
204
+ /window\.Clue(?:Init|Identify|SetAccount|Logout)|(?:function|const|let|var)\s+Clue(?:Init|Identify|SetAccount|Logout)\b/;
205
+ const componentLifecycleInitFiles = sources
206
+ .filter((source) =>
207
+ /useEffect\s*\([\s\S]{0,1200}ClueInit/.test(source.text),
208
+ )
209
+ .map((source) => source.file_path);
210
+ const unguardedLifecycleCalls = sources.flatMap((source) =>
211
+ findLifecycleGuardViolations(source.text).map((violation) => ({
212
+ file_path: source.file_path,
213
+ ...violation,
214
+ })),
215
+ );
216
+ const unguardedLifecycleFiles = [
217
+ ...new Set(unguardedLifecycleCalls.map((violation) => violation.file_path)),
218
+ ];
219
+ const backendPresent = backendSources.length > 0;
220
+ const backendSdkPresent =
221
+ !backendPresent ||
222
+ hasAnyMarker(
223
+ `${backendCombined}\n${dependencyCombined}`,
224
+ BACKEND_SDK_MARKERS,
225
+ );
226
+ const backendInitPresent =
227
+ !backendPresent ||
228
+ /clue_init_fastapi|clue_init_django|configure_settings|CluePythonBootstrapConfig/.test(
229
+ backendCombined,
230
+ );
231
+ const backendIdentityRequired =
232
+ backendPresent &&
233
+ /\b(login|signin|sign_in|auth|token|session)\b/i.test(backendCombined);
234
+ const backendLogoutRequired =
235
+ backendPresent && /\b(logout|signout|sign_out)\b/i.test(backendCombined);
236
+ const backendAccountRequired =
237
+ backendPresent &&
238
+ /\b(account|workspace|organization|tenant)\b/i.test(backendCombined);
239
+ const backendMissingApis = [
240
+ ...(backendIdentityRequired &&
241
+ !backendFoundApiNames.includes("ClueIdentify")
242
+ ? ["ClueIdentify"]
243
+ : []),
244
+ ...(backendLogoutRequired && !backendFoundApiNames.includes("ClueLogout")
245
+ ? ["ClueLogout"]
246
+ : []),
247
+ ...(backendAccountRequired &&
248
+ !backendFoundApiNames.includes("ClueSetAccount")
249
+ ? ["ClueSetAccount"]
250
+ : []),
251
+ ];
252
+ return {
253
+ foundApis,
254
+ missingApis,
255
+ backend_lifecycle: {
256
+ backend_present: backendPresent,
257
+ backend_root_paths: backendRootPaths,
258
+ dependency_files: dependencySources.map((source) => source.file_path),
259
+ sdk_dependency_or_import_present: backendSdkPresent,
260
+ sdk_init_present: backendInitPresent,
261
+ required_apis: [
262
+ ...(backendIdentityRequired ? ["ClueIdentify"] : []),
263
+ ...(backendAccountRequired ? ["ClueSetAccount"] : []),
264
+ ...(backendLogoutRequired ? ["ClueLogout"] : []),
265
+ ],
266
+ missing_apis: backendMissingApis,
267
+ },
268
+ has_noop_wrapper: noOpPattern.test(combined),
269
+ component_lifecycle_init_files: componentLifecycleInitFiles,
270
+ unguarded_lifecycle_files: unguardedLifecycleFiles,
271
+ unguarded_lifecycle_calls: unguardedLifecycleCalls,
272
+ passed:
273
+ missingApis.length === 0 &&
274
+ backendSdkPresent &&
275
+ backendInitPresent &&
276
+ backendMissingApis.length === 0 &&
277
+ !noOpPattern.test(combined) &&
278
+ componentLifecycleInitFiles.length === 0 &&
279
+ unguardedLifecycleFiles.length === 0,
280
+ };
262
281
  };
263
282
 
264
283
  export const runSetupCheck = async ({
265
- repoRoot,
266
- target,
267
- request,
268
- requireSdkLifecycle = false,
284
+ repoRoot,
285
+ target,
286
+ request,
287
+ requireSdkLifecycle = false,
269
288
  }) => {
270
- const resolvedRepoRoot = resolve(repoRoot ?? ".");
271
- const checks = [];
272
- const normalizedTarget = normalizeTarget(target);
289
+ const resolvedRepoRoot = resolve(repoRoot ?? ".");
290
+ const checks = [];
291
+ const normalizedTarget = normalizeTarget(target);
273
292
 
274
- if (normalizedTarget) {
275
- const skillRoot = join(
276
- resolvedRepoRoot,
277
- ...TARGET_SKILL_ROOTS[normalizedTarget],
278
- );
279
- const missingSkills = [];
280
- for (const skillName of SETUP_SKILLS) {
281
- const skillPath = join(skillRoot, skillName, "SKILL.md");
282
- if (!(await exists(skillPath))) {
283
- missingSkills.push(
284
- join(...TARGET_SKILL_ROOTS[normalizedTarget], skillName, "SKILL.md"),
285
- );
286
- }
287
- }
288
- addCheck(
289
- checks,
290
- "setup_skills",
291
- missingSkills.length === 0,
292
- missingSkills.length === 0
293
- ? "setup skills are installed"
294
- : "setup skills are missing",
295
- { missing_files: missingSkills },
296
- );
297
- }
293
+ if (normalizedTarget) {
294
+ const skillRoot = join(
295
+ resolvedRepoRoot,
296
+ ...TARGET_SKILL_ROOTS[normalizedTarget],
297
+ );
298
+ const missingSkills = [];
299
+ for (const skillName of SETUP_SKILLS) {
300
+ const skillPath = join(skillRoot, skillName, "SKILL.md");
301
+ if (!(await exists(skillPath))) {
302
+ missingSkills.push(
303
+ join(...TARGET_SKILL_ROOTS[normalizedTarget], skillName, "SKILL.md"),
304
+ );
305
+ }
306
+ }
307
+ addCheck(
308
+ checks,
309
+ "setup_skills",
310
+ missingSkills.length === 0,
311
+ missingSkills.length === 0
312
+ ? "setup skills are installed"
313
+ : "setup skills are missing",
314
+ { missing_files: missingSkills },
315
+ );
316
+ }
298
317
 
299
- const workflowPath = request?.ci_workflow_path ?? DEFAULT_WORKFLOW_PATH;
300
- const absoluteWorkflowPath = join(resolvedRepoRoot, workflowPath);
301
- if (await exists(absoluteWorkflowPath)) {
302
- const workflow = await readFile(absoluteWorkflowPath, "utf8");
303
- addCheck(
304
- checks,
305
- "semantic_workflow",
306
- workflow.includes(
307
- "npx @clue-ai/cli semantic-ci --request-env CLUE_SEMANTIC_REQUEST_JSON --repo .",
308
- ) &&
309
- workflow.includes("CLUE_SEMANTIC_REQUEST_JSON: |") &&
310
- workflow.includes("CLUE_API_KEY: ${{ secrets.CLUE_API_KEY }}") &&
311
- workflow.includes(
312
- "AI_PROVIDER_API_KEY: ${{ secrets.AI_PROVIDER_API_KEY }}",
313
- ) &&
314
- workflow.includes("permissions:\n contents: read") &&
315
- workflow.includes("persist-credentials: false") &&
316
- !disallowedWorkflowMetadataPattern.test(workflow),
317
- "semantic workflow uses the canonical env runtime request, least-privilege checkout, and privacy-minimized GitHub metadata",
318
- { workflow_path: workflowPath },
319
- );
320
- } else {
321
- addCheck(
322
- checks,
323
- "semantic_workflow",
324
- false,
325
- "semantic workflow is missing",
326
- {
327
- workflow_path: workflowPath,
328
- },
329
- );
330
- }
318
+ const workflowPath = request?.ci_workflow_path ?? DEFAULT_WORKFLOW_PATH;
319
+ const absoluteWorkflowPath = join(resolvedRepoRoot, workflowPath);
320
+ if (await exists(absoluteWorkflowPath)) {
321
+ const workflow = await readFile(absoluteWorkflowPath, "utf8");
322
+ const semanticRequest = semanticRequestFromWorkflow(workflow);
323
+ addCheck(
324
+ checks,
325
+ "semantic_workflow",
326
+ workflow.includes(
327
+ "npx @clue-ai/cli semantic-gen --request-env CLUE_SEMANTIC_REQUEST_JSON --repo .",
328
+ ) &&
329
+ semanticRequest !== null &&
330
+ workflow.includes("CLUE_SEMANTIC_REQUEST_JSON: |") &&
331
+ workflow.includes("CLUE_API_KEY: ${{ secrets.CLUE_API_KEY }}") &&
332
+ workflow.includes(
333
+ "CLUE_AI_PROVIDER_API_KEY: ${{ secrets.CLUE_AI_PROVIDER_API_KEY }}",
334
+ ) &&
335
+ workflow.includes("CLUE_AI_PROVIDER: ${{ vars.CLUE_AI_PROVIDER }}") &&
336
+ workflow.includes("CLUE_AI_MODEL: ${{ vars.CLUE_AI_MODEL }}") &&
337
+ !workflow.includes('"ai_provider": "${{ vars.CLUE_AI_PROVIDER }}"') &&
338
+ !workflow.includes('"ai_provider_base_url"') &&
339
+ !workflow.includes('"ai_model": "${{ vars.CLUE_AI_MODEL }}"') &&
340
+ !workflow.includes('"root_path"') &&
341
+ workflow.includes("permissions:\n contents: read") &&
342
+ workflow.includes("persist-credentials: false") &&
343
+ !disallowedWorkflowMetadataPattern.test(workflow),
344
+ "semantic workflow uses the canonical env runtime request, least-privilege checkout, and privacy-minimized GitHub metadata",
345
+ { workflow_path: workflowPath },
346
+ );
347
+ } else {
348
+ addCheck(
349
+ checks,
350
+ "semantic_workflow",
351
+ false,
352
+ "semantic workflow is missing",
353
+ {
354
+ workflow_path: workflowPath,
355
+ },
356
+ );
357
+ }
331
358
 
332
- addCheck(
333
- checks,
334
- "runtime_request_not_committed",
335
- !(await exists(
336
- join(resolvedRepoRoot, ".clue", "semantic-request.runtime.json"),
337
- )),
338
- ".clue/semantic-request.runtime.json is not present in the repository",
339
- );
359
+ addCheck(
360
+ checks,
361
+ "runtime_request_not_committed",
362
+ !(await exists(
363
+ join(resolvedRepoRoot, ".clue", "semantic-request.runtime.json"),
364
+ )),
365
+ ".clue/semantic-request.runtime.json is not present in the repository",
366
+ );
340
367
 
341
- if (request) {
342
- try {
343
- const inventory = await runSemanticInventory({
344
- repoRoot: resolvedRepoRoot,
345
- request,
346
- });
347
- addCheck(
348
- checks,
349
- "semantic_inventory",
350
- true,
351
- "semantic inventory discovers routes",
352
- {
353
- route_count: inventory.route_count,
354
- },
355
- );
356
- } catch (error) {
357
- addCheck(
358
- checks,
359
- "semantic_inventory",
360
- false,
361
- "semantic inventory failed",
362
- {
363
- error: error instanceof Error ? error.message : String(error),
364
- },
365
- );
366
- }
367
- }
368
+ if (request) {
369
+ try {
370
+ const inventory = await runSemanticInventory({
371
+ repoRoot: resolvedRepoRoot,
372
+ request,
373
+ });
374
+ addCheck(
375
+ checks,
376
+ "semantic_inventory",
377
+ true,
378
+ "semantic inventory discovers routes",
379
+ {
380
+ route_count: inventory.route_count,
381
+ },
382
+ );
383
+ } catch (error) {
384
+ addCheck(
385
+ checks,
386
+ "semantic_inventory",
387
+ false,
388
+ "semantic inventory failed",
389
+ {
390
+ error: error instanceof Error ? error.message : String(error),
391
+ },
392
+ );
393
+ }
394
+ }
368
395
 
369
- const sourcePaths = await setupSourcePaths({
370
- repoRoot: resolvedRepoRoot,
371
- request,
372
- includeFrontend: requireSdkLifecycle,
373
- });
374
- const excludedPaths = request?.excluded_source_paths ?? [];
375
- const sources = await readAllowedSourceText({
376
- repoRoot: resolvedRepoRoot,
377
- allowedSourcePaths: sourcePaths,
378
- excludedSourcePaths: excludedPaths,
379
- });
380
- const dependencySources = await readDependencyText({
381
- repoRoot: resolvedRepoRoot,
382
- roots: request?.allowed_source_paths ?? [],
383
- });
384
- const secretLeaks = findSecretLeaks([
385
- ...sources,
386
- ...dependencySources,
387
- ...((await exists(absoluteWorkflowPath))
388
- ? [
389
- {
390
- file_path: workflowPath,
391
- text: await readFile(absoluteWorkflowPath, "utf8"),
392
- },
393
- ]
394
- : []),
395
- ]);
396
- addCheck(
397
- checks,
398
- "no_secret_values",
399
- secretLeaks.length === 0,
400
- "no obvious secret values found",
401
- {
402
- files: secretLeaks,
403
- },
404
- );
396
+ const sourcePaths = await setupSourcePaths({
397
+ repoRoot: resolvedRepoRoot,
398
+ request,
399
+ includeFrontend: requireSdkLifecycle,
400
+ });
401
+ const excludedPaths = request?.excluded_source_paths ?? [];
402
+ const sources = await readAllowedSourceText({
403
+ repoRoot: resolvedRepoRoot,
404
+ allowedSourcePaths: sourcePaths,
405
+ excludedSourcePaths: excludedPaths,
406
+ });
407
+ const dependencySources = await readDependencyText({
408
+ repoRoot: resolvedRepoRoot,
409
+ roots: request?.allowed_source_paths ?? [],
410
+ });
411
+ const secretLeaks = findSecretLeaks([
412
+ ...sources,
413
+ ...dependencySources,
414
+ ...((await exists(absoluteWorkflowPath))
415
+ ? [
416
+ {
417
+ file_path: workflowPath,
418
+ text: await readFile(absoluteWorkflowPath, "utf8"),
419
+ },
420
+ ]
421
+ : []),
422
+ ]);
423
+ addCheck(
424
+ checks,
425
+ "no_secret_values",
426
+ secretLeaks.length === 0,
427
+ "no obvious secret values found",
428
+ {
429
+ files: secretLeaks,
430
+ },
431
+ );
405
432
 
406
- if (requireSdkLifecycle) {
407
- const sdkLifecycle = checkSdkLifecycle({
408
- backendRootPaths: request?.allowed_source_paths ?? [],
409
- dependencySources,
410
- sources,
411
- });
412
- addCheck(
413
- checks,
414
- "sdk_lifecycle",
415
- sdkLifecycle.passed,
416
- "SDK lifecycle calls resolve to real source code instead of no-op globals",
417
- {
418
- found_apis: sdkLifecycle.foundApis,
419
- missing_apis: sdkLifecycle.missingApis,
420
- backend_lifecycle: sdkLifecycle.backend_lifecycle,
421
- has_noop_wrapper: sdkLifecycle.has_noop_wrapper,
422
- component_lifecycle_init_files:
423
- sdkLifecycle.component_lifecycle_init_files,
424
- unguarded_lifecycle_files: sdkLifecycle.unguarded_lifecycle_files,
425
- unguarded_lifecycle_calls: sdkLifecycle.unguarded_lifecycle_calls,
426
- },
427
- );
428
- }
433
+ if (requireSdkLifecycle) {
434
+ const sdkLifecycle = checkSdkLifecycle({
435
+ backendRootPaths: request?.allowed_source_paths ?? [],
436
+ dependencySources,
437
+ sources,
438
+ });
439
+ addCheck(
440
+ checks,
441
+ "sdk_lifecycle",
442
+ sdkLifecycle.passed,
443
+ "SDK lifecycle calls resolve to real source code instead of no-op globals",
444
+ {
445
+ found_apis: sdkLifecycle.foundApis,
446
+ missing_apis: sdkLifecycle.missingApis,
447
+ backend_lifecycle: sdkLifecycle.backend_lifecycle,
448
+ has_noop_wrapper: sdkLifecycle.has_noop_wrapper,
449
+ component_lifecycle_init_files:
450
+ sdkLifecycle.component_lifecycle_init_files,
451
+ unguarded_lifecycle_files: sdkLifecycle.unguarded_lifecycle_files,
452
+ unguarded_lifecycle_calls: sdkLifecycle.unguarded_lifecycle_calls,
453
+ },
454
+ );
455
+ }
429
456
 
430
- const passed = checks.every((check) => check.passed);
431
- return {
432
- passed,
433
- checks,
434
- };
457
+ const passed = checks.every((check) => check.passed);
458
+ return {
459
+ passed,
460
+ checks,
461
+ };
435
462
  };