frappe-builder 1.1.0-dev.9 → 1.2.0-dev.29
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/.fb/state.db +0 -0
- package/.frappe-builder/po-approval/implementation-artifacts/sprint-status.yaml +15 -0
- package/AGENTS.md +59 -130
- package/README.md +14 -21
- package/agents/frappe-architect.md +29 -0
- package/agents/frappe-ba.md +28 -0
- package/agents/frappe-dev.md +25 -0
- package/agents/frappe-docs.md +27 -0
- package/agents/frappe-planner.md +28 -0
- package/agents/frappe-qa.md +28 -0
- package/config/constants.ts +45 -0
- package/config/defaults.ts +11 -3
- package/config/loader.ts +18 -84
- package/dist/cli.mjs +49 -36
- package/dist/init-DvtJrAiJ.mjs +233 -0
- package/extensions/agent-chain.ts +254 -0
- package/extensions/frappe-gates.ts +31 -7
- package/extensions/frappe-session.ts +11 -3
- package/extensions/frappe-state.ts +110 -20
- package/extensions/frappe-tools.ts +52 -29
- package/extensions/frappe-ui.ts +100 -40
- package/extensions/frappe-workflow.ts +82 -13
- package/extensions/pi-types.ts +53 -0
- package/package.json +2 -2
- package/state/artifacts.ts +85 -0
- package/state/db.ts +18 -4
- package/state/fsm.ts +33 -13
- package/state/schema.ts +42 -3
- package/tools/agent-tools.ts +71 -5
- package/tools/bench-tools.ts +4 -8
- package/tools/context-sandbox.ts +11 -7
- package/tools/feature-tools.ts +125 -8
- package/tools/frappe-context7.ts +28 -32
- package/tools/frappe-query-tools.ts +75 -20
- package/tools/project-tools.ts +14 -11
- package/dist/coverage-check-DLGO_qwW.mjs +0 -55
- package/dist/db-Cx_EyUEu.mjs +0 -58
- package/dist/frappe-gates-c4HHJp-4.mjs +0 -349
- package/dist/frappe-session-BfFveYq1.mjs +0 -5
- package/dist/frappe-session-BzM5oUCb.mjs +0 -5
- package/dist/frappe-state-k--gX3wq.mjs +0 -6
- package/dist/frappe-tools-Dwz0eEQ-.mjs +0 -13
- package/dist/frappe-ui-htmQgO8t.mjs +0 -3
- package/dist/frappe-workflow-VId2tr9e.mjs +0 -4
- package/dist/fsm-DkLob1CA.mjs +0 -3
- package/dist/init-ChmHonBN.mjs +0 -159
- package/dist/loader-DC2PlJU7.mjs +0 -68
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { routeThroughContextMode } from "./context-sandbox.js";
|
|
1
|
+
import { db } from "../state/db.js";
|
|
2
|
+
import { applyTruncationFallback } from "./context-sandbox.js";
|
|
4
3
|
|
|
5
4
|
export interface FrappeQueryArgs {
|
|
6
5
|
doctype: string;
|
|
@@ -12,33 +11,89 @@ export interface FrappeQueryResult {
|
|
|
12
11
|
error?: string;
|
|
13
12
|
}
|
|
14
13
|
|
|
14
|
+
interface SessionCredentials {
|
|
15
|
+
site_url: string | null;
|
|
16
|
+
api_key: string | null;
|
|
17
|
+
api_secret: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Retries a fetch on transient server errors (5xx) and rate limiting (429).
|
|
22
|
+
* Uses linear backoff (1s × attempt). Passes through 4xx client errors immediately.
|
|
23
|
+
*/
|
|
24
|
+
async function fetchWithRetry(url: string, options: RequestInit, maxRetries = 3): Promise<Response> {
|
|
25
|
+
let lastResponse: Response | undefined;
|
|
26
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
27
|
+
const response = await fetch(url, options);
|
|
28
|
+
if (response.ok || (response.status >= 400 && response.status < 500 && response.status !== 429)) {
|
|
29
|
+
return response;
|
|
30
|
+
}
|
|
31
|
+
lastResponse = response;
|
|
32
|
+
if (attempt < maxRetries) {
|
|
33
|
+
await new Promise((r) => setTimeout(r, 1000 * attempt));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return lastResponse!;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Builds and validates the Frappe REST resource URL.
|
|
41
|
+
* Strips trailing slash from siteUrl, uses URL constructor for validation.
|
|
42
|
+
* Returns null if siteUrl is not a valid URL.
|
|
43
|
+
*/
|
|
44
|
+
function buildResourceUrl(siteUrl: string, doctype: string, params: URLSearchParams): string | null {
|
|
45
|
+
try {
|
|
46
|
+
const base = siteUrl.replace(/\/$/, "");
|
|
47
|
+
const url = new URL(`${base}/api/resource/${encodeURIComponent(doctype)}`);
|
|
48
|
+
url.search = params.toString();
|
|
49
|
+
return url.toString();
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
15
55
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
56
|
+
* Queries Frappe data via direct REST API call using credentials stored in the
|
|
57
|
+
* active session (set via set_active_project).
|
|
58
|
+
* Output is truncation-guarded — never returns raw payloads over 8K tokens.
|
|
18
59
|
* Returns structured error on failure, never throws.
|
|
19
|
-
*
|
|
20
|
-
* Note: mcp2cli command syntax uses --mcp <url> --raw <tool_name> <json_args>.
|
|
21
|
-
* Adapt args array if actual mcp2cli CLI syntax differs.
|
|
22
60
|
*/
|
|
23
61
|
export async function frappeQuery({ doctype, filters }: FrappeQueryArgs): Promise<FrappeQueryResult> {
|
|
24
|
-
const
|
|
62
|
+
const session = db
|
|
63
|
+
.prepare("SELECT site_url, api_key, api_secret FROM sessions WHERE is_active = 1 LIMIT 1")
|
|
64
|
+
.get() as SessionCredentials | undefined;
|
|
25
65
|
|
|
26
|
-
if (!
|
|
27
|
-
return { error: "
|
|
66
|
+
if (!session?.site_url) {
|
|
67
|
+
return { error: "No site_url configured. Call set_active_project with siteUrl, apiKey, and apiSecret first." };
|
|
68
|
+
}
|
|
69
|
+
if (!session.api_key || !session.api_secret) {
|
|
70
|
+
return { error: "No API credentials configured. Call set_active_project with apiKey and apiSecret." };
|
|
28
71
|
}
|
|
29
72
|
|
|
30
73
|
try {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
74
|
+
const params = new URLSearchParams();
|
|
75
|
+
if (filters && Object.keys(filters).length > 0) {
|
|
76
|
+
params.set("filters", JSON.stringify(filters));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const url = buildResourceUrl(session.site_url, doctype, params);
|
|
80
|
+
if (!url) {
|
|
81
|
+
return { error: `Invalid site_url: "${session.site_url}". Must be a valid URL (e.g. http://site1.localhost).` };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const response = await fetchWithRetry(url, {
|
|
85
|
+
headers: {
|
|
86
|
+
Authorization: `token ${session.api_key}:${session.api_secret}`,
|
|
87
|
+
"Content-Type": "application/json",
|
|
88
|
+
},
|
|
89
|
+
});
|
|
38
90
|
|
|
39
|
-
if (
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
return { error: `Frappe API error ${response.status}: ${response.statusText}` };
|
|
93
|
+
}
|
|
40
94
|
|
|
41
|
-
const
|
|
95
|
+
const raw = await response.text();
|
|
96
|
+
const summary = applyTruncationFallback(raw);
|
|
42
97
|
return { summary };
|
|
43
98
|
} catch (err) {
|
|
44
99
|
const msg = err instanceof Error ? err.message : String(err);
|
package/tools/project-tools.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { db, switchProject } from "../state/db.js";
|
|
1
|
+
import { db, switchProject, type ProjectCredentials } from "../state/db.js";
|
|
2
2
|
import { reloadSessionContext } from "../extensions/frappe-session.js";
|
|
3
3
|
|
|
4
4
|
export interface ComponentStatus {
|
|
@@ -77,30 +77,33 @@ export function getProjectStatus(_args?: unknown): ProjectStatus {
|
|
|
77
77
|
interface SetActiveProjectArgs {
|
|
78
78
|
projectId: string;
|
|
79
79
|
sitePath: string;
|
|
80
|
+
appPath?: string;
|
|
81
|
+
siteUrl?: string;
|
|
82
|
+
apiKey?: string;
|
|
83
|
+
apiSecret?: string;
|
|
80
84
|
}
|
|
81
85
|
|
|
82
86
|
/**
|
|
83
87
|
* Switches the active Frappe project and site, flushes current session state,
|
|
84
88
|
* creates a new session, and reloads the system prompt context.
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* no second appendEntry() call here.
|
|
89
|
+
* Site credentials (siteUrl, apiKey, apiSecret) are stored in the session row
|
|
90
|
+
* and used by frappe_query for direct REST API calls.
|
|
88
91
|
*/
|
|
89
|
-
export async function setActiveProject({ projectId, sitePath }: SetActiveProjectArgs) {
|
|
90
|
-
|
|
91
|
-
switchProject(projectId,
|
|
92
|
+
export async function setActiveProject({ projectId, sitePath, appPath, siteUrl, apiKey, apiSecret }: SetActiveProjectArgs) {
|
|
93
|
+
const creds: ProjectCredentials = { sitePath, appPath, siteUrl, apiKey, apiSecret };
|
|
94
|
+
switchProject(projectId, creds);
|
|
92
95
|
|
|
93
|
-
// Reload system prompt with new project context
|
|
94
96
|
await reloadSessionContext();
|
|
95
97
|
|
|
96
|
-
// Read restored phase for return value
|
|
97
98
|
const session = db
|
|
98
|
-
.prepare("SELECT current_phase FROM sessions WHERE is_active = 1 LIMIT 1")
|
|
99
|
-
.get() as { current_phase: string } | undefined;
|
|
99
|
+
.prepare("SELECT current_phase, site_url FROM sessions WHERE is_active = 1 LIMIT 1")
|
|
100
|
+
.get() as { current_phase: string; site_url: string | null } | undefined;
|
|
100
101
|
|
|
101
102
|
return {
|
|
102
103
|
project_id: projectId,
|
|
103
104
|
site_path: sitePath,
|
|
105
|
+
app_path: appPath ?? null,
|
|
106
|
+
site_url: session?.site_url ?? null,
|
|
104
107
|
phase: session?.current_phase ?? "idle",
|
|
105
108
|
context_reloaded: true,
|
|
106
109
|
};
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { execa } from "execa";
|
|
3
|
-
//#region \0rolldown/runtime.js
|
|
4
|
-
var __defProp = Object.defineProperty;
|
|
5
|
-
var __exportAll = (all, no_symbols) => {
|
|
6
|
-
let target = {};
|
|
7
|
-
for (var name in all) __defProp(target, name, {
|
|
8
|
-
get: all[name],
|
|
9
|
-
enumerable: true
|
|
10
|
-
});
|
|
11
|
-
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
12
|
-
return target;
|
|
13
|
-
};
|
|
14
|
-
//#endregion
|
|
15
|
-
//#region gates/coverage-check.ts
|
|
16
|
-
/**
|
|
17
|
-
* gates/coverage-check.ts — Async coverage gate (NOT a pure function).
|
|
18
|
-
* Runs `bench run-tests --coverage` via execa and parses the result.
|
|
19
|
-
*
|
|
20
|
-
* NOT wired via frappe-gates.ts — integrated directly in completeComponent().
|
|
21
|
-
* Triggers when the final component is completed in `testing` phase. (FR29)
|
|
22
|
-
*/
|
|
23
|
-
var coverage_check_exports = /* @__PURE__ */ __exportAll({ coverageCheck: () => coverageCheck });
|
|
24
|
-
function parseCoverage(stdout) {
|
|
25
|
-
const match = stdout.match(/^TOTAL\s+\d+\s+\d+\s+(\d+)%/m);
|
|
26
|
-
return match ? parseInt(match[1], 10) : 0;
|
|
27
|
-
}
|
|
28
|
-
async function coverageCheck(_context) {
|
|
29
|
-
try {
|
|
30
|
-
const { stdout } = await execa("bench", ["run-tests", "--coverage"], {
|
|
31
|
-
cwd: process.env.FRAPPE_BENCH_PATH ?? process.cwd(),
|
|
32
|
-
reject: false,
|
|
33
|
-
timeout: 12e4
|
|
34
|
-
});
|
|
35
|
-
const coverage = parseCoverage(stdout ?? "");
|
|
36
|
-
if (coverage < 70) return {
|
|
37
|
-
passed: false,
|
|
38
|
-
coverage,
|
|
39
|
-
required: "70%"
|
|
40
|
-
};
|
|
41
|
-
return {
|
|
42
|
-
passed: true,
|
|
43
|
-
coverage
|
|
44
|
-
};
|
|
45
|
-
} catch (err) {
|
|
46
|
-
return {
|
|
47
|
-
passed: false,
|
|
48
|
-
coverage: 0,
|
|
49
|
-
required: "70%",
|
|
50
|
-
error: err.message
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
//#endregion
|
|
55
|
-
export { __exportAll as n, coverage_check_exports as t };
|
package/dist/db-Cx_EyUEu.mjs
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import "node:os";
|
|
3
|
-
import "node:path";
|
|
4
|
-
import { mkdirSync } from "node:fs";
|
|
5
|
-
import Database from "better-sqlite3";
|
|
6
|
-
//#region state/schema.ts
|
|
7
|
-
/**
|
|
8
|
-
* Runs all CREATE TABLE IF NOT EXISTS statements against the provided database.
|
|
9
|
-
* Safe to call multiple times — idempotent by design.
|
|
10
|
-
*/
|
|
11
|
-
function initSchema(db) {
|
|
12
|
-
db.exec(`
|
|
13
|
-
CREATE TABLE IF NOT EXISTS features (
|
|
14
|
-
feature_id TEXT PRIMARY KEY,
|
|
15
|
-
name TEXT NOT NULL,
|
|
16
|
-
mode TEXT NOT NULL DEFAULT 'full' CHECK (mode IN ('full', 'quick')),
|
|
17
|
-
current_phase TEXT NOT NULL DEFAULT 'idle',
|
|
18
|
-
created_at TEXT NOT NULL,
|
|
19
|
-
updated_at TEXT,
|
|
20
|
-
progress_done INTEGER DEFAULT 0,
|
|
21
|
-
progress_total INTEGER DEFAULT 0
|
|
22
|
-
);
|
|
23
|
-
|
|
24
|
-
CREATE TABLE IF NOT EXISTS components (
|
|
25
|
-
component_id TEXT NOT NULL,
|
|
26
|
-
feature_id TEXT NOT NULL,
|
|
27
|
-
status TEXT NOT NULL DEFAULT 'in-progress',
|
|
28
|
-
completed_at TEXT,
|
|
29
|
-
PRIMARY KEY (feature_id, component_id),
|
|
30
|
-
FOREIGN KEY (feature_id) REFERENCES features(feature_id)
|
|
31
|
-
);
|
|
32
|
-
|
|
33
|
-
CREATE TABLE IF NOT EXISTS sessions (
|
|
34
|
-
session_id TEXT PRIMARY KEY,
|
|
35
|
-
project_id TEXT NOT NULL,
|
|
36
|
-
current_phase TEXT NOT NULL DEFAULT 'idle',
|
|
37
|
-
site_path TEXT,
|
|
38
|
-
feature_id TEXT,
|
|
39
|
-
last_tool TEXT,
|
|
40
|
-
started_at TEXT NOT NULL,
|
|
41
|
-
ended_at TEXT,
|
|
42
|
-
is_active INTEGER NOT NULL DEFAULT 1
|
|
43
|
-
);
|
|
44
|
-
`);
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* Overrides the sessions directory — intended for test use only.
|
|
48
|
-
* Pass null to reset to the default ~/.frappe-builder/sessions path.
|
|
49
|
-
*/
|
|
50
|
-
function setSessionsDir(dir) {}
|
|
51
|
-
//#endregion
|
|
52
|
-
//#region state/db.ts
|
|
53
|
-
mkdirSync(".fb", { recursive: true });
|
|
54
|
-
/** Singleton SQLite connection — the only Database instance in the codebase. */
|
|
55
|
-
let db = new Database(".fb/state.db");
|
|
56
|
-
initSchema(db);
|
|
57
|
-
//#endregion
|
|
58
|
-
export { setSessionsDir as n, db as t };
|
|
@@ -1,349 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { n as __exportAll, t as coverage_check_exports } from "./coverage-check-DLGO_qwW.mjs";
|
|
3
|
-
import "./db-Cx_EyUEu.mjs";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
//#region gates/frappe-native-check.ts
|
|
6
|
-
const NON_NATIVE_PATTERNS = [
|
|
7
|
-
{
|
|
8
|
-
pattern: /import\s+axios/,
|
|
9
|
-
label: "axios (use frappe.call)"
|
|
10
|
-
},
|
|
11
|
-
{
|
|
12
|
-
pattern: /require\(['"]axios['"]\)/,
|
|
13
|
-
label: "axios (use frappe.call)"
|
|
14
|
-
},
|
|
15
|
-
{
|
|
16
|
-
pattern: /import\s+requests/,
|
|
17
|
-
label: "requests (use frappe.call)"
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
pattern: /from\s+requests\s+import/,
|
|
21
|
-
label: "requests (use frappe.call)"
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
pattern: /cursor\.execute\s*\(/,
|
|
25
|
-
label: "raw SQL cursor (use frappe.db.sql or ORM)"
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
pattern: /pymysql|psycopg2|sqlite3\.connect/,
|
|
29
|
-
label: "direct DB driver (use Frappe ORM)"
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
pattern: /mongoose|sequelize|typeorm/i,
|
|
33
|
-
label: "external ORM (use Frappe DocType)"
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
pattern: /jwt\.sign|jsonwebtoken/,
|
|
37
|
-
label: "JWT auth (use frappe.whitelist + session)"
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
pattern: /passport\.authenticate/,
|
|
41
|
-
label: "passport auth (use Frappe auth)"
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
pattern: /import\s+pandas/,
|
|
45
|
-
label: "pandas (use Frappe Script Report)"
|
|
46
|
-
},
|
|
47
|
-
{
|
|
48
|
-
pattern: /import\s+numpy/,
|
|
49
|
-
label: "numpy (use Frappe computation)"
|
|
50
|
-
},
|
|
51
|
-
{
|
|
52
|
-
pattern: /fs\.writeFileSync|fs\.readFileSync/,
|
|
53
|
-
label: "raw fs (use frappe.get_file_url or File DocType)"
|
|
54
|
-
},
|
|
55
|
-
{
|
|
56
|
-
pattern: /express\(\)|fastapi|flask\.Flask/,
|
|
57
|
-
label: "external web framework (use Frappe whitelist)"
|
|
58
|
-
}
|
|
59
|
-
];
|
|
60
|
-
/**
|
|
61
|
-
* Scans `content` for non-Frappe-native patterns.
|
|
62
|
-
*
|
|
63
|
-
* - No detected patterns → { passed: true }
|
|
64
|
-
* - Detected + no justification → { passed: false, requiresJustification: true, ... }
|
|
65
|
-
* - Detected + justification provided → { passed: true, result: "override", ... }
|
|
66
|
-
*/
|
|
67
|
-
function checkFrappeNative(content, justification) {
|
|
68
|
-
const detectedPatterns = NON_NATIVE_PATTERNS.filter(({ pattern }) => pattern.test(content)).map(({ label }) => label);
|
|
69
|
-
if (detectedPatterns.length === 0) return { passed: true };
|
|
70
|
-
if (justification) return {
|
|
71
|
-
passed: true,
|
|
72
|
-
result: "override",
|
|
73
|
-
justification,
|
|
74
|
-
detectedPatterns
|
|
75
|
-
};
|
|
76
|
-
return {
|
|
77
|
-
passed: false,
|
|
78
|
-
requiresJustification: true,
|
|
79
|
-
message: "This uses a non-Frappe-native approach. Justification required to proceed.",
|
|
80
|
-
detectedPatterns
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
//#endregion
|
|
84
|
-
//#region gates/permission-check.ts
|
|
85
|
-
var permission_check_exports = /* @__PURE__ */ __exportAll({ permissionCheck: () => permissionCheck });
|
|
86
|
-
const PERMISSION_PATTERNS = [
|
|
87
|
-
/frappe\.has_permission\s*\(/,
|
|
88
|
-
/frappe\.only_for\s*\(/,
|
|
89
|
-
/frappe\.check_permission\s*\(/,
|
|
90
|
-
/frappe\.has_role\s*\(/,
|
|
91
|
-
/frappe\.session\.user/
|
|
92
|
-
];
|
|
93
|
-
function getLineNumber(code, offset) {
|
|
94
|
-
return code.slice(0, offset).split("\n").length;
|
|
95
|
-
}
|
|
96
|
-
function extractFunctionBody(code, defStart) {
|
|
97
|
-
const bodyStart = code.indexOf("\n", defStart) + 1;
|
|
98
|
-
const nextTopLevel = /\n(?=def |class |@)/g;
|
|
99
|
-
nextTopLevel.lastIndex = bodyStart;
|
|
100
|
-
const match = nextTopLevel.exec(code);
|
|
101
|
-
return match ? code.slice(bodyStart, match.index) : code.slice(bodyStart);
|
|
102
|
-
}
|
|
103
|
-
function hasPermissionCheck(body) {
|
|
104
|
-
return PERMISSION_PATTERNS.some((p) => p.test(body));
|
|
105
|
-
}
|
|
106
|
-
function permissionCheck(code, context) {
|
|
107
|
-
if (!context.file.endsWith(".py")) return { passed: true };
|
|
108
|
-
const WHITELIST_DECORATOR = /@frappe\.whitelist\([^)]*\)\s*\ndef\s+(\w+)/g;
|
|
109
|
-
const violations = [];
|
|
110
|
-
let match;
|
|
111
|
-
while ((match = WHITELIST_DECORATOR.exec(code)) !== null) {
|
|
112
|
-
const decoratorOffset = match.index;
|
|
113
|
-
const methodName = match[1];
|
|
114
|
-
const line = getLineNumber(code, decoratorOffset);
|
|
115
|
-
if (!hasPermissionCheck(extractFunctionBody(code, code.indexOf("\ndef ", decoratorOffset) + 1))) violations.push({
|
|
116
|
-
method: methodName,
|
|
117
|
-
line,
|
|
118
|
-
reason: "missing permission check"
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
if (violations.length > 0) return {
|
|
122
|
-
passed: false,
|
|
123
|
-
violations
|
|
124
|
-
};
|
|
125
|
-
return { passed: true };
|
|
126
|
-
}
|
|
127
|
-
//#endregion
|
|
128
|
-
//#region gates/query-check.ts
|
|
129
|
-
var query_check_exports = /* @__PURE__ */ __exportAll({ queryCheck: () => queryCheck });
|
|
130
|
-
const SQL_KEYWORD = /\b(SELECT|INSERT|UPDATE|DELETE|FROM|WHERE)\b/i;
|
|
131
|
-
const INJECTION_PATTERNS = [
|
|
132
|
-
{
|
|
133
|
-
pattern: /f["'].*\{[^}]+\}.*["']/,
|
|
134
|
-
label: "f-string interpolation"
|
|
135
|
-
},
|
|
136
|
-
{
|
|
137
|
-
pattern: /["']\s*\+\s*\w/,
|
|
138
|
-
label: "string concatenation (+)"
|
|
139
|
-
},
|
|
140
|
-
{
|
|
141
|
-
pattern: /["']\s*%\s*\w/,
|
|
142
|
-
label: "%-format interpolation"
|
|
143
|
-
}
|
|
144
|
-
];
|
|
145
|
-
const REASON$1 = "SQL string concatenation detected — use parameterised queries";
|
|
146
|
-
function queryCheck(code, context) {
|
|
147
|
-
if (!context.file.endsWith(".py")) return { passed: true };
|
|
148
|
-
const lines = code.split("\n");
|
|
149
|
-
const violations = [];
|
|
150
|
-
for (let i = 0; i < lines.length; i++) {
|
|
151
|
-
const line = lines[i];
|
|
152
|
-
if (!SQL_KEYWORD.test(line)) continue;
|
|
153
|
-
for (const { pattern, label } of INJECTION_PATTERNS) if (pattern.test(line)) {
|
|
154
|
-
violations.push({
|
|
155
|
-
line: i + 1,
|
|
156
|
-
pattern: label,
|
|
157
|
-
reason: REASON$1
|
|
158
|
-
});
|
|
159
|
-
break;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
return violations.length > 0 ? {
|
|
163
|
-
passed: false,
|
|
164
|
-
violations
|
|
165
|
-
} : { passed: true };
|
|
166
|
-
}
|
|
167
|
-
//#endregion
|
|
168
|
-
//#region gates/server-side-check.ts
|
|
169
|
-
var server_side_check_exports = /* @__PURE__ */ __exportAll({ serverSideCheck: () => serverSideCheck });
|
|
170
|
-
const BUSINESS_LOGIC_PATTERNS = [
|
|
171
|
-
{
|
|
172
|
-
pattern: /frm\.doc\.\w+\s*=\s*frm\.doc\.\w+\s*[*\/+\-]\s*frm\.doc\.\w+/,
|
|
173
|
-
label: "client-side financial calculation"
|
|
174
|
-
},
|
|
175
|
-
{
|
|
176
|
-
pattern: /frappe\.session\.user\s*[=!]={1,2}\s*["']/,
|
|
177
|
-
label: "client-side permission decision (user identity)"
|
|
178
|
-
},
|
|
179
|
-
{
|
|
180
|
-
pattern: /frappe\.user\.has_role\s*\(/,
|
|
181
|
-
label: "client-side permission decision (role check)"
|
|
182
|
-
},
|
|
183
|
-
{
|
|
184
|
-
pattern: /frappe\.user_roles\b/,
|
|
185
|
-
label: "client-side permission decision (user_roles)"
|
|
186
|
-
},
|
|
187
|
-
{
|
|
188
|
-
pattern: /frappe\.db\.(get_value|get_list|set_value|insert|delete_doc)\s*\(/,
|
|
189
|
-
label: "direct DB access from client-side (use frappe.call instead)"
|
|
190
|
-
}
|
|
191
|
-
];
|
|
192
|
-
const REASON = "business logic must be server-side";
|
|
193
|
-
function isTestFile(file) {
|
|
194
|
-
return file.includes("/test/") || file.includes(".test.") || file.includes(".spec.");
|
|
195
|
-
}
|
|
196
|
-
function serverSideCheck(code, context) {
|
|
197
|
-
if (!context.file.endsWith(".js") && !context.file.endsWith(".ts")) return { passed: true };
|
|
198
|
-
if (isTestFile(context.file)) return { passed: true };
|
|
199
|
-
const lines = code.split("\n");
|
|
200
|
-
const violations = [];
|
|
201
|
-
for (let i = 0; i < lines.length; i++) {
|
|
202
|
-
const line = lines[i];
|
|
203
|
-
for (const { pattern, label } of BUSINESS_LOGIC_PATTERNS) if (pattern.test(line)) {
|
|
204
|
-
violations.push({
|
|
205
|
-
line: i + 1,
|
|
206
|
-
pattern: label,
|
|
207
|
-
reason: REASON
|
|
208
|
-
});
|
|
209
|
-
break;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
return violations.length > 0 ? {
|
|
213
|
-
passed: false,
|
|
214
|
-
violations
|
|
215
|
-
} : { passed: true };
|
|
216
|
-
}
|
|
217
|
-
//#endregion
|
|
218
|
-
//#region gates/style-check.ts
|
|
219
|
-
var style_check_exports = /* @__PURE__ */ __exportAll({ styleCheck: () => styleCheck });
|
|
220
|
-
const DEF_LINE = /^([^\S\n]*)def\s+(\w+)\s*\([^)]*\)\s*:/gm;
|
|
221
|
-
const NON_DESCRIPTIVE = /^(\s*)(x|y|z|data|temp|tmp|val|var|foo|bar|test|obj|res|ret)\s*=/gm;
|
|
222
|
-
const SQL_STRING = /frappe\.db\.sql\s*\(\s*["'`]{1,3}([\s\S]*?)["'`]{1,3}/g;
|
|
223
|
-
const CAMEL_IDENTIFIER = /\b([a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*)\b/g;
|
|
224
|
-
const STUB_BODY = /^\s*(pass|return|return None|super\(\)\.__init__\(.*?\))\s*$/;
|
|
225
|
-
function lineNumber(code, index) {
|
|
226
|
-
return code.slice(0, index).split("\n").length;
|
|
227
|
-
}
|
|
228
|
-
function hasDocstring(code, defStart) {
|
|
229
|
-
const bodyStart = code.indexOf("\n", defStart) + 1;
|
|
230
|
-
const bodyTrimmed = code.slice(bodyStart).trimStart();
|
|
231
|
-
return bodyTrimmed.startsWith("\"\"\"") || bodyTrimmed.startsWith("'''");
|
|
232
|
-
}
|
|
233
|
-
function isStubBody(code, defStart) {
|
|
234
|
-
const newline = code.indexOf("\n", defStart);
|
|
235
|
-
if (newline === -1) return true;
|
|
236
|
-
const nextNewline = code.indexOf("\n", newline + 1);
|
|
237
|
-
const bodyLine = nextNewline === -1 ? code.slice(newline + 1) : code.slice(newline + 1, nextNewline);
|
|
238
|
-
return STUB_BODY.test(bodyLine);
|
|
239
|
-
}
|
|
240
|
-
function checkPython(code, violations) {
|
|
241
|
-
let m;
|
|
242
|
-
DEF_LINE.lastIndex = 0;
|
|
243
|
-
while ((m = DEF_LINE.exec(code)) !== null) {
|
|
244
|
-
const fnName = m[2];
|
|
245
|
-
const defStart = m.index;
|
|
246
|
-
if (!hasDocstring(code, defStart) && !isStubBody(code, defStart)) violations.push({
|
|
247
|
-
function: fnName,
|
|
248
|
-
line: lineNumber(code, defStart),
|
|
249
|
-
reason: "missing docstring"
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
NON_DESCRIPTIVE.lastIndex = 0;
|
|
253
|
-
while ((m = NON_DESCRIPTIVE.exec(code)) !== null) {
|
|
254
|
-
const varName = m[2];
|
|
255
|
-
violations.push({
|
|
256
|
-
line: lineNumber(code, m.index),
|
|
257
|
-
variable: varName,
|
|
258
|
-
reason: "non-descriptive variable name"
|
|
259
|
-
});
|
|
260
|
-
}
|
|
261
|
-
SQL_STRING.lastIndex = 0;
|
|
262
|
-
while ((m = SQL_STRING.exec(code)) !== null) {
|
|
263
|
-
const sqlContent = m[1];
|
|
264
|
-
const sqlStart = m.index;
|
|
265
|
-
CAMEL_IDENTIFIER.lastIndex = 0;
|
|
266
|
-
let cm;
|
|
267
|
-
while ((cm = CAMEL_IDENTIFIER.exec(sqlContent)) !== null) violations.push({
|
|
268
|
-
line: lineNumber(code, sqlStart),
|
|
269
|
-
column: cm[1],
|
|
270
|
-
reason: "SQL column must be snake_case"
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
function checkFilename(context, violations) {
|
|
275
|
-
const basename = path.basename(context.file).replace(/\.(test|spec)\.(ts|js)$/, "").replace(/\.(ts|js)$/, "");
|
|
276
|
-
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(basename)) violations.push({
|
|
277
|
-
file: context.file,
|
|
278
|
-
reason: "TypeScript filename must be kebab-case"
|
|
279
|
-
});
|
|
280
|
-
}
|
|
281
|
-
function styleCheck(code, context) {
|
|
282
|
-
const violations = [];
|
|
283
|
-
const ext = path.extname(context.file);
|
|
284
|
-
if (ext === ".py") checkPython(code, violations);
|
|
285
|
-
else if (ext === ".ts" || ext === ".js") checkFilename(context, violations);
|
|
286
|
-
if (violations.length > 0) return {
|
|
287
|
-
passed: false,
|
|
288
|
-
violations
|
|
289
|
-
};
|
|
290
|
-
return { passed: true };
|
|
291
|
-
}
|
|
292
|
-
//#endregion
|
|
293
|
-
//#region extensions/frappe-gates.ts
|
|
294
|
-
function nativeAdapter(code, _context) {
|
|
295
|
-
const r = checkFrappeNative(code);
|
|
296
|
-
if (r.passed) return { passed: true };
|
|
297
|
-
return {
|
|
298
|
-
passed: false,
|
|
299
|
-
violations: [{
|
|
300
|
-
reason: r.message,
|
|
301
|
-
detectedPatterns: r.detectedPatterns
|
|
302
|
-
}]
|
|
303
|
-
};
|
|
304
|
-
}
|
|
305
|
-
function tryLoadGate(mod, exportName, gateName) {
|
|
306
|
-
const fn = mod[exportName];
|
|
307
|
-
if (typeof fn === "function") return {
|
|
308
|
-
name: gateName,
|
|
309
|
-
fn
|
|
310
|
-
};
|
|
311
|
-
console.warn(`[GATE WARNING: ${gateName} gate not yet implemented — skipping]`);
|
|
312
|
-
return null;
|
|
313
|
-
}
|
|
314
|
-
const GATE_REGISTRY = [{
|
|
315
|
-
name: "frappe_native",
|
|
316
|
-
fn: nativeAdapter
|
|
317
|
-
}];
|
|
318
|
-
for (const [mod, exportName, gateName] of [
|
|
319
|
-
[
|
|
320
|
-
permission_check_exports,
|
|
321
|
-
"permissionCheck",
|
|
322
|
-
"permission_check"
|
|
323
|
-
],
|
|
324
|
-
[
|
|
325
|
-
query_check_exports,
|
|
326
|
-
"queryCheck",
|
|
327
|
-
"query_check"
|
|
328
|
-
],
|
|
329
|
-
[
|
|
330
|
-
server_side_check_exports,
|
|
331
|
-
"serverSideCheck",
|
|
332
|
-
"server_side_check"
|
|
333
|
-
],
|
|
334
|
-
[
|
|
335
|
-
coverage_check_exports,
|
|
336
|
-
"coverageCheck",
|
|
337
|
-
"coverage_check"
|
|
338
|
-
],
|
|
339
|
-
[
|
|
340
|
-
style_check_exports,
|
|
341
|
-
"styleCheck",
|
|
342
|
-
"style_check"
|
|
343
|
-
]
|
|
344
|
-
]) {
|
|
345
|
-
const entry = tryLoadGate(mod, exportName, gateName);
|
|
346
|
-
if (entry) GATE_REGISTRY.push(entry);
|
|
347
|
-
}
|
|
348
|
-
//#endregion
|
|
349
|
-
export {};
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import "./coverage-check-DLGO_qwW.mjs";
|
|
3
|
-
import "./loader-DC2PlJU7.mjs";
|
|
4
|
-
import "./db-Cx_EyUEu.mjs";
|
|
5
|
-
import "./fsm-DkLob1CA.mjs";
|
|
6
|
-
import "./frappe-session-BzM5oUCb.mjs";
|
|
7
|
-
import { homedir } from "node:os";
|
|
8
|
-
import { join } from "node:path";
|
|
9
|
-
import "node:fs";
|
|
10
|
-
import "execa";
|
|
11
|
-
join(join(homedir(), ".frappe-builder"), "allowed-commands.json");
|
|
12
|
-
//#endregion
|
|
13
|
-
export {};
|
package/dist/fsm-DkLob1CA.mjs
DELETED