loki-mode 7.129.5 → 8.0.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 +81 -5
- package/SKILL.md +66 -5
- package/VERSION +1 -1
- package/autonomy/app-runner.sh +37 -0
- package/autonomy/completion-council.sh +862 -28
- package/autonomy/council-v2.sh +39 -0
- package/autonomy/crash.sh +3 -2
- package/autonomy/grill.sh +42 -0
- package/autonomy/hooks/validate-bash.sh +301 -4
- package/autonomy/lib/cockpit-render.sh +8 -2
- package/autonomy/lib/cr-rematerialize.py +101 -4
- package/autonomy/lib/deadline.py +957 -0
- package/autonomy/lib/dependency-setup.sh +205 -0
- package/autonomy/lib/done-recognition.sh +45 -0
- package/autonomy/lib/efficiency_cost.py +13 -6
- package/autonomy/lib/no_mock_scan.py +793 -0
- package/autonomy/lib/prd-enrich.sh +42 -0
- package/autonomy/lib/proof-analytics-props.py +69 -0
- package/autonomy/lib/proof-generator.py +282 -95
- package/autonomy/lib/proof-template.html +15 -12
- package/autonomy/lib/proof-verify.py +151 -102
- package/autonomy/lib/requirements_contract.py +848 -0
- package/autonomy/lib/sdk-mode.sh +103 -0
- package/autonomy/lib/secret-scan.sh +139 -0
- package/autonomy/lib/spec-expand.sh +208 -0
- package/autonomy/lib/tree_digest.py +266 -0
- package/autonomy/lib/voter-agents.sh +58 -8
- package/autonomy/lib/workspace_diff.py +124 -0
- package/autonomy/loki +189 -17
- package/autonomy/playwright-verify.sh +501 -0
- package/autonomy/prd-checklist.sh +17 -8
- package/autonomy/provider-offer.sh +111 -0
- package/autonomy/run.sh +4417 -676
- package/autonomy/sandbox.sh +42 -0
- package/autonomy/spec-interrogation.sh +148 -5
- package/autonomy/spec.sh +118 -1
- package/autonomy/telemetry.sh +133 -7
- package/autonomy/verify.sh +107 -0
- package/bin/loki +110 -3
- package/completions/_loki +10 -0
- package/completions/loki.bash +1 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/audit.py +96 -7
- package/dashboard/build_supervisor.py +1619 -0
- package/dashboard/control.py +8 -0
- package/dashboard/prompt_optimizer.py +7 -0
- package/dashboard/server.py +577 -22
- package/dashboard/static/trust.html +1 -1
- package/docs/ARCHITECTURE-OVERVIEW.md +207 -0
- package/docs/AUTONOMI-ECOSYSTEM.md +107 -0
- package/docs/INSTALLATION.md +19 -2
- package/docs/MODEL-EQUIVALENCE-HARNESS-PLAN.md +70 -0
- package/docs/PLANS-INDEX.md +33 -0
- package/docs/PRIVACY.md +29 -1
- package/docs/SIGNED-RECEIPTS.md +102 -0
- package/docs/SONNET5-DEFAULT-PLAN.md +1 -1
- package/docs/V8-ACCEPTANCE-TRIAGE-2026-07-24.md +114 -0
- package/docs/V8-AGENT-SDK-PLAN.md +692 -0
- package/docs/V8-COMPLEXITY-AUDIT-2026-07-24.md +45 -0
- package/docs/V8-MAJOR-RELEASE-PLAN.md +137 -0
- package/docs/V8-OVERNIGHT-PLAN-2026-07-25.md +82 -0
- package/docs/V8-RUNTIME-TRUTH-2026-07-25.md +232 -0
- package/docs/V8-SDK-RESEARCH-RAW.md +129 -0
- package/docs/alternative-installations.md +4 -4
- package/loki-ts/dist/loki.js +674 -285
- package/loki-ts/package.json +2 -0
- package/mcp/__init__.py +1 -1
- package/package.json +7 -6
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/providers/claude.sh +75 -0
- package/providers/codex.sh +85 -13
- package/references/sdk-mode.md +106 -0
- package/skills/model-selection.md +1 -1
- package/skills/quality-gates.md +22 -0
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Detect rendered mock operational data with collection-level binding."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Iterable
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
RESOLUTION_EXTENSIONS = (
|
|
16
|
+
".ts", ".tsx", ".mts", ".js", ".jsx", ".mjs", ".cts", ".cjs", ".vue", ".svelte",
|
|
17
|
+
)
|
|
18
|
+
SOURCE_EXTENSIONS = frozenset(RESOLUTION_EXTENSIONS)
|
|
19
|
+
SOURCE_INDEX_EXCLUDE = re.compile(
|
|
20
|
+
r"(^|/)(node_modules|\.git|\.loki|\.next|\.cache|dist|build|out|coverage|"
|
|
21
|
+
r"storybook)(/|$)|\.d\.(?:ts|mts|cts)$",
|
|
22
|
+
re.IGNORECASE,
|
|
23
|
+
)
|
|
24
|
+
RENDER_EXCLUDE = re.compile(
|
|
25
|
+
r"(^|/)(node_modules|\.git|\.loki|\.next|\.cache|dist|build|out|coverage|"
|
|
26
|
+
r"__mocks__|__tests__|__fixtures__|fixtures?|mocks?|stories|storybook)(/|$)"
|
|
27
|
+
r"|\.(test|spec|stories|story|mock|fixture)\."
|
|
28
|
+
r"|\.d\.(?:ts|mts|cts)$|(^|/)msw|(^|/)setup",
|
|
29
|
+
re.IGNORECASE,
|
|
30
|
+
)
|
|
31
|
+
DECLARATION = re.compile(
|
|
32
|
+
r"\b(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*"
|
|
33
|
+
r"(?::[^=;]+)?=\s*",
|
|
34
|
+
re.MULTILINE,
|
|
35
|
+
)
|
|
36
|
+
NAMED_IMPORT = re.compile(
|
|
37
|
+
r"\bimport\s*\{([^}]+)\}\s*from\s*[\"']([^\"']+)[\"']",
|
|
38
|
+
re.MULTILINE,
|
|
39
|
+
)
|
|
40
|
+
DEFAULT_IMPORT = re.compile(
|
|
41
|
+
r"\bimport\s+(?!type\b)([A-Za-z_$][\w$]*)\s+from\s*[\"']([^\"']+)[\"']",
|
|
42
|
+
re.MULTILINE,
|
|
43
|
+
)
|
|
44
|
+
DEFAULT_EXPORT_IDENTIFIER = re.compile(
|
|
45
|
+
r"\bexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?",
|
|
46
|
+
re.MULTILINE,
|
|
47
|
+
)
|
|
48
|
+
DEFAULT_EXPORT = re.compile(r"\bexport\s+default\s+", re.MULTILINE)
|
|
49
|
+
NAMED_REEXPORT = re.compile(
|
|
50
|
+
r"\bexport\s*\{([^}]+)\}\s*from\s*[\"']([^\"']+)[\"']",
|
|
51
|
+
re.MULTILINE,
|
|
52
|
+
)
|
|
53
|
+
STAR_REEXPORT = re.compile(
|
|
54
|
+
r"\bexport\s*\*\s*from\s*[\"']([^\"']+)[\"']",
|
|
55
|
+
re.MULTILINE,
|
|
56
|
+
)
|
|
57
|
+
FAKER = re.compile(r"\bfaker\s*\.", re.IGNORECASE)
|
|
58
|
+
PLACEHOLDER_VALUE = re.compile(
|
|
59
|
+
r"lorem ipsum|john doe|jane doe|dummy data|example@example"
|
|
60
|
+
r"|\b(?:mock|fake|dummy|placeholder)(?:Data|Users?|Orders?|Customers?|Rows?|Records?)\b",
|
|
61
|
+
re.IGNORECASE,
|
|
62
|
+
)
|
|
63
|
+
EXPLICIT_MOCK_TOKEN = {"mock", "fake", "dummy", "placeholder", "sample", "fixture"}
|
|
64
|
+
OPERATIONAL_TOKEN = {
|
|
65
|
+
"user", "users", "order", "orders", "customer", "customers", "account",
|
|
66
|
+
"accounts", "invoice", "invoices", "payment", "payments", "transaction",
|
|
67
|
+
"transactions", "record", "records", "row", "rows", "ticket", "tickets",
|
|
68
|
+
"message", "messages", "notification", "notifications", "activity",
|
|
69
|
+
"activities", "event", "events", "metric", "metrics", "analytics",
|
|
70
|
+
"dashboard", "task", "tasks", "note", "notes", "product", "products",
|
|
71
|
+
"member", "members", "project", "projects", "job", "jobs", "lead",
|
|
72
|
+
"leads", "contact", "contacts", "subscription", "subscriptions",
|
|
73
|
+
"deployment", "deployments", "build", "builds",
|
|
74
|
+
}
|
|
75
|
+
STATIC_CONTENT_TOKEN = {
|
|
76
|
+
"feature", "features", "benefit", "benefits", "plan", "plans", "pricing",
|
|
77
|
+
"testimonial", "testimonials", "story", "stories", "comparison",
|
|
78
|
+
"comparisons", "faq", "faqs", "navigation", "nav", "logo", "logos",
|
|
79
|
+
"step", "steps", "tier", "tiers", "quote", "quotes",
|
|
80
|
+
}
|
|
81
|
+
JSX_TAG = re.compile(r"<[A-Za-z][A-Za-z0-9_.:-]*\b")
|
|
82
|
+
DIRECT_COLLECTION_PROP = re.compile(
|
|
83
|
+
r"<(?:DataGrid|Table|List|Grid)\b[^>]*\b(?:rows|dataSource|items|data)\s*=\s*"
|
|
84
|
+
r"\{\s*([A-Za-z_$][\w$]*)[^{}]{0,500}\}",
|
|
85
|
+
re.IGNORECASE | re.DOTALL,
|
|
86
|
+
)
|
|
87
|
+
VUE_LOOP = re.compile(
|
|
88
|
+
r"\bv-for\s*=\s*[\"'][^\"']*\bin\s+([A-Za-z_$][\w$]*)[^\"']*[\"']",
|
|
89
|
+
re.IGNORECASE,
|
|
90
|
+
)
|
|
91
|
+
SVELTE_LOOP = re.compile(
|
|
92
|
+
r"\{#each\s+([A-Za-z_$][\w$]*)\s+as\b",
|
|
93
|
+
re.IGNORECASE,
|
|
94
|
+
)
|
|
95
|
+
DIRECT_INLINE_MAP = re.compile(
|
|
96
|
+
r"\[\s*\{(?P<body>.{0,4000}?)\}\s*(?:,\s*\{.{0,4000}?\}\s*)*\]"
|
|
97
|
+
r"\s*\.map\s*\(",
|
|
98
|
+
re.DOTALL,
|
|
99
|
+
)
|
|
100
|
+
OPERATIONAL_FIELD = re.compile(
|
|
101
|
+
r"\b(?:id|email|role|status|amount|total|customer|order|user|invoice|payment|transaction)\s*:",
|
|
102
|
+
re.IGNORECASE,
|
|
103
|
+
)
|
|
104
|
+
STRONG_OPERATIONAL_FIELD = re.compile(
|
|
105
|
+
r"\b(?:email|role|amount|total|customer|order|user|invoice|payment|transaction)\s*:",
|
|
106
|
+
re.IGNORECASE,
|
|
107
|
+
)
|
|
108
|
+
STATIC_CONTENT_FIELD = re.compile(
|
|
109
|
+
r"\b(?:feature|title|description|benefit|plan|price|quote|question|answer|logo|step|tier)\s*:",
|
|
110
|
+
re.IGNORECASE,
|
|
111
|
+
)
|
|
112
|
+
STATE_DECLARATION = re.compile(
|
|
113
|
+
r"\b(?:export\s+)?(?:const|let|var)\s*\[\s*([A-Za-z_$][\w$]*)[^\]]*\]"
|
|
114
|
+
r"\s*(?::[^=;]+)?=\s*",
|
|
115
|
+
re.MULTILINE,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class Declaration:
|
|
121
|
+
file: str
|
|
122
|
+
name: str
|
|
123
|
+
expression: str
|
|
124
|
+
line: int
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _identifier_tokens(name: str) -> list[str]:
|
|
128
|
+
expanded = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", name)
|
|
129
|
+
return [token.lower() for token in re.split(r"[^A-Za-z0-9]+", expanded) if token]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _read_initializer(source: str, start: int) -> str:
|
|
133
|
+
"""Read a JavaScript initializer through its top-level semicolon."""
|
|
134
|
+
|
|
135
|
+
depth = 0
|
|
136
|
+
quote = ""
|
|
137
|
+
escaped = False
|
|
138
|
+
index = start
|
|
139
|
+
limit = min(len(source), start + 50000)
|
|
140
|
+
while index < limit:
|
|
141
|
+
char = source[index]
|
|
142
|
+
next_char = source[index + 1] if index + 1 < limit else ""
|
|
143
|
+
if quote:
|
|
144
|
+
if escaped:
|
|
145
|
+
escaped = False
|
|
146
|
+
elif char == "\\":
|
|
147
|
+
escaped = True
|
|
148
|
+
elif char == quote:
|
|
149
|
+
quote = ""
|
|
150
|
+
index += 1
|
|
151
|
+
continue
|
|
152
|
+
if char in "\"'`":
|
|
153
|
+
quote = char
|
|
154
|
+
elif char == "/" and next_char == "/":
|
|
155
|
+
newline = source.find("\n", index + 2, limit)
|
|
156
|
+
index = limit if newline < 0 else newline
|
|
157
|
+
continue
|
|
158
|
+
elif char == "/" and next_char == "*":
|
|
159
|
+
close = source.find("*/", index + 2, limit)
|
|
160
|
+
index = limit if close < 0 else close + 2
|
|
161
|
+
continue
|
|
162
|
+
elif char in "([{":
|
|
163
|
+
depth += 1
|
|
164
|
+
elif char in ")]}" and depth > 0:
|
|
165
|
+
depth -= 1
|
|
166
|
+
elif char == ";" and depth == 0:
|
|
167
|
+
return source[start:index]
|
|
168
|
+
elif char == "\n" and depth == 0:
|
|
169
|
+
remainder = source[index + 1:limit]
|
|
170
|
+
if re.match(
|
|
171
|
+
r"\s*(?:(?:export\s+)?(?:const|let|var|function|class)\b|"
|
|
172
|
+
r"import\b|export\s+(?:default|\{|\*))",
|
|
173
|
+
remainder,
|
|
174
|
+
):
|
|
175
|
+
return source[start:index]
|
|
176
|
+
index += 1
|
|
177
|
+
return source[start:limit]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _declarations(relative: str, source: str) -> dict[str, Declaration]:
|
|
181
|
+
found: dict[str, Declaration] = {}
|
|
182
|
+
for match in DECLARATION.finditer(source):
|
|
183
|
+
name = match.group(1)
|
|
184
|
+
found[name] = Declaration(
|
|
185
|
+
file=relative,
|
|
186
|
+
name=name,
|
|
187
|
+
expression=_read_initializer(source, match.end()),
|
|
188
|
+
line=source.count("\n", 0, match.start()) + 1,
|
|
189
|
+
)
|
|
190
|
+
for match in STATE_DECLARATION.finditer(source):
|
|
191
|
+
name = match.group(1)
|
|
192
|
+
found[name] = Declaration(
|
|
193
|
+
file=relative,
|
|
194
|
+
name=name,
|
|
195
|
+
expression=_read_initializer(source, match.end()),
|
|
196
|
+
line=source.count("\n", 0, match.start()) + 1,
|
|
197
|
+
)
|
|
198
|
+
return found
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _mock_reason(declaration: Declaration) -> str:
|
|
202
|
+
tokens = _identifier_tokens(declaration.name)
|
|
203
|
+
expression = declaration.expression
|
|
204
|
+
static_content = any(token in STATIC_CONTENT_TOKEN for token in tokens)
|
|
205
|
+
if any(token in EXPLICIT_MOCK_TOKEN for token in tokens) and not static_content:
|
|
206
|
+
return "explicit_mock_collection"
|
|
207
|
+
if FAKER.search(expression):
|
|
208
|
+
return "faker_collection"
|
|
209
|
+
if PLACEHOLDER_VALUE.search(expression):
|
|
210
|
+
return "placeholder_collection"
|
|
211
|
+
inline_objects = re.match(
|
|
212
|
+
r"\s*(?:(?:useState|ref|reactive|readonly|\$state)"
|
|
213
|
+
r"(?:<[^>]{1,500}>)?\s*\(\s*)?\[\s*\{",
|
|
214
|
+
expression,
|
|
215
|
+
) is not None
|
|
216
|
+
operational_fields = len(OPERATIONAL_FIELD.findall(expression)) >= 2
|
|
217
|
+
strong_operational = STRONG_OPERATIONAL_FIELD.search(expression) is not None
|
|
218
|
+
if inline_objects and not static_content and (
|
|
219
|
+
any(token in OPERATIONAL_TOKEN for token in tokens)
|
|
220
|
+
or (operational_fields and strong_operational)
|
|
221
|
+
):
|
|
222
|
+
return "inline_operational_collection"
|
|
223
|
+
if (
|
|
224
|
+
re.match(r"\s*Array\.from\s*\(", expression)
|
|
225
|
+
and any(token in OPERATIONAL_TOKEN for token in tokens)
|
|
226
|
+
and not static_content
|
|
227
|
+
and re.search(r"=>\s*\(?\s*\{", expression)
|
|
228
|
+
):
|
|
229
|
+
return "generated_operational_collection"
|
|
230
|
+
return ""
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _mask_non_code(source: str) -> str:
|
|
234
|
+
"""Mask comments and strings while preserving positions and newlines."""
|
|
235
|
+
|
|
236
|
+
masked = list(source)
|
|
237
|
+
index = 0
|
|
238
|
+
while index < len(source):
|
|
239
|
+
char = source[index]
|
|
240
|
+
next_char = source[index + 1] if index + 1 < len(source) else ""
|
|
241
|
+
if char == "/" and next_char == "/":
|
|
242
|
+
end = source.find("\n", index + 2)
|
|
243
|
+
end = len(source) if end < 0 else end
|
|
244
|
+
for position in range(index, end):
|
|
245
|
+
masked[position] = " "
|
|
246
|
+
index = end
|
|
247
|
+
continue
|
|
248
|
+
if char == "/" and next_char == "*":
|
|
249
|
+
end = source.find("*/", index + 2)
|
|
250
|
+
end = len(source) if end < 0 else end + 2
|
|
251
|
+
for position in range(index, end):
|
|
252
|
+
if masked[position] != "\n":
|
|
253
|
+
masked[position] = " "
|
|
254
|
+
index = end
|
|
255
|
+
continue
|
|
256
|
+
if char in "\"'`":
|
|
257
|
+
quote = char
|
|
258
|
+
end = index + 1
|
|
259
|
+
escaped = False
|
|
260
|
+
while end < len(source):
|
|
261
|
+
current = source[end]
|
|
262
|
+
if escaped:
|
|
263
|
+
escaped = False
|
|
264
|
+
elif current == "\\":
|
|
265
|
+
escaped = True
|
|
266
|
+
elif current == quote:
|
|
267
|
+
end += 1
|
|
268
|
+
break
|
|
269
|
+
end += 1
|
|
270
|
+
for position in range(index, end):
|
|
271
|
+
if masked[position] != "\n":
|
|
272
|
+
masked[position] = " "
|
|
273
|
+
index = end
|
|
274
|
+
continue
|
|
275
|
+
index += 1
|
|
276
|
+
return "".join(masked)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _balanced_parenthesized(source: str, opening: int) -> str:
|
|
280
|
+
if opening < 0 or opening >= len(source) or source[opening] != "(":
|
|
281
|
+
return ""
|
|
282
|
+
depth = 0
|
|
283
|
+
for index in range(opening, min(len(source), opening + 50000)):
|
|
284
|
+
if source[index] == "(":
|
|
285
|
+
depth += 1
|
|
286
|
+
elif source[index] == ")":
|
|
287
|
+
depth -= 1
|
|
288
|
+
if depth == 0:
|
|
289
|
+
return source[opening:index + 1]
|
|
290
|
+
return ""
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _named_callback_renders_jsx(source: str, call: str) -> bool:
|
|
294
|
+
callback = re.match(r"\(\s*([A-Za-z_$][\w$]*)\s*(?:,|\))", call)
|
|
295
|
+
if not callback:
|
|
296
|
+
return False
|
|
297
|
+
callback_name = callback.group(1)
|
|
298
|
+
for declaration in _declarations("", source).values():
|
|
299
|
+
if (
|
|
300
|
+
declaration.name == callback_name
|
|
301
|
+
and "=>" in declaration.expression
|
|
302
|
+
and JSX_TAG.search(declaration.expression)
|
|
303
|
+
):
|
|
304
|
+
return True
|
|
305
|
+
name = re.escape(callback_name)
|
|
306
|
+
function = re.search(
|
|
307
|
+
rf"\bfunction\s+{name}\s*\([^)]*\)\s*\{{.{{0,5000}}?\breturn\s+(<[A-Za-z][A-Za-z0-9_.:-]*\b)",
|
|
308
|
+
source,
|
|
309
|
+
re.DOTALL,
|
|
310
|
+
)
|
|
311
|
+
return function is not None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _skip_balanced_backward(source: str, index: int) -> int:
|
|
315
|
+
"""Return the index before a balanced parenthesized expression."""
|
|
316
|
+
|
|
317
|
+
depth = 0
|
|
318
|
+
quote = ""
|
|
319
|
+
escaped = False
|
|
320
|
+
while index >= 0:
|
|
321
|
+
char = source[index]
|
|
322
|
+
if quote:
|
|
323
|
+
if escaped:
|
|
324
|
+
escaped = False
|
|
325
|
+
elif char == "\\":
|
|
326
|
+
escaped = True
|
|
327
|
+
elif char == quote:
|
|
328
|
+
quote = ""
|
|
329
|
+
index -= 1
|
|
330
|
+
continue
|
|
331
|
+
if char in "\"'`":
|
|
332
|
+
quote = char
|
|
333
|
+
elif char == ")":
|
|
334
|
+
depth += 1
|
|
335
|
+
elif char == "(":
|
|
336
|
+
depth -= 1
|
|
337
|
+
if depth == 0:
|
|
338
|
+
return index - 1
|
|
339
|
+
index -= 1
|
|
340
|
+
return -1
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _map_collection_root(source: str, map_dot: int) -> str:
|
|
344
|
+
"""Trace `collection.filter(...).map` back to its root identifier."""
|
|
345
|
+
|
|
346
|
+
index = map_dot - 1
|
|
347
|
+
candidate = ""
|
|
348
|
+
while index >= 0:
|
|
349
|
+
while index >= 0 and source[index].isspace():
|
|
350
|
+
index -= 1
|
|
351
|
+
if index >= 0 and source[index] == ")":
|
|
352
|
+
index = _skip_balanced_backward(source, index)
|
|
353
|
+
while index >= 0 and source[index].isspace():
|
|
354
|
+
index -= 1
|
|
355
|
+
end = index + 1
|
|
356
|
+
while index >= 0 and (source[index].isalnum() or source[index] in "_$"):
|
|
357
|
+
index -= 1
|
|
358
|
+
if end == index + 1:
|
|
359
|
+
return candidate
|
|
360
|
+
candidate = source[index + 1:end]
|
|
361
|
+
while index >= 0 and source[index].isspace():
|
|
362
|
+
index -= 1
|
|
363
|
+
if index >= 0 and source[index] == "?":
|
|
364
|
+
index -= 1
|
|
365
|
+
if index >= 0 and source[index] == ".":
|
|
366
|
+
index -= 1
|
|
367
|
+
continue
|
|
368
|
+
return candidate
|
|
369
|
+
return candidate
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _rendered_symbols(source: str) -> set[str]:
|
|
373
|
+
rendered: set[str] = set()
|
|
374
|
+
structural = _mask_non_code(source)
|
|
375
|
+
for match in re.finditer(r"(?:\?\.|\.)\s*map\s*\(", structural):
|
|
376
|
+
call = _balanced_parenthesized(structural, match.end() - 1)
|
|
377
|
+
if JSX_TAG.search(call) or _named_callback_renders_jsx(structural, call):
|
|
378
|
+
root = _map_collection_root(structural, match.start())
|
|
379
|
+
if root:
|
|
380
|
+
rendered.add(root)
|
|
381
|
+
rendered.update(match.group(1) for match in DIRECT_COLLECTION_PROP.finditer(structural))
|
|
382
|
+
rendered.update(match.group(1) for match in VUE_LOOP.finditer(source))
|
|
383
|
+
rendered.update(match.group(1) for match in SVELTE_LOOP.finditer(source))
|
|
384
|
+
return rendered
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _named_imports(source: str) -> dict[str, tuple[str, str]]:
|
|
388
|
+
imports: dict[str, tuple[str, str]] = {}
|
|
389
|
+
for match in NAMED_IMPORT.finditer(source):
|
|
390
|
+
module = match.group(2)
|
|
391
|
+
for raw_binding in match.group(1).split(","):
|
|
392
|
+
binding = raw_binding.strip()
|
|
393
|
+
if not binding:
|
|
394
|
+
continue
|
|
395
|
+
parts = re.split(r"\s+as\s+", binding, maxsplit=1)
|
|
396
|
+
imported = parts[0].strip()
|
|
397
|
+
local = parts[-1].strip()
|
|
398
|
+
if re.fullmatch(r"[A-Za-z_$][\w$]*", imported) and re.fullmatch(
|
|
399
|
+
r"[A-Za-z_$][\w$]*", local
|
|
400
|
+
):
|
|
401
|
+
imports[local] = (module, imported)
|
|
402
|
+
for match in DEFAULT_IMPORT.finditer(source):
|
|
403
|
+
imports[match.group(1)] = (match.group(2), "default")
|
|
404
|
+
return imports
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _jsonc_object(path: Path) -> dict[str, object]:
|
|
408
|
+
try:
|
|
409
|
+
source = path.read_text(encoding="utf-8", errors="replace")
|
|
410
|
+
except OSError:
|
|
411
|
+
return {}
|
|
412
|
+
source = re.sub(r"/\*.*?\*/", "", source, flags=re.DOTALL)
|
|
413
|
+
source = re.sub(r"(^|\s)//.*$", r"\1", source, flags=re.MULTILINE)
|
|
414
|
+
source = re.sub(r",\s*([}\]])", r"\1", source)
|
|
415
|
+
try:
|
|
416
|
+
value = json.loads(source)
|
|
417
|
+
except json.JSONDecodeError:
|
|
418
|
+
return {}
|
|
419
|
+
return value if isinstance(value, dict) else {}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _module_bases(root: Path, importer: str, module: str) -> Iterable[Path]:
|
|
423
|
+
if module.startswith("."):
|
|
424
|
+
yield (root / importer).parent / module
|
|
425
|
+
return
|
|
426
|
+
|
|
427
|
+
matched = False
|
|
428
|
+
for config_name in ("tsconfig.json", "jsconfig.json"):
|
|
429
|
+
config = _jsonc_object(root / config_name)
|
|
430
|
+
compiler = config.get("compilerOptions")
|
|
431
|
+
if not isinstance(compiler, dict):
|
|
432
|
+
continue
|
|
433
|
+
base_url = compiler.get("baseUrl", ".")
|
|
434
|
+
base = root / (base_url if isinstance(base_url, str) else ".")
|
|
435
|
+
paths = compiler.get("paths")
|
|
436
|
+
if not isinstance(paths, dict):
|
|
437
|
+
continue
|
|
438
|
+
for pattern, replacements in paths.items():
|
|
439
|
+
if not isinstance(pattern, str) or not isinstance(replacements, list):
|
|
440
|
+
continue
|
|
441
|
+
prefix, marker, suffix = pattern.partition("*")
|
|
442
|
+
if marker:
|
|
443
|
+
if not module.startswith(prefix) or not module.endswith(suffix):
|
|
444
|
+
continue
|
|
445
|
+
wildcard = module[len(prefix):len(module) - len(suffix) if suffix else None]
|
|
446
|
+
elif module == pattern:
|
|
447
|
+
wildcard = ""
|
|
448
|
+
else:
|
|
449
|
+
continue
|
|
450
|
+
for replacement in replacements:
|
|
451
|
+
if isinstance(replacement, str):
|
|
452
|
+
matched = True
|
|
453
|
+
yield base / replacement.replace("*", wildcard)
|
|
454
|
+
if not matched and module.startswith(("@/", "~/")):
|
|
455
|
+
yield root / "src" / module[2:]
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _resolve_module(root: Path, importer: str, module: str) -> str:
|
|
459
|
+
candidates: list[Path] = []
|
|
460
|
+
for base in _module_bases(root, importer, module):
|
|
461
|
+
candidates.append(base)
|
|
462
|
+
candidates.extend(Path(str(base) + suffix) for suffix in RESOLUTION_EXTENSIONS)
|
|
463
|
+
candidates.extend(base / ("index" + suffix) for suffix in RESOLUTION_EXTENSIONS)
|
|
464
|
+
for candidate in candidates:
|
|
465
|
+
try:
|
|
466
|
+
resolved = candidate.resolve()
|
|
467
|
+
resolved.relative_to(root.resolve())
|
|
468
|
+
except (OSError, ValueError):
|
|
469
|
+
continue
|
|
470
|
+
if resolved.is_file() and resolved.suffix.lower() in SOURCE_EXTENSIONS:
|
|
471
|
+
return resolved.relative_to(root.resolve()).as_posix()
|
|
472
|
+
return ""
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _source_files(tree: Path) -> dict[str, str]:
|
|
476
|
+
sources: dict[str, str] = {}
|
|
477
|
+
for directory, names, files in os.walk(tree, topdown=True, followlinks=False):
|
|
478
|
+
directory_path = Path(directory)
|
|
479
|
+
relative_directory = directory_path.relative_to(tree).as_posix()
|
|
480
|
+
prefix = "" if relative_directory == "." else relative_directory + "/"
|
|
481
|
+
names[:] = sorted(
|
|
482
|
+
name
|
|
483
|
+
for name in names
|
|
484
|
+
if not SOURCE_INDEX_EXCLUDE.search(prefix + name + "/")
|
|
485
|
+
and not (directory_path / name).is_symlink()
|
|
486
|
+
)
|
|
487
|
+
for name in sorted(files):
|
|
488
|
+
path = directory_path / name
|
|
489
|
+
relative = prefix + name
|
|
490
|
+
if path.is_symlink() or path.suffix.lower() not in SOURCE_EXTENSIONS:
|
|
491
|
+
continue
|
|
492
|
+
if SOURCE_INDEX_EXCLUDE.search(relative):
|
|
493
|
+
continue
|
|
494
|
+
try:
|
|
495
|
+
sources[relative] = path.read_text(encoding="utf-8", errors="replace")
|
|
496
|
+
except OSError:
|
|
497
|
+
continue
|
|
498
|
+
return sources
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _exported_declaration(
|
|
502
|
+
tree: Path,
|
|
503
|
+
declarations: dict[str, dict[str, Declaration]],
|
|
504
|
+
sources: dict[str, str],
|
|
505
|
+
source_file: str,
|
|
506
|
+
imported_name: str,
|
|
507
|
+
local_name: str,
|
|
508
|
+
seen: set[tuple[str, str]] | None = None,
|
|
509
|
+
trace: set[str] | None = None,
|
|
510
|
+
) -> Declaration | None:
|
|
511
|
+
seen = set() if seen is None else seen
|
|
512
|
+
trace = set() if trace is None else trace
|
|
513
|
+
key = (source_file, imported_name)
|
|
514
|
+
if not source_file or key in seen or len(seen) >= 12:
|
|
515
|
+
return None
|
|
516
|
+
seen.add(key)
|
|
517
|
+
trace.add(source_file)
|
|
518
|
+
if imported_name != "default":
|
|
519
|
+
direct = declarations.get(source_file, {}).get(imported_name)
|
|
520
|
+
if direct is not None:
|
|
521
|
+
return direct
|
|
522
|
+
source = sources.get(source_file, "")
|
|
523
|
+
if imported_name == "default":
|
|
524
|
+
identifier = DEFAULT_EXPORT_IDENTIFIER.search(source)
|
|
525
|
+
if identifier:
|
|
526
|
+
direct = declarations.get(source_file, {}).get(identifier.group(1))
|
|
527
|
+
if direct is not None:
|
|
528
|
+
return direct
|
|
529
|
+
exported = DEFAULT_EXPORT.search(source)
|
|
530
|
+
if exported:
|
|
531
|
+
expression = _read_initializer(source, exported.end())
|
|
532
|
+
return Declaration(
|
|
533
|
+
file=source_file,
|
|
534
|
+
name=local_name,
|
|
535
|
+
expression=expression,
|
|
536
|
+
line=source.count("\n", 0, exported.start()) + 1,
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
for match in NAMED_REEXPORT.finditer(source):
|
|
540
|
+
module = match.group(2)
|
|
541
|
+
for raw_binding in match.group(1).split(","):
|
|
542
|
+
parts = re.split(r"\s+as\s+", raw_binding.strip(), maxsplit=1)
|
|
543
|
+
original = parts[0].strip()
|
|
544
|
+
exported_name = parts[-1].strip()
|
|
545
|
+
if exported_name != imported_name:
|
|
546
|
+
continue
|
|
547
|
+
target = _resolve_module(tree, source_file, module)
|
|
548
|
+
branch_trace = set(trace)
|
|
549
|
+
resolved = _exported_declaration(
|
|
550
|
+
tree,
|
|
551
|
+
declarations,
|
|
552
|
+
sources,
|
|
553
|
+
target,
|
|
554
|
+
original,
|
|
555
|
+
local_name,
|
|
556
|
+
seen,
|
|
557
|
+
branch_trace,
|
|
558
|
+
)
|
|
559
|
+
if resolved is not None:
|
|
560
|
+
trace.update(branch_trace)
|
|
561
|
+
return resolved
|
|
562
|
+
if imported_name != "default":
|
|
563
|
+
for match in STAR_REEXPORT.finditer(source):
|
|
564
|
+
target = _resolve_module(tree, source_file, match.group(1))
|
|
565
|
+
branch_trace = set(trace)
|
|
566
|
+
resolved = _exported_declaration(
|
|
567
|
+
tree,
|
|
568
|
+
declarations,
|
|
569
|
+
sources,
|
|
570
|
+
target,
|
|
571
|
+
imported_name,
|
|
572
|
+
local_name,
|
|
573
|
+
seen,
|
|
574
|
+
branch_trace,
|
|
575
|
+
)
|
|
576
|
+
if resolved is not None:
|
|
577
|
+
trace.update(branch_trace)
|
|
578
|
+
return resolved
|
|
579
|
+
return None
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _symbol_declaration(
|
|
583
|
+
tree: Path,
|
|
584
|
+
declarations: dict[str, dict[str, Declaration]],
|
|
585
|
+
sources: dict[str, str],
|
|
586
|
+
source_file: str,
|
|
587
|
+
symbol: str,
|
|
588
|
+
trace: set[str] | None = None,
|
|
589
|
+
) -> Declaration | None:
|
|
590
|
+
trace = set() if trace is None else trace
|
|
591
|
+
direct = declarations.get(source_file, {}).get(symbol)
|
|
592
|
+
if direct is not None:
|
|
593
|
+
trace.add(source_file)
|
|
594
|
+
return direct
|
|
595
|
+
imported = _named_imports(sources.get(source_file, "")).get(symbol)
|
|
596
|
+
if imported is None:
|
|
597
|
+
return None
|
|
598
|
+
module, imported_name = imported
|
|
599
|
+
target = _resolve_module(tree, source_file, module)
|
|
600
|
+
branch_trace = set(trace)
|
|
601
|
+
resolved = _exported_declaration(
|
|
602
|
+
tree,
|
|
603
|
+
declarations,
|
|
604
|
+
sources,
|
|
605
|
+
target,
|
|
606
|
+
imported_name,
|
|
607
|
+
symbol,
|
|
608
|
+
trace=branch_trace,
|
|
609
|
+
)
|
|
610
|
+
if resolved is not None:
|
|
611
|
+
trace.update(branch_trace)
|
|
612
|
+
return resolved
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _referenced_symbol(expression: str) -> str:
|
|
616
|
+
match = re.match(r"\s*([A-Za-z_$][\w$]*)\s*(?:\?\.)?\.", expression)
|
|
617
|
+
if match and match.group(1) not in {"Array", "Object", "Math", "JSON"}:
|
|
618
|
+
return match.group(1)
|
|
619
|
+
derived = re.match(
|
|
620
|
+
r"\s*(?:useMemo|computed)\s*\(\s*(?:\([^)]*\)|[A-Za-z_$][\w$]*)"
|
|
621
|
+
r"\s*=>\s*\(?\s*([A-Za-z_$][\w$]*)\s*(?:\?\.|\.|\[)",
|
|
622
|
+
expression,
|
|
623
|
+
)
|
|
624
|
+
return derived.group(1) if derived else ""
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _bound_mock_reason(
|
|
628
|
+
tree: Path,
|
|
629
|
+
declarations: dict[str, dict[str, Declaration]],
|
|
630
|
+
sources: dict[str, str],
|
|
631
|
+
declaration: Declaration,
|
|
632
|
+
seen: set[tuple[str, str]] | None = None,
|
|
633
|
+
trace: set[str] | None = None,
|
|
634
|
+
) -> tuple[Declaration, str] | None:
|
|
635
|
+
seen = set() if seen is None else seen
|
|
636
|
+
trace = set() if trace is None else trace
|
|
637
|
+
key = (declaration.file, declaration.name)
|
|
638
|
+
if key in seen or len(seen) >= 12:
|
|
639
|
+
return None
|
|
640
|
+
seen.add(key)
|
|
641
|
+
trace.add(declaration.file)
|
|
642
|
+
reason = _mock_reason(declaration)
|
|
643
|
+
if reason:
|
|
644
|
+
return declaration, reason
|
|
645
|
+
referenced = _referenced_symbol(declaration.expression)
|
|
646
|
+
if not referenced:
|
|
647
|
+
return None
|
|
648
|
+
upstream = _symbol_declaration(
|
|
649
|
+
tree, declarations, sources, declaration.file, referenced, trace,
|
|
650
|
+
)
|
|
651
|
+
if upstream is None:
|
|
652
|
+
return None
|
|
653
|
+
return _bound_mock_reason(tree, declarations, sources, upstream, seen, trace)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _changed_digest(files: list[str], tree: Path) -> str:
|
|
657
|
+
digest = hashlib.sha256()
|
|
658
|
+
root = tree.resolve()
|
|
659
|
+
for relative in sorted(set(files)):
|
|
660
|
+
digest.update(relative.encode("utf-8", errors="surrogateescape"))
|
|
661
|
+
digest.update(b"\0")
|
|
662
|
+
path = tree / relative
|
|
663
|
+
try:
|
|
664
|
+
resolved = path.resolve()
|
|
665
|
+
resolved.relative_to(root)
|
|
666
|
+
if path.is_symlink():
|
|
667
|
+
digest.update(b"symlink\0")
|
|
668
|
+
digest.update(os.readlink(path).encode("utf-8", errors="surrogateescape"))
|
|
669
|
+
elif path.is_file():
|
|
670
|
+
digest.update(b"file\0")
|
|
671
|
+
digest.update(hashlib.sha256(path.read_bytes()).digest())
|
|
672
|
+
else:
|
|
673
|
+
digest.update(b"missing\0")
|
|
674
|
+
except (OSError, ValueError):
|
|
675
|
+
digest.update(b"unreadable\0")
|
|
676
|
+
digest.update(b"\0")
|
|
677
|
+
return digest.hexdigest()
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def scan(files: list[str], tree: Path) -> tuple[int, dict[str, object] | None]:
|
|
681
|
+
"""Return changed sources scanned and the first collection-bound hit."""
|
|
682
|
+
|
|
683
|
+
changed = {
|
|
684
|
+
relative
|
|
685
|
+
for relative in files
|
|
686
|
+
if Path(relative).suffix.lower() in SOURCE_EXTENSIONS
|
|
687
|
+
and not SOURCE_INDEX_EXCLUDE.search(relative)
|
|
688
|
+
}
|
|
689
|
+
sources = _source_files(tree)
|
|
690
|
+
scanned = sum(1 for relative in changed if relative in sources)
|
|
691
|
+
declarations = {
|
|
692
|
+
relative: _declarations(relative, source)
|
|
693
|
+
for relative, source in sources.items()
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
for render_file, source in sources.items():
|
|
697
|
+
if RENDER_EXCLUDE.search(render_file):
|
|
698
|
+
continue
|
|
699
|
+
rendered = _rendered_symbols(source)
|
|
700
|
+
for local_name in sorted(rendered):
|
|
701
|
+
resolution_trace = {render_file}
|
|
702
|
+
declaration = _symbol_declaration(
|
|
703
|
+
tree,
|
|
704
|
+
declarations,
|
|
705
|
+
sources,
|
|
706
|
+
render_file,
|
|
707
|
+
local_name,
|
|
708
|
+
resolution_trace,
|
|
709
|
+
)
|
|
710
|
+
if declaration is None:
|
|
711
|
+
continue
|
|
712
|
+
bound = _bound_mock_reason(
|
|
713
|
+
tree,
|
|
714
|
+
declarations,
|
|
715
|
+
sources,
|
|
716
|
+
declaration,
|
|
717
|
+
trace=resolution_trace,
|
|
718
|
+
)
|
|
719
|
+
if bound is None:
|
|
720
|
+
continue
|
|
721
|
+
backing, reason = bound
|
|
722
|
+
if changed.isdisjoint(resolution_trace):
|
|
723
|
+
continue
|
|
724
|
+
return scanned, {
|
|
725
|
+
"file": backing.file,
|
|
726
|
+
"line": backing.line,
|
|
727
|
+
"snippet": backing.name[:80],
|
|
728
|
+
"reason": reason,
|
|
729
|
+
"render_file": render_file,
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if render_file in changed:
|
|
733
|
+
structural = _mask_non_code(source)
|
|
734
|
+
for match in DIRECT_INLINE_MAP.finditer(structural):
|
|
735
|
+
body = source[match.start("body"):match.end("body")]
|
|
736
|
+
call = _balanced_parenthesized(structural, match.end() - 1)
|
|
737
|
+
operational_fields = len(OPERATIONAL_FIELD.findall(body)) >= 2
|
|
738
|
+
strong_operational = STRONG_OPERATIONAL_FIELD.search(body) is not None
|
|
739
|
+
static_content = STATIC_CONTENT_FIELD.search(body) is not None
|
|
740
|
+
if JSX_TAG.search(call) and (
|
|
741
|
+
(operational_fields and strong_operational and not static_content)
|
|
742
|
+
or FAKER.search(body)
|
|
743
|
+
or PLACEHOLDER_VALUE.search(body)
|
|
744
|
+
):
|
|
745
|
+
return scanned, {
|
|
746
|
+
"file": render_file,
|
|
747
|
+
"line": source.count("\n", 0, match.start()) + 1,
|
|
748
|
+
"snippet": "inline array map",
|
|
749
|
+
"reason": "direct_inline_operational_collection",
|
|
750
|
+
"render_file": render_file,
|
|
751
|
+
}
|
|
752
|
+
return scanned, None
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def main() -> int:
|
|
756
|
+
files = [line.strip() for line in os.environ.get("_NM_FILES", "").splitlines() if line.strip()]
|
|
757
|
+
tree = Path(os.environ.get("_NM_TREE", ".")).resolve()
|
|
758
|
+
output = os.environ.get("_NM_OUT", "")
|
|
759
|
+
scanned, hit = scan(files, tree)
|
|
760
|
+
receipt = {
|
|
761
|
+
"schema_version": 2,
|
|
762
|
+
"scanned": scanned,
|
|
763
|
+
"hit": hit is not None,
|
|
764
|
+
"file": hit["file"] if hit else "",
|
|
765
|
+
"line": hit["line"] if hit else 0,
|
|
766
|
+
"snippet": hit["snippet"] if hit else "",
|
|
767
|
+
"reason": hit["reason"] if hit else "",
|
|
768
|
+
"render_file": hit["render_file"] if hit else "",
|
|
769
|
+
"base_sha": os.environ.get("_NM_BASE", ""),
|
|
770
|
+
"source_digest": _changed_digest(files, tree),
|
|
771
|
+
}
|
|
772
|
+
if output:
|
|
773
|
+
try:
|
|
774
|
+
destination = Path(output)
|
|
775
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
776
|
+
temporary = destination.with_name(destination.name + ".tmp")
|
|
777
|
+
temporary.write_text(json.dumps(receipt, indent=2), encoding="utf-8")
|
|
778
|
+
temporary.replace(destination)
|
|
779
|
+
except OSError as error:
|
|
780
|
+
print(f"receipt write failed: {error}", file=os.sys.stderr)
|
|
781
|
+
return 2
|
|
782
|
+
|
|
783
|
+
if hit:
|
|
784
|
+
print(f"FAIL:{hit['file']}:{hit['line']}:{hit['snippet']}:{hit['reason']}")
|
|
785
|
+
elif scanned == 0:
|
|
786
|
+
print("SKIP:0")
|
|
787
|
+
else:
|
|
788
|
+
print(f"PASS:{scanned}")
|
|
789
|
+
return 0
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
if __name__ == "__main__":
|
|
793
|
+
raise SystemExit(main())
|