@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/README.md +43 -0
- package/bin/clue-cli.mjs +844 -68
- package/package.json +1 -1
- package/src/contracts.mjs +5 -0
- package/src/init-tool.mjs +154 -55
- package/src/lifecycle-guard.mjs +141 -0
- package/src/lifecycle-init.mjs +210 -167
- package/src/path-policy.mjs +2 -0
- package/src/public-schema.cjs +26 -0
- package/src/semantic-ci.mjs +722 -32
- package/src/setup-check.mjs +435 -0
- package/src/setup-detect.mjs +198 -0
- package/src/setup-prepare.mjs +170 -0
- package/src/setup-tool.mjs +231 -166
package/package.json
CHANGED
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 {
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
167
|
+
repoRoot,
|
|
168
|
+
request: rawRequest,
|
|
169
|
+
env = process.env,
|
|
170
|
+
lifecyclePlanner = planLifecycleInsertions,
|
|
68
171
|
}) => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
];
|