ltcai 8.8.0 → 8.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.
- package/README.md +34 -27
- package/auto_setup.py +73 -8
- package/docs/CHANGELOG.md +37 -0
- package/docs/CODE_REVIEW_2026-07-06.md +764 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +3 -3
- package/docs/DEVELOPMENT.md +10 -10
- package/docs/LEGACY_COMPATIBILITY.md +1 -1
- package/docs/ONBOARDING.md +2 -2
- package/docs/PRODUCT_DIRECTION_REVIEW.md +3 -2
- package/docs/TRUST_MODEL.md +5 -1
- package/docs/WHY_LATTICE.md +4 -3
- package/docs/architecture.md +4 -0
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/conversations.py +156 -21
- package/lattice_brain/graph/_kg_common.py +12 -34
- package/lattice_brain/graph/json_utils.py +25 -0
- package/lattice_brain/graph/retrieval.py +66 -27
- package/lattice_brain/graph/runtime.py +16 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +31 -14
- package/latticeai/api/mcp.py +3 -2
- package/latticeai/api/models.py +4 -1
- package/latticeai/api/permissions.py +69 -30
- package/latticeai/api/setup.py +17 -2
- package/latticeai/api/tools.py +104 -62
- package/latticeai/app_factory.py +93 -10
- package/latticeai/core/agent.py +25 -7
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/sessions.py +11 -3
- package/latticeai/core/tool_registry.py +15 -4
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/runtime/bootstrap.py +1 -1
- package/latticeai/services/app_context.py +1 -0
- package/latticeai/services/architecture_readiness.py +2 -2
- package/latticeai/services/model_engines.py +79 -12
- package/latticeai/services/model_runtime.py +24 -4
- package/latticeai/services/process_audit.py +208 -0
- package/latticeai/services/product_readiness.py +11 -11
- package/latticeai/services/search_service.py +106 -30
- package/latticeai/services/tool_dispatch.py +66 -0
- package/latticeai/services/workspace_service.py +15 -0
- package/package.json +1 -1
- package/scripts/check_i18n_literals.mjs +20 -8
- package/scripts/i18n_literal_allowlist.json +34 -0
- package/scripts/lint_frontend.mjs +6 -2
- package/setup_wizard.py +185 -19
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +10 -10
- package/static/app/assets/{Act-C7K9wsO9.js → Act-fZokUnC0.js} +1 -1
- package/static/app/assets/{Brain-I1OSzxJu.js → Brain-DtyuWubr.js} +1 -1
- package/static/app/assets/{Capture-B3V4_5Xp.js → Capture-D5KV3Cu7.js} +1 -1
- package/static/app/assets/{Library-Cgj-EF50.js → Library-C9kyFkSt.js} +1 -1
- package/static/app/assets/{System-D1Lkei3I.js → System-VbChmX7r.js} +1 -1
- package/static/app/assets/{index--P0ksosz.js → index-DPdcPoF0.js} +5 -5
- package/static/app/assets/{primitives-BLqaKk5g.js → primitives-DFeanEV6.js} +1 -1
- package/static/app/assets/{textarea-CVQkN2Tk.js → textarea-CD8UNKIy.js} +1 -1
- package/static/app/index.html +1 -1
- package/static/sw.js +1 -1
|
@@ -42,9 +42,84 @@ class SearchService:
|
|
|
42
42
|
graph = self._require_graph()
|
|
43
43
|
return graph.filter_scoped_nodes(matches, allowed_workspaces)
|
|
44
44
|
|
|
45
|
+
def _graph_search(self, graph, query: str, *, limit: int, allowed_workspaces=None):
|
|
46
|
+
try:
|
|
47
|
+
return graph.search(query, limit, allowed_workspaces=allowed_workspaces)
|
|
48
|
+
except TypeError:
|
|
49
|
+
payload = graph.search(query, limit)
|
|
50
|
+
if allowed_workspaces is not None:
|
|
51
|
+
payload = {
|
|
52
|
+
**payload,
|
|
53
|
+
"matches": graph.filter_scoped_nodes(
|
|
54
|
+
payload.get("matches", []),
|
|
55
|
+
allowed_workspaces,
|
|
56
|
+
),
|
|
57
|
+
}
|
|
58
|
+
return payload
|
|
59
|
+
|
|
60
|
+
def _relationship_search(self, graph, *, allowed_workspaces=None, **kwargs):
|
|
61
|
+
try:
|
|
62
|
+
return graph.relationship_search(
|
|
63
|
+
**kwargs,
|
|
64
|
+
allowed_workspaces=allowed_workspaces,
|
|
65
|
+
)
|
|
66
|
+
except TypeError:
|
|
67
|
+
payload = graph.relationship_search(**kwargs)
|
|
68
|
+
if allowed_workspaces is not None:
|
|
69
|
+
kept = []
|
|
70
|
+
for rel in payload.get("relationships", []):
|
|
71
|
+
endpoints = [
|
|
72
|
+
{"id": (rel.get("source") or {}).get("id")},
|
|
73
|
+
{"id": (rel.get("target") or {}).get("id")},
|
|
74
|
+
]
|
|
75
|
+
if len(graph.filter_scoped_nodes(endpoints, allowed_workspaces)) == 2:
|
|
76
|
+
kept.append(rel)
|
|
77
|
+
payload = {**payload, "relationships": kept}
|
|
78
|
+
return payload
|
|
79
|
+
|
|
80
|
+
def _traverse(self, graph, node_id: str, *, depth: int, limit: int, allowed_workspaces=None):
|
|
81
|
+
try:
|
|
82
|
+
return graph.traverse(
|
|
83
|
+
node_id,
|
|
84
|
+
depth=depth,
|
|
85
|
+
limit=limit,
|
|
86
|
+
allowed_workspaces=allowed_workspaces,
|
|
87
|
+
)
|
|
88
|
+
except TypeError:
|
|
89
|
+
neighborhood = graph.traverse(node_id, depth=depth, limit=limit)
|
|
90
|
+
if allowed_workspaces is not None:
|
|
91
|
+
nodes = graph.filter_scoped_nodes(
|
|
92
|
+
neighborhood.get("nodes", []),
|
|
93
|
+
allowed_workspaces,
|
|
94
|
+
)
|
|
95
|
+
kept = {item.get("id") for item in nodes}
|
|
96
|
+
edges = [
|
|
97
|
+
edge for edge in neighborhood.get("edges", [])
|
|
98
|
+
if edge.get("from") in kept and edge.get("to") in kept
|
|
99
|
+
]
|
|
100
|
+
neighborhood = {**neighborhood, "nodes": nodes, "edges": edges}
|
|
101
|
+
return neighborhood
|
|
102
|
+
|
|
103
|
+
def _get_node(self, graph, node_id: str, *, allowed_workspaces=None):
|
|
104
|
+
try:
|
|
105
|
+
return graph.get_node(node_id, allowed_workspaces=allowed_workspaces)
|
|
106
|
+
except TypeError:
|
|
107
|
+
node = graph.get_node(node_id)
|
|
108
|
+
if allowed_workspaces is not None:
|
|
109
|
+
visible = graph.filter_scoped_nodes([node], allowed_workspaces)
|
|
110
|
+
if not visible:
|
|
111
|
+
raise ValueError(f"graph node not found: {node_id}")
|
|
112
|
+
return visible[0]
|
|
113
|
+
return node
|
|
114
|
+
|
|
45
115
|
def keyword_search(self, query: str, *, limit: int = 30, allowed_workspaces=None) -> Dict[str, Any]:
|
|
46
116
|
graph = self._require_graph()
|
|
47
|
-
payload =
|
|
117
|
+
payload = self._graph_search(
|
|
118
|
+
graph,
|
|
119
|
+
query,
|
|
120
|
+
limit=limit,
|
|
121
|
+
allowed_workspaces=allowed_workspaces,
|
|
122
|
+
)
|
|
48
123
|
matches = []
|
|
49
124
|
for rank, match in enumerate(payload.get("matches", []), start=1):
|
|
50
125
|
matches.append({
|
|
@@ -95,8 +170,18 @@ class SearchService:
|
|
|
95
170
|
graph = self._require_graph()
|
|
96
171
|
limit = max(1, min(int(limit or 30), 100))
|
|
97
172
|
expand_depth = max(0, min(int(expand_depth or 1), 3))
|
|
98
|
-
direct =
|
|
99
|
-
|
|
173
|
+
direct = self._graph_search(
|
|
174
|
+
graph,
|
|
175
|
+
query,
|
|
176
|
+
limit=max(limit, 10),
|
|
177
|
+
allowed_workspaces=allowed_workspaces,
|
|
178
|
+
).get("matches", [])
|
|
179
|
+
relationships = self._relationship_search(
|
|
180
|
+
graph,
|
|
181
|
+
query=query,
|
|
182
|
+
limit=limit,
|
|
183
|
+
allowed_workspaces=allowed_workspaces,
|
|
184
|
+
).get("relationships", [])
|
|
100
185
|
by_id: Dict[str, Dict[str, Any]] = {}
|
|
101
186
|
|
|
102
187
|
def add_node(node: Mapping[str, Any], score: float, reason: str, edge: Optional[Mapping[str, Any]] = None) -> None:
|
|
@@ -138,7 +223,13 @@ class SearchService:
|
|
|
138
223
|
if expand_depth <= 0:
|
|
139
224
|
continue
|
|
140
225
|
try:
|
|
141
|
-
neighborhood =
|
|
226
|
+
neighborhood = self._traverse(
|
|
227
|
+
graph,
|
|
228
|
+
match["id"],
|
|
229
|
+
depth=expand_depth,
|
|
230
|
+
limit=limit * 3,
|
|
231
|
+
allowed_workspaces=allowed_workspaces,
|
|
232
|
+
)
|
|
142
233
|
except Exception:
|
|
143
234
|
neighborhood = {"nodes": [], "edges": []}
|
|
144
235
|
edge_by_pair = {
|
|
@@ -262,23 +353,16 @@ class SearchService:
|
|
|
262
353
|
allowed_workspaces=None,
|
|
263
354
|
) -> Dict[str, Any]:
|
|
264
355
|
graph = self._require_graph()
|
|
265
|
-
node =
|
|
266
|
-
if allowed_workspaces is not None:
|
|
267
|
-
visible = graph.filter_scoped_nodes([node], allowed_workspaces)
|
|
268
|
-
if not visible:
|
|
269
|
-
raise ValueError(f"graph node not found: {node_id}")
|
|
270
|
-
node = visible[0]
|
|
356
|
+
node = self._get_node(graph, node_id, allowed_workspaces=allowed_workspaces)
|
|
271
357
|
payload = {"node": node}
|
|
272
358
|
if include_neighbors:
|
|
273
|
-
neighborhood =
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
]
|
|
281
|
-
neighborhood = {**neighborhood, "nodes": nodes, "edges": edges}
|
|
359
|
+
neighborhood = self._traverse(
|
|
360
|
+
graph,
|
|
361
|
+
node_id,
|
|
362
|
+
depth=depth,
|
|
363
|
+
limit=limit,
|
|
364
|
+
allowed_workspaces=allowed_workspaces,
|
|
365
|
+
)
|
|
282
366
|
payload["neighborhood"] = neighborhood
|
|
283
367
|
return payload
|
|
284
368
|
|
|
@@ -292,22 +376,14 @@ class SearchService:
|
|
|
292
376
|
allowed_workspaces=None,
|
|
293
377
|
) -> Dict[str, Any]:
|
|
294
378
|
graph = self._require_graph()
|
|
295
|
-
payload =
|
|
379
|
+
payload = self._relationship_search(
|
|
380
|
+
graph,
|
|
296
381
|
query=query,
|
|
297
382
|
node_id=node_id,
|
|
298
383
|
relationship_type=relationship_type,
|
|
299
384
|
limit=limit,
|
|
385
|
+
allowed_workspaces=allowed_workspaces,
|
|
300
386
|
)
|
|
301
|
-
if allowed_workspaces is not None:
|
|
302
|
-
kept = []
|
|
303
|
-
for rel in payload.get("relationships", []):
|
|
304
|
-
endpoints = [
|
|
305
|
-
{"id": (rel.get("source") or {}).get("id")},
|
|
306
|
-
{"id": (rel.get("target") or {}).get("id")},
|
|
307
|
-
]
|
|
308
|
-
if len(graph.filter_scoped_nodes(endpoints, allowed_workspaces)) == 2:
|
|
309
|
-
kept.append(rel)
|
|
310
|
-
payload = {**payload, "relationships": kept}
|
|
311
387
|
return payload
|
|
312
388
|
|
|
313
389
|
def index_status(self) -> Dict[str, Any]:
|
|
@@ -107,6 +107,53 @@ class ToolDispatchService:
|
|
|
107
107
|
detail=f"'{tool_name}' 툴은 관리자 전용입니다.",
|
|
108
108
|
)
|
|
109
109
|
|
|
110
|
+
def user_role(self, current_user: str) -> str:
|
|
111
|
+
users = self.load_users()
|
|
112
|
+
try:
|
|
113
|
+
return str(self.get_user_role(current_user, users) or "user")
|
|
114
|
+
except Exception:
|
|
115
|
+
return "user"
|
|
116
|
+
|
|
117
|
+
def enforce_policy(
|
|
118
|
+
self,
|
|
119
|
+
tool_name: str,
|
|
120
|
+
args: Optional[dict],
|
|
121
|
+
*,
|
|
122
|
+
current_user: str,
|
|
123
|
+
source: str,
|
|
124
|
+
require_auto_approval: bool = True,
|
|
125
|
+
trusted_admin: bool = False,
|
|
126
|
+
) -> ToolPolicy:
|
|
127
|
+
"""Authorize a tool call before any hook or handler can execute.
|
|
128
|
+
|
|
129
|
+
This is the shared policy gate for direct HTTP/MCP/tool surfaces. Agent
|
|
130
|
+
runtime uses the same policy data and blocks non-auto-approved steps in
|
|
131
|
+
its state machine so Computer Use direct endpoints can remain unchanged
|
|
132
|
+
when explicitly excluded from a hardening pass.
|
|
133
|
+
"""
|
|
134
|
+
policy = self.policy_for(tool_name, args or {})
|
|
135
|
+
if not trusted_admin:
|
|
136
|
+
self.check_role(tool_name, current_user)
|
|
137
|
+
if policy["destructive"] or policy["risk"] == "destructive":
|
|
138
|
+
raise HTTPException(
|
|
139
|
+
status_code=403,
|
|
140
|
+
detail=f"'{tool_name}' 툴은 파괴적 작업으로 차단되었습니다.",
|
|
141
|
+
)
|
|
142
|
+
if (
|
|
143
|
+
require_auto_approval
|
|
144
|
+
and not trusted_admin
|
|
145
|
+
and not policy["auto_approve"]
|
|
146
|
+
and self.user_role(current_user) != "admin"
|
|
147
|
+
):
|
|
148
|
+
raise HTTPException(
|
|
149
|
+
status_code=403,
|
|
150
|
+
detail=(
|
|
151
|
+
f"'{tool_name}' 툴은 명시 승인이 필요합니다. "
|
|
152
|
+
"8.9.0에서는 승인 UI가 없는 직접 실행 경로에서 기본 차단됩니다."
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
return policy
|
|
156
|
+
|
|
110
157
|
def rollback_file(self, path: str) -> Dict[str, Any]:
|
|
111
158
|
r = subprocess.run(
|
|
112
159
|
["git", "checkout", "--", path],
|
|
@@ -160,6 +207,25 @@ def check_tool_role(tool_name: str, current_user: str) -> None:
|
|
|
160
207
|
DEFAULT_TOOL_DISPATCH_SERVICE.check_role(tool_name, current_user)
|
|
161
208
|
|
|
162
209
|
|
|
210
|
+
def enforce_tool_policy(
|
|
211
|
+
tool_name: str,
|
|
212
|
+
args: Optional[dict],
|
|
213
|
+
*,
|
|
214
|
+
current_user: str,
|
|
215
|
+
source: str,
|
|
216
|
+
require_auto_approval: bool = True,
|
|
217
|
+
trusted_admin: bool = False,
|
|
218
|
+
) -> ToolPolicy:
|
|
219
|
+
return DEFAULT_TOOL_DISPATCH_SERVICE.enforce_policy(
|
|
220
|
+
tool_name,
|
|
221
|
+
args or {},
|
|
222
|
+
current_user=current_user,
|
|
223
|
+
source=source,
|
|
224
|
+
require_auto_approval=require_auto_approval,
|
|
225
|
+
trusted_admin=trusted_admin,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
163
229
|
def collect_created_files(transcript: list) -> list:
|
|
164
230
|
files = []
|
|
165
231
|
for step in transcript:
|
|
@@ -79,6 +79,21 @@ class WorkspaceService:
|
|
|
79
79
|
def can_write(self, workspace_id: str, user_id: Optional[str]) -> bool:
|
|
80
80
|
return self.store.has_permission(workspace_id, self._identity(user_id), "write")
|
|
81
81
|
|
|
82
|
+
def readable_workspaces(self, user_id: Optional[str]) -> list[str]:
|
|
83
|
+
"""Return workspace ids the caller can read.
|
|
84
|
+
|
|
85
|
+
This keeps scoped read APIs from each reconstructing membership logic
|
|
86
|
+
from raw workspace state. The personal workspace remains readable via
|
|
87
|
+
the store's normal permission rules.
|
|
88
|
+
"""
|
|
89
|
+
resolved_user = self._identity(user_id)
|
|
90
|
+
workspaces = (self.store.load_state().get("workspaces") or {})
|
|
91
|
+
return [
|
|
92
|
+
str(workspace_id)
|
|
93
|
+
for workspace_id in workspaces
|
|
94
|
+
if self.store.has_permission(str(workspace_id), resolved_user, "read")
|
|
95
|
+
]
|
|
96
|
+
|
|
82
97
|
# ── record-level authorization (by-id access must not bypass gating) ──
|
|
83
98
|
|
|
84
99
|
def authorize_record_read(self, record: Dict[str, Any], user_id: Optional[str]) -> None:
|
package/package.json
CHANGED
|
@@ -4,10 +4,12 @@ import { join, relative } from "node:path";
|
|
|
4
4
|
|
|
5
5
|
const repo = join(import.meta.dirname, "..");
|
|
6
6
|
const roots = [
|
|
7
|
-
join(repo, "frontend", "src"
|
|
8
|
-
join(repo, "frontend", "src", "features", "admin"),
|
|
9
|
-
join(repo, "frontend", "src", "components", "onboarding"),
|
|
7
|
+
join(repo, "frontend", "src"),
|
|
10
8
|
];
|
|
9
|
+
const allowlistPath = join(repo, "scripts", "i18n_literal_allowlist.json");
|
|
10
|
+
const allowlist = existsSync(allowlistPath)
|
|
11
|
+
? JSON.parse(readFileSync(allowlistPath, "utf8"))
|
|
12
|
+
: {};
|
|
11
13
|
|
|
12
14
|
function walk(dir) {
|
|
13
15
|
if (!existsSync(dir)) return [];
|
|
@@ -16,7 +18,9 @@ function walk(dir) {
|
|
|
16
18
|
const path = join(dir, name);
|
|
17
19
|
const stat = statSync(path);
|
|
18
20
|
if (stat.isDirectory()) out.push(...walk(path));
|
|
19
|
-
else if (name.endsWith(".tsx")
|
|
21
|
+
else if ((name.endsWith(".tsx") || name.endsWith(".ts")) && name !== "openapi.ts") {
|
|
22
|
+
out.push(path);
|
|
23
|
+
}
|
|
20
24
|
}
|
|
21
25
|
return out;
|
|
22
26
|
}
|
|
@@ -25,6 +29,7 @@ const rawLocalizedProps = /\b(?:aria-label|placeholder|title)=["'][^"'{]*[A-Za-z
|
|
|
25
29
|
const rawJsxText = />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,})\s*</g;
|
|
26
30
|
const rawComponentCopy = /\b(?:title|detail|description|successLabel|empty)=["'][^"'{]*[A-Za-z][^"']*["']/g;
|
|
27
31
|
let failures = 0;
|
|
32
|
+
let allowed = 0;
|
|
28
33
|
|
|
29
34
|
for (const file of roots.flatMap(walk)) {
|
|
30
35
|
const text = readFileSync(file, "utf8");
|
|
@@ -38,9 +43,16 @@ for (const file of roots.flatMap(walk)) {
|
|
|
38
43
|
matches.push(`JSX text: ${literal}`);
|
|
39
44
|
}
|
|
40
45
|
if (!matches.length) continue;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
const rel = relative(repo, file);
|
|
47
|
+
const budget = Number(allowlist[rel]?.maxFindings || 0);
|
|
48
|
+
if (budget >= matches.length) {
|
|
49
|
+
allowed += matches.length;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const newMatches = matches.slice(budget);
|
|
53
|
+
failures += newMatches.length;
|
|
54
|
+
for (const match of newMatches) {
|
|
55
|
+
console.error(`${rel}: hardcoded localized prop: ${match}`);
|
|
44
56
|
}
|
|
45
57
|
}
|
|
46
58
|
|
|
@@ -49,4 +61,4 @@ if (failures) {
|
|
|
49
61
|
process.exit(1);
|
|
50
62
|
}
|
|
51
63
|
|
|
52
|
-
console.log(
|
|
64
|
+
console.log(`i18n literal check: localized props use translation keys (${allowed} allowlisted legacy literal(s))`);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"frontend/src/App.tsx": {
|
|
3
|
+
"maxFindings": 1,
|
|
4
|
+
"reason": "legacy shell aria label pending translation-key migration"
|
|
5
|
+
},
|
|
6
|
+
"frontend/src/components/LivingBrain.tsx": {
|
|
7
|
+
"maxFindings": 1,
|
|
8
|
+
"reason": "legacy visual component aria label pending translation-key migration"
|
|
9
|
+
},
|
|
10
|
+
"frontend/src/components/primitives.tsx": {
|
|
11
|
+
"maxFindings": 1,
|
|
12
|
+
"reason": "shared empty-state fallback pending translation-key migration"
|
|
13
|
+
},
|
|
14
|
+
"frontend/src/pages/Act.tsx": {
|
|
15
|
+
"maxFindings": 41,
|
|
16
|
+
"reason": "large legacy automation page pending incremental copy migration"
|
|
17
|
+
},
|
|
18
|
+
"frontend/src/pages/Brain.tsx": {
|
|
19
|
+
"maxFindings": 25,
|
|
20
|
+
"reason": "large legacy memory page pending incremental copy migration"
|
|
21
|
+
},
|
|
22
|
+
"frontend/src/pages/Capture.tsx": {
|
|
23
|
+
"maxFindings": 2,
|
|
24
|
+
"reason": "technical type-name literals are intentionally preserved for now"
|
|
25
|
+
},
|
|
26
|
+
"frontend/src/pages/Library.tsx": {
|
|
27
|
+
"maxFindings": 21,
|
|
28
|
+
"reason": "large legacy model/plugin page pending incremental copy migration"
|
|
29
|
+
},
|
|
30
|
+
"frontend/src/pages/System.tsx": {
|
|
31
|
+
"maxFindings": 118,
|
|
32
|
+
"reason": "large legacy system/admin page pending incremental copy migration"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -52,8 +52,12 @@ for (const file of files) {
|
|
|
52
52
|
console.log(`privacy: ${scanned} frontend/static files scanned`);
|
|
53
53
|
|
|
54
54
|
const client = readFileSync(join(frontend, "src", "api", "client.ts"), "utf8");
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
const apiBase = readFileSync(join(frontend, "src", "api", "base.ts"), "utf8");
|
|
56
|
+
if (
|
|
57
|
+
!(client.includes("openapi-fetch") || apiBase.includes("openapi-fetch")) ||
|
|
58
|
+
!(client.includes("./openapi") || apiBase.includes("./openapi"))
|
|
59
|
+
) {
|
|
60
|
+
fail("frontend/src/api/client.ts or base.ts must use the generated OpenAPI client");
|
|
57
61
|
}
|
|
58
62
|
if (!existsSync(join(frontend, "src", "api", "openapi.ts"))) {
|
|
59
63
|
fail("generated OpenAPI types missing");
|