@clue-ai/cli 0.0.4 → 0.0.5

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clue-ai/cli",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "clue-ai": "bin/clue-cli.mjs"
package/src/contracts.mjs CHANGED
@@ -7,6 +7,7 @@ const {
7
7
  clueInitToolRequestSchema,
8
8
  clueInitToolReportSchema,
9
9
  semanticSnapshotRequestSchema,
10
+ semanticSnapshotResponseSchema,
10
11
  } = schemaPackage;
11
12
 
12
13
  const formatSchemaError = (result, field) => {
@@ -122,3 +123,7 @@ export const buildOperationSourceKey = (method, pathTemplate) =>
122
123
  export const validateSemanticSnapshotRequest = (input) => {
123
124
  return parseWithSchema(semanticSnapshotRequestSchema, input, "semantic snapshot");
124
125
  };
126
+
127
+ export const validateSemanticSnapshotResponse = (input) => {
128
+ return parseWithSchema(semanticSnapshotResponseSchema, input, "semantic snapshot response");
129
+ };
package/src/init-tool.mjs CHANGED
@@ -1,86 +1,185 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { buildInitReport, validateInitRequest } from "./contracts.mjs";
4
- import { applyLifecyclePlan, planLifecycleInsertions } from "./lifecycle-init.mjs";
4
+ import {
5
+ applyLifecyclePlan,
6
+ planLifecycleInsertions,
7
+ } from "./lifecycle-init.mjs";
8
+
9
+ const DEFAULT_SEMANTIC_WORKFLOW_PATH =
10
+ ".github/workflows/clue-semantic-snapshot.yml";
11
+
12
+ const nonEmpty = (value, field) => {
13
+ if (typeof value !== "string" || value.trim() === "") {
14
+ throw new Error(`${field} is required`);
15
+ }
16
+ return value.trim();
17
+ };
18
+
19
+ const normalizeStringArray = (value, field, { min = 0 } = {}) => {
20
+ if (!Array.isArray(value)) {
21
+ throw new Error(`${field} must be an array`);
22
+ }
23
+ const result = value
24
+ .filter((entry) => typeof entry === "string" && entry.trim())
25
+ .map((entry) => entry.trim());
26
+ if (result.length < min) {
27
+ throw new Error(`${field} must include at least ${min} item(s)`);
28
+ }
29
+ return [...new Set(result)];
30
+ };
31
+
32
+ const splitCsv = (value) =>
33
+ typeof value === "string"
34
+ ? value
35
+ .split(",")
36
+ .map((entry) => entry.trim())
37
+ .filter(Boolean)
38
+ : [];
39
+
40
+ const deriveServiceKeyFromPath = (backendRootPath) => {
41
+ const segment = backendRootPath
42
+ .split("/")
43
+ .map((part) => part.trim())
44
+ .filter(Boolean)
45
+ .at(-1);
46
+ const normalized = segment
47
+ ?.toLowerCase()
48
+ .replace(/[^a-z0-9_-]+/g, "-")
49
+ .replace(/^-+|-+$/g, "");
50
+ if (!normalized) {
51
+ throw new Error("service-key cannot be derived from backend-root-path");
52
+ }
53
+ return normalized;
54
+ };
55
+
56
+ export const buildSemanticWorkflowRequestFromFlags = (flags) => {
57
+ const backendRootPath = nonEmpty(flags.backendRootPath, "backend-root-path");
58
+ const allowedSourcePaths = splitCsv(flags.allowedSourcePaths);
59
+ return {
60
+ ci_workflow_path:
61
+ typeof flags.workflowPath === "string" && flags.workflowPath.trim()
62
+ ? flags.workflowPath.trim()
63
+ : DEFAULT_SEMANTIC_WORKFLOW_PATH,
64
+ service_key:
65
+ typeof flags.serviceKey === "string" && flags.serviceKey.trim()
66
+ ? flags.serviceKey.trim()
67
+ : deriveServiceKeyFromPath(backendRootPath),
68
+ framework: nonEmpty(flags.framework, "framework"),
69
+ allowed_source_paths: normalizeStringArray(
70
+ allowedSourcePaths.length ? allowedSourcePaths : [backendRootPath],
71
+ "allowed-source-paths",
72
+ { min: 1 },
73
+ ),
74
+ excluded_source_paths: normalizeStringArray(
75
+ splitCsv(flags.excludedSourcePaths),
76
+ "excluded-source-paths",
77
+ ),
78
+ };
79
+ };
5
80
 
6
81
  const workflowRequestPayload = (request) =>
7
- JSON.stringify(
8
- {
9
- project_key: "${{ vars.CLUE_PROJECT_KEY }}",
10
- environment: "${{ vars.CLUE_ENVIRONMENT }}",
11
- service_key: request.service_key,
12
- repository: {
13
- provider: "github",
14
- repository_id: "${{ github.repository_id }}",
15
- owner: "${{ github.repository_owner }}",
16
- name: "${{ github.event.repository.name }}",
17
- default_branch: "${{ github.event.repository.default_branch }}",
18
- merge_commit: "${{ github.sha }}",
19
- workflow_run_id: "${{ github.run_id }}",
20
- },
21
- service: {
22
- service_key: request.service_key,
23
- root_path: request.allowed_source_paths[0],
24
- framework: request.framework,
25
- language: "python",
26
- },
27
- allowed_source_paths: request.allowed_source_paths,
28
- excluded_source_paths: request.excluded_source_paths,
29
- clue_api_base_url: "${{ vars.CLUE_API_BASE_URL }}",
30
- ai_model: "${{ vars.CLUE_AI_MODEL }}",
31
- },
32
- null,
33
- 10,
34
- ).trim();
82
+ JSON.stringify(
83
+ {
84
+ project_key: "${{ vars.CLUE_PROJECT_KEY }}",
85
+ environment: "${{ vars.CLUE_ENVIRONMENT }}",
86
+ service_key: request.service_key,
87
+ repository: {
88
+ provider: "github",
89
+ repository_id: "${{ github.repository_id }}",
90
+ merge_commit: "${{ github.sha }}",
91
+ workflow_run_id: "${{ github.run_id }}",
92
+ },
93
+ service: {
94
+ service_key: request.service_key,
95
+ root_path: request.allowed_source_paths[0],
96
+ framework: request.framework,
97
+ language: "python",
98
+ },
99
+ allowed_source_paths: request.allowed_source_paths,
100
+ excluded_source_paths: request.excluded_source_paths,
101
+ clue_api_base_url: "${{ vars.CLUE_API_BASE_URL }}",
102
+ ai_model: "${{ vars.CLUE_AI_MODEL }}",
103
+ },
104
+ null,
105
+ 10,
106
+ ).trim();
107
+
108
+ const indentMultiline = (value, spaces) => {
109
+ const prefix = " ".repeat(spaces);
110
+ return value
111
+ .split("\n")
112
+ .map((line) => `${prefix}${line}`)
113
+ .join("\n");
114
+ };
35
115
 
36
116
  const workflowTemplate = (request) => `name: Clue Semantic Snapshot
37
117
 
38
118
  on:
39
119
  push:
40
120
 
121
+ permissions:
122
+ contents: read
123
+
41
124
  jobs:
42
125
  semantic-snapshot:
43
126
  if: github.ref_name == github.event.repository.default_branch
44
127
  runs-on: ubuntu-latest
45
128
  steps:
46
129
  - uses: actions/checkout@v4
130
+ with:
131
+ persist-credentials: false
47
132
  - uses: actions/setup-node@v4
48
133
  with:
49
134
  node-version: "20"
50
135
  - name: Run Clue semantic generation
51
- continue-on-error: true
52
136
  env:
53
137
  CLUE_API_KEY: \${{ secrets.CLUE_API_KEY }}
54
138
  AI_PROVIDER_API_KEY: \${{ secrets.AI_PROVIDER_API_KEY }}
139
+ CLUE_SEMANTIC_REQUEST_JSON: |
140
+ ${indentMultiline(workflowRequestPayload(request), 12)}
55
141
  run: |
56
- mkdir -p .clue
57
- cat > .clue/semantic-request.runtime.json <<'JSON'
58
- ${workflowRequestPayload(request)}
59
- JSON
60
- npx @clue-ai/cli semantic-ci --request .clue/semantic-request.runtime.json --repo .
142
+ npx @clue-ai/cli semantic-ci --request-env CLUE_SEMANTIC_REQUEST_JSON --repo .
61
143
  `;
62
144
 
145
+ export const writeSemanticWorkflow = async ({ repoRoot, request }) => {
146
+ const workflowPath = join(repoRoot, request.ci_workflow_path);
147
+ await mkdir(dirname(workflowPath), { recursive: true });
148
+ await writeFile(workflowPath, workflowTemplate(request), "utf8");
149
+ return {
150
+ ci_workflow_path: request.ci_workflow_path,
151
+ ci_workflow_added: true,
152
+ required_secrets: ["CLUE_API_KEY", "AI_PROVIDER_API_KEY"],
153
+ required_variables: [
154
+ "CLUE_PROJECT_KEY",
155
+ "CLUE_ENVIRONMENT",
156
+ "CLUE_API_BASE_URL",
157
+ "CLUE_AI_MODEL",
158
+ ],
159
+ runtime_request_committed: false,
160
+ semantic_generation_timing: "after_merge_ci",
161
+ allowed_source_paths: request.allowed_source_paths,
162
+ excluded_source_paths: request.excluded_source_paths,
163
+ };
164
+ };
165
+
63
166
  export const runInitTool = async ({
64
- repoRoot,
65
- request: rawRequest,
66
- env = process.env,
67
- lifecyclePlanner = planLifecycleInsertions,
167
+ repoRoot,
168
+ request: rawRequest,
169
+ env = process.env,
170
+ lifecyclePlanner = planLifecycleInsertions,
68
171
  }) => {
69
- const request = validateInitRequest(rawRequest);
70
- const workflowPath = join(repoRoot, request.ci_workflow_path);
71
- await mkdir(dirname(workflowPath), { recursive: true });
72
- await writeFile(workflowPath, workflowTemplate(request), "utf8");
73
- const lifecyclePlan = await lifecyclePlanner({ repoRoot, request, env });
74
- const lifecycleResult = await applyLifecyclePlan({
75
- repoRoot,
76
- plan: lifecyclePlan,
77
- });
172
+ const request = validateInitRequest(rawRequest);
173
+ await writeSemanticWorkflow({ repoRoot, request });
174
+ const lifecyclePlan = await lifecyclePlanner({ repoRoot, request, env });
175
+ const lifecycleResult = await applyLifecyclePlan({
176
+ repoRoot,
177
+ plan: lifecyclePlan,
178
+ });
78
179
 
79
- return buildInitReport({
80
- request,
81
- lifecycleInsertions: lifecycleResult.lifecycleInsertions,
82
- warnings: [
83
- ...lifecycleResult.warnings,
84
- ],
85
- });
180
+ return buildInitReport({
181
+ request,
182
+ lifecycleInsertions: lifecycleResult.lifecycleInsertions,
183
+ warnings: [...lifecycleResult.warnings],
184
+ });
86
185
  };
@@ -0,0 +1,141 @@
1
+ const LIFECYCLE_CALL_PATTERN =
2
+ /\b(ClueInit|ClueIdentify|ClueSetAccount|ClueLogout)\s*\(/g;
3
+ const SAFE_HELPER_PATTERN =
4
+ /\b(?:safeClue|safe_clue|safe_clue_call|safeClueCall|withClueGuard|with_clue_guard)\s*\(/g;
5
+
6
+ const findMatchingDelimiter = (text, openIndex, open, close) => {
7
+ let depth = 0;
8
+ for (let index = openIndex; index < text.length; index += 1) {
9
+ const character = text[index];
10
+ if (character === open) depth += 1;
11
+ if (character === close) {
12
+ depth -= 1;
13
+ if (depth === 0) return index;
14
+ }
15
+ }
16
+ return -1;
17
+ };
18
+
19
+ const lineNumberForIndex = (text, index) =>
20
+ text.slice(0, index).split("\n").length;
21
+
22
+ const isAwaitedOnCallLine = (text, callIndex) => {
23
+ const lineStart = text.lastIndexOf("\n", callIndex - 1) + 1;
24
+ return /\bawait\b/.test(text.slice(lineStart, callIndex));
25
+ };
26
+
27
+ const isInsideSafeHelperCall = (text, callIndex) => {
28
+ for (const match of text.matchAll(SAFE_HELPER_PATTERN)) {
29
+ const helperIndex = match.index ?? 0;
30
+ if (helperIndex > callIndex) return false;
31
+ const openParenIndex = text.indexOf("(", helperIndex);
32
+ const closeParenIndex = findMatchingDelimiter(
33
+ text,
34
+ openParenIndex,
35
+ "(",
36
+ ")",
37
+ );
38
+ if (openParenIndex < callIndex && callIndex < closeParenIndex) return true;
39
+ }
40
+ return false;
41
+ };
42
+
43
+ const isInsideJsTryBlock = (text, callIndex) => {
44
+ for (const match of text.matchAll(/\btry\s*{/g)) {
45
+ const tryIndex = match.index ?? 0;
46
+ if (tryIndex > callIndex) return false;
47
+ const openBraceIndex = text.indexOf("{", tryIndex);
48
+ const closeBraceIndex = findMatchingDelimiter(
49
+ text,
50
+ openBraceIndex,
51
+ "{",
52
+ "}",
53
+ );
54
+ if (openBraceIndex < callIndex && callIndex < closeBraceIndex) return true;
55
+ }
56
+ return false;
57
+ };
58
+
59
+ const buildLines = (text) => {
60
+ const lines = [];
61
+ let start = 0;
62
+ for (const line of text.split("\n")) {
63
+ const indent = line.match(/^\s*/)?.[0].length ?? 0;
64
+ lines.push({
65
+ start,
66
+ end: start + line.length,
67
+ indent,
68
+ text: line,
69
+ });
70
+ start += line.length + 1;
71
+ }
72
+ return lines;
73
+ };
74
+
75
+ const isInsidePythonTryBlock = (text, callIndex) => {
76
+ const lines = buildLines(text);
77
+ const callLineIndex = lines.findIndex(
78
+ (line) => line.start <= callIndex && callIndex <= line.end,
79
+ );
80
+ if (callLineIndex < 0) return false;
81
+ const callLine = lines[callLineIndex];
82
+ for (let index = callLineIndex - 1; index >= 0; index -= 1) {
83
+ const candidate = lines[index];
84
+ if (!candidate.text.trim()) continue;
85
+ if (!/^\s*try\s*:\s*(?:#.*)?$/.test(candidate.text)) continue;
86
+ if (candidate.indent >= callLine.indent) continue;
87
+ const escaped = lines
88
+ .slice(index + 1, callLineIndex)
89
+ .some(
90
+ (line) =>
91
+ line.text.trim() &&
92
+ line.indent <= candidate.indent &&
93
+ !/^\s*(?:except|finally|else)\b/.test(line.text),
94
+ );
95
+ if (!escaped) return true;
96
+ }
97
+ return false;
98
+ };
99
+
100
+ const hasCatchHandlerOnCall = (text, callIndex) => {
101
+ const openParenIndex = text.indexOf("(", callIndex);
102
+ const closeParenIndex = findMatchingDelimiter(text, openParenIndex, "(", ")");
103
+ if (closeParenIndex < 0) return false;
104
+ return /^\s*\.catch\s*\(/.test(text.slice(closeParenIndex + 1));
105
+ };
106
+
107
+ const isGuardedLifecycleCall = (text, callIndex) =>
108
+ hasCatchHandlerOnCall(text, callIndex) ||
109
+ isInsideSafeHelperCall(text, callIndex) ||
110
+ isInsideJsTryBlock(text, callIndex) ||
111
+ isInsidePythonTryBlock(text, callIndex);
112
+
113
+ export const findLifecycleGuardViolations = (text) => {
114
+ const violations = [];
115
+ for (const match of text.matchAll(LIFECYCLE_CALL_PATTERN)) {
116
+ const callIndex = match.index ?? 0;
117
+ const apiName = match[1];
118
+ if (isAwaitedOnCallLine(text, callIndex)) {
119
+ violations.push({
120
+ api_name: apiName,
121
+ line: lineNumberForIndex(text, callIndex),
122
+ reason: "awaited_lifecycle_call",
123
+ });
124
+ continue;
125
+ }
126
+ if (!isGuardedLifecycleCall(text, callIndex)) {
127
+ violations.push({
128
+ api_name: apiName,
129
+ line: lineNumberForIndex(text, callIndex),
130
+ reason: "unguarded_lifecycle_call",
131
+ });
132
+ }
133
+ }
134
+ return violations;
135
+ };
136
+
137
+ export const findLifecycleCallApiNames = (text) => [
138
+ ...new Set(
139
+ [...text.matchAll(LIFECYCLE_CALL_PATTERN)].map((match) => match[1]),
140
+ ),
141
+ ];