atomadic-forge 0.3.2__py3-none-any.whl

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.
Files changed (131) hide show
  1. atomadic_forge/__init__.py +12 -0
  2. atomadic_forge/__main__.py +5 -0
  3. atomadic_forge/a0_qk_constants/__init__.py +1 -0
  4. atomadic_forge/a0_qk_constants/agent_plan_schema.py +120 -0
  5. atomadic_forge/a0_qk_constants/commandsmith_types.py +49 -0
  6. atomadic_forge/a0_qk_constants/config_defaults.py +38 -0
  7. atomadic_forge/a0_qk_constants/emergent_types.py +77 -0
  8. atomadic_forge/a0_qk_constants/error_codes.py +296 -0
  9. atomadic_forge/a0_qk_constants/forge_types.py +89 -0
  10. atomadic_forge/a0_qk_constants/gen_language.py +116 -0
  11. atomadic_forge/a0_qk_constants/lang_extensions.py +150 -0
  12. atomadic_forge/a0_qk_constants/policy_schema.py +48 -0
  13. atomadic_forge/a0_qk_constants/receipt_schema.py +311 -0
  14. atomadic_forge/a0_qk_constants/roi_constants.py +96 -0
  15. atomadic_forge/a0_qk_constants/semantic_types.py +61 -0
  16. atomadic_forge/a0_qk_constants/sidecar_schema.py +81 -0
  17. atomadic_forge/a0_qk_constants/synergy_types.py +62 -0
  18. atomadic_forge/a0_qk_constants/tier_names.py +47 -0
  19. atomadic_forge/a1_at_functions/__init__.py +1 -0
  20. atomadic_forge/a1_at_functions/agent_context_pack.py +193 -0
  21. atomadic_forge/a1_at_functions/agent_memory.py +139 -0
  22. atomadic_forge/a1_at_functions/agent_plan_emitter.py +324 -0
  23. atomadic_forge/a1_at_functions/agent_summary.py +277 -0
  24. atomadic_forge/a1_at_functions/body_extractor.py +306 -0
  25. atomadic_forge/a1_at_functions/card_renderer.py +210 -0
  26. atomadic_forge/a1_at_functions/certify_checks.py +445 -0
  27. atomadic_forge/a1_at_functions/chat_context.py +170 -0
  28. atomadic_forge/a1_at_functions/cherry_pick.py +71 -0
  29. atomadic_forge/a1_at_functions/classify_tier.py +115 -0
  30. atomadic_forge/a1_at_functions/commandsmith_discover.py +167 -0
  31. atomadic_forge/a1_at_functions/commandsmith_render.py +267 -0
  32. atomadic_forge/a1_at_functions/compiler_feedback.py +94 -0
  33. atomadic_forge/a1_at_functions/compliance_checker.py +228 -0
  34. atomadic_forge/a1_at_functions/config_io.py +68 -0
  35. atomadic_forge/a1_at_functions/cs1_renderer.py +588 -0
  36. atomadic_forge/a1_at_functions/doc_synthesizer.py +205 -0
  37. atomadic_forge/a1_at_functions/emergent_compose.py +192 -0
  38. atomadic_forge/a1_at_functions/emergent_rank.py +116 -0
  39. atomadic_forge/a1_at_functions/emergent_signature_extract.py +242 -0
  40. atomadic_forge/a1_at_functions/emergent_synthesize.py +88 -0
  41. atomadic_forge/a1_at_functions/enforce_planner.py +208 -0
  42. atomadic_forge/a1_at_functions/error_hints.py +105 -0
  43. atomadic_forge/a1_at_functions/evolution_log.py +94 -0
  44. atomadic_forge/a1_at_functions/forge_feedback.py +433 -0
  45. atomadic_forge/a1_at_functions/generation_quality.py +322 -0
  46. atomadic_forge/a1_at_functions/import_repair.py +211 -0
  47. atomadic_forge/a1_at_functions/import_smoke.py +102 -0
  48. atomadic_forge/a1_at_functions/js_parser.py +539 -0
  49. atomadic_forge/a1_at_functions/lineage_chain.py +144 -0
  50. atomadic_forge/a1_at_functions/lineage_reader.py +107 -0
  51. atomadic_forge/a1_at_functions/llm_client.py +554 -0
  52. atomadic_forge/a1_at_functions/local_signer.py +134 -0
  53. atomadic_forge/a1_at_functions/lsp_protocol.py +379 -0
  54. atomadic_forge/a1_at_functions/manifest_diff.py +314 -0
  55. atomadic_forge/a1_at_functions/mcp_protocol.py +1066 -0
  56. atomadic_forge/a1_at_functions/patch_scorer.py +267 -0
  57. atomadic_forge/a1_at_functions/plan_adapter.py +75 -0
  58. atomadic_forge/a1_at_functions/policy_loader.py +107 -0
  59. atomadic_forge/a1_at_functions/preflight_change.py +227 -0
  60. atomadic_forge/a1_at_functions/progress_reporter.py +81 -0
  61. atomadic_forge/a1_at_functions/provider_detect.py +157 -0
  62. atomadic_forge/a1_at_functions/provider_resolver.py +48 -0
  63. atomadic_forge/a1_at_functions/receipt_emitter.py +291 -0
  64. atomadic_forge/a1_at_functions/recipes.py +186 -0
  65. atomadic_forge/a1_at_functions/repo_explainer.py +124 -0
  66. atomadic_forge/a1_at_functions/roi_calculator.py +265 -0
  67. atomadic_forge/a1_at_functions/rollback_planner.py +147 -0
  68. atomadic_forge/a1_at_functions/sbom_emitter.py +155 -0
  69. atomadic_forge/a1_at_functions/scaffold_js.py +55 -0
  70. atomadic_forge/a1_at_functions/scaffold_pyproject.py +62 -0
  71. atomadic_forge/a1_at_functions/scaffold_starter.py +94 -0
  72. atomadic_forge/a1_at_functions/scout_walk.py +309 -0
  73. atomadic_forge/a1_at_functions/sidecar_parser.py +161 -0
  74. atomadic_forge/a1_at_functions/sidecar_validator.py +202 -0
  75. atomadic_forge/a1_at_functions/stub_detector.py +158 -0
  76. atomadic_forge/a1_at_functions/synergy_detect.py +166 -0
  77. atomadic_forge/a1_at_functions/synergy_render.py +252 -0
  78. atomadic_forge/a1_at_functions/synergy_surface_extract.py +163 -0
  79. atomadic_forge/a1_at_functions/test_runner.py +196 -0
  80. atomadic_forge/a1_at_functions/test_selector.py +122 -0
  81. atomadic_forge/a1_at_functions/tier_init_rebuild.py +122 -0
  82. atomadic_forge/a1_at_functions/tool_composer.py +130 -0
  83. atomadic_forge/a1_at_functions/transcript_log.py +70 -0
  84. atomadic_forge/a1_at_functions/wire_check.py +260 -0
  85. atomadic_forge/a2_mo_composites/__init__.py +1 -0
  86. atomadic_forge/a2_mo_composites/lineage_chain_store.py +122 -0
  87. atomadic_forge/a2_mo_composites/manifest_store.py +46 -0
  88. atomadic_forge/a2_mo_composites/plan_store.py +164 -0
  89. atomadic_forge/a2_mo_composites/receipt_signer.py +231 -0
  90. atomadic_forge/a3_og_features/__init__.py +1 -0
  91. atomadic_forge/a3_og_features/commandsmith_feature.py +267 -0
  92. atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/__init__.py +3 -0
  93. atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/a0_qk_constants/__init__.py +4 -0
  94. atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/a1_at_functions/__init__.py +14 -0
  95. atomadic_forge/a3_og_features/demo_packages/mixed_py_js/tests/conftest.py +10 -0
  96. atomadic_forge/a3_og_features/demo_packages/mixed_py_js/tests/test_mixed.py +18 -0
  97. atomadic_forge/a3_og_features/demo_runner.py +502 -0
  98. atomadic_forge/a3_og_features/emergent_feature.py +95 -0
  99. atomadic_forge/a3_og_features/emergent_pipeline_integration.py +154 -0
  100. atomadic_forge/a3_og_features/forge_enforce.py +107 -0
  101. atomadic_forge/a3_og_features/forge_evolve.py +176 -0
  102. atomadic_forge/a3_og_features/forge_loop.py +528 -0
  103. atomadic_forge/a3_og_features/forge_pipeline.py +295 -0
  104. atomadic_forge/a3_og_features/forge_plan_apply.py +222 -0
  105. atomadic_forge/a3_og_features/lsp_server.py +98 -0
  106. atomadic_forge/a3_og_features/mcp_server.py +160 -0
  107. atomadic_forge/a3_og_features/setup_wizard.py +337 -0
  108. atomadic_forge/a3_og_features/synergy_feature.py +65 -0
  109. atomadic_forge/a4_sy_orchestration/__init__.py +1 -0
  110. atomadic_forge/a4_sy_orchestration/cli.py +1284 -0
  111. atomadic_forge/commands/__init__.py +1 -0
  112. atomadic_forge/commands/_registry.py +36 -0
  113. atomadic_forge/commands/audit.py +142 -0
  114. atomadic_forge/commands/chat.py +133 -0
  115. atomadic_forge/commands/commandsmith.py +178 -0
  116. atomadic_forge/commands/config_cmd.py +145 -0
  117. atomadic_forge/commands/demo.py +142 -0
  118. atomadic_forge/commands/emergent.py +124 -0
  119. atomadic_forge/commands/emergent_then_synergy.py +70 -0
  120. atomadic_forge/commands/evolve.py +122 -0
  121. atomadic_forge/commands/evolve_then_iterate.py +70 -0
  122. atomadic_forge/commands/feature_then_emergent.py +111 -0
  123. atomadic_forge/commands/iterate.py +140 -0
  124. atomadic_forge/commands/synergy.py +96 -0
  125. atomadic_forge/commands/synergy_then_emergent.py +70 -0
  126. atomadic_forge-0.3.2.dist-info/METADATA +471 -0
  127. atomadic_forge-0.3.2.dist-info/RECORD +131 -0
  128. atomadic_forge-0.3.2.dist-info/WHEEL +5 -0
  129. atomadic_forge-0.3.2.dist-info/entry_points.txt +3 -0
  130. atomadic_forge-0.3.2.dist-info/licenses/LICENSE +15 -0
  131. atomadic_forge-0.3.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,539 @@
1
+ """Tier a1 — pure JavaScript / TypeScript surface parser.
2
+
3
+ Forge is a tier classifier, not a JS compiler. We extract the few things
4
+ the downstream pipeline needs from JS/TS source via regex:
5
+
6
+ * top-level imports (ES6 ``import … from "x"`` and CommonJS ``require("x")``)
7
+ * top-level exported symbols (``export const``, ``export function``,
8
+ ``export class``, ``export default { fetch }`` Worker handlers)
9
+ * cheap state / effect signals (the presence of ``class``, ``let`` at
10
+ module level with subsequent reassignment, ``new`` constructors, and
11
+ Cloudflare-Worker ``fetch`` / ``scheduled`` entry points)
12
+
13
+ Block comments and string literals are stripped before parsing so we don't
14
+ treat ``"import x"`` inside a string as a real import. Single-line ``//``
15
+ comments are stripped too. The parser is forgiving — malformed sources
16
+ produce an empty surface rather than raising.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from dataclasses import dataclass, field
23
+
24
+ # --- comment / string stripping -------------------------------------------
25
+
26
+ _BLOCK_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
27
+ _LINE_COMMENT_RE = re.compile(r"(?<!:)//[^\n]*")
28
+ _STRING_RE = re.compile(
29
+ r'"(?:\\.|[^"\\])*"'
30
+ r"|'(?:\\.|[^'\\])*'"
31
+ r"|`(?:\\.|[^`\\])*`",
32
+ re.DOTALL,
33
+ )
34
+
35
+
36
+ def strip_comments(src: str) -> str:
37
+ """Remove block + line comments. Pure."""
38
+ src = _BLOCK_COMMENT_RE.sub("", src)
39
+ src = _LINE_COMMENT_RE.sub("", src)
40
+ return src
41
+
42
+
43
+ def strip_comments_and_strings(src: str) -> str:
44
+ """Remove block comments, line comments, and string literals.
45
+
46
+ Replaces strings with empty quotes so token positions are preserved
47
+ enough for line-based regex. Used for export / class / state checks
48
+ where the *contents* of strings are noise. Import parsers must use
49
+ :func:`strip_comments` instead so the import specifier itself
50
+ survives.
51
+ """
52
+ return _STRING_RE.sub('""', strip_comments(src))
53
+
54
+
55
+ # --- imports --------------------------------------------------------------
56
+
57
+ # ES6 forms we handle:
58
+ # import "x"
59
+ # import x from "y"
60
+ # import { a, b } from "y"
61
+ # import * as ns from "y"
62
+ # import x, { a } from "y"
63
+ # import type { X } from "y" (TypeScript)
64
+ # import("x") ← dynamic import
65
+ _ES6_IMPORT_RE = re.compile(
66
+ r"""
67
+ \bimport\b
68
+ (?: # any clause shape (or none for side-effect)
69
+ \s+(?:type\s+)? # optional 'type' for TS
70
+ (?:[A-Za-z_$][\w$]*\s*,?\s*)? # default name
71
+ (?:\{[^}]*\}\s*)? # named-bindings { a, b }
72
+ (?:\*\s*as\s+[A-Za-z_$][\w$]*\s*)? # namespace
73
+ from\s*
74
+ )?
75
+ \s*
76
+ [\"']([^\"']+)[\"']
77
+ """,
78
+ re.VERBOSE,
79
+ )
80
+ _DYNAMIC_IMPORT_RE = re.compile(r"""\bimport\s*\(\s*[\"']([^\"']+)[\"']""")
81
+ _REQUIRE_RE = re.compile(r"""\brequire\s*\(\s*[\"']([^\"']+)[\"']""")
82
+
83
+
84
+ def _mask_non_import_strings(src: str) -> str:
85
+ """Mask string-literal *contents* unless they directly follow an
86
+ ``import`` / ``from`` / ``require(`` token. Pure.
87
+
88
+ The masked output preserves length and quote positions so subsequent
89
+ regex matchers see the surrounding code unchanged.
90
+ """
91
+ out: list[str] = []
92
+ i = 0
93
+ n = len(src)
94
+ keyword_re = re.compile(r"(?:^|[^A-Za-z0-9_$])(import|from|require)\s*\(?\s*$")
95
+ while i < n:
96
+ ch = src[i]
97
+ if ch in ("'", '"', "`"):
98
+ # find end of string
99
+ j = i + 1
100
+ while j < n:
101
+ if src[j] == "\\":
102
+ j += 2
103
+ continue
104
+ if src[j] == ch:
105
+ break
106
+ j += 1
107
+ # decide: was this string preceded by an import-context keyword?
108
+ preceding = src[max(0, i - 40): i]
109
+ preserve = bool(keyword_re.search(preceding))
110
+ if preserve:
111
+ out.append(src[i:j + 1])
112
+ else:
113
+ # mask: keep quote chars at the boundaries, blank middle
114
+ if j < n:
115
+ out.append(ch + " " * max(0, j - i - 1) + ch)
116
+ else:
117
+ out.append(ch + " " * (n - i - 1))
118
+ i = j + 1
119
+ continue
120
+ out.append(ch)
121
+ i += 1
122
+ return "".join(out)
123
+
124
+
125
+ def parse_imports(src: str) -> list[str]:
126
+ """Return every imported module specifier in source-order, deduped.
127
+
128
+ Detects ES6 ``import``, dynamic ``import()`` and CommonJS ``require()``.
129
+ Comments are stripped first; non-import string literals are masked so
130
+ a fake ``import x from 'y'`` inside a JS string never registers.
131
+ """
132
+ cleaned = _mask_non_import_strings(strip_comments(src))
133
+ seen: list[str] = []
134
+ for rx in (_ES6_IMPORT_RE, _DYNAMIC_IMPORT_RE, _REQUIRE_RE):
135
+ for m in rx.finditer(cleaned):
136
+ spec = m.group(1)
137
+ if spec and spec not in seen:
138
+ seen.append(spec)
139
+ return seen
140
+
141
+
142
+ # --- exports / symbols ----------------------------------------------------
143
+
144
+ _EXPORT_FUNCTION_RE = re.compile(
145
+ r"\bexport\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)"
146
+ )
147
+ _EXPORT_CLASS_RE = re.compile(r"\bexport\s+class\s+([A-Za-z_$][\w$]*)")
148
+ _EXPORT_CONST_RE = re.compile(r"\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)")
149
+ _EXPORT_DEFAULT_OBJECT_OPEN_RE = re.compile(r"\bexport\s+default\s*\{")
150
+ _EXPORT_DEFAULT_FUNCTION_RE = re.compile(
151
+ r"\bexport\s+default\s+(?:async\s+)?function(?:\s+([A-Za-z_$][\w$]*))?"
152
+ )
153
+ _EXPORT_DEFAULT_CLASS_RE = re.compile(
154
+ r"\bexport\s+default\s+class(?:\s+([A-Za-z_$][\w$]*))?"
155
+ )
156
+ # CommonJS: module.exports = { … } exports.foo = …
157
+ _MODULE_EXPORTS_OBJECT_RE = re.compile(
158
+ r"\bmodule\.exports\s*=\s*\{\s*([^}]{0,300})\}", re.DOTALL
159
+ )
160
+ _EXPORTS_PROPERTY_RE = re.compile(r"\bexports\.([A-Za-z_$][\w$]*)\s*=")
161
+
162
+ # Top-level (non-export) declarations we track for inferring tier.
163
+ _TOP_FUNCTION_RE = re.compile(
164
+ r"^(?:async\s+)?function\s+([A-Za-z_$][\w$]*)", re.MULTILINE
165
+ )
166
+ _TOP_CLASS_RE = re.compile(r"^class\s+([A-Za-z_$][\w$]*)", re.MULTILINE)
167
+ _TOP_CONST_RE = re.compile(
168
+ r"^(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=", re.MULTILINE
169
+ )
170
+
171
+
172
+ @dataclass
173
+ class JsSurface:
174
+ """The parsed surface of a single JS/TS file."""
175
+
176
+ imports: list[str] = field(default_factory=list)
177
+ exported_functions: list[str] = field(default_factory=list)
178
+ exported_classes: list[str] = field(default_factory=list)
179
+ exported_consts: list[str] = field(default_factory=list)
180
+ default_export_kind: str = "" # "" | "object" | "function" | "class"
181
+ default_export_keys: list[str] = field(default_factory=list)
182
+ has_class: bool = False
183
+ has_module_exports: bool = False
184
+ has_worker_default_fetch: bool = False
185
+ has_scheduled_handler: bool = False
186
+ top_level_consts: list[str] = field(default_factory=list)
187
+ top_level_functions: list[str] = field(default_factory=list)
188
+ top_level_classes: list[str] = field(default_factory=list)
189
+ statement_count: int = 0
190
+
191
+ @property
192
+ def all_exports(self) -> list[str]:
193
+ seen: list[str] = []
194
+ for name in (
195
+ self.exported_functions
196
+ + self.exported_classes
197
+ + self.exported_consts
198
+ + self.default_export_keys
199
+ ):
200
+ if name and name not in seen:
201
+ seen.append(name)
202
+ return seen
203
+
204
+
205
+ def _balance_braces(src: str, open_idx: int) -> int:
206
+ """Return the index of the matching close brace for ``src[open_idx] == '{'``.
207
+
208
+ Brace-counts while skipping string literals and comments. Returns
209
+ ``-1`` if no match is found (malformed source).
210
+ """
211
+ if open_idx >= len(src) or src[open_idx] != "{":
212
+ return -1
213
+ depth = 0
214
+ i = open_idx
215
+ n = len(src)
216
+ while i < n:
217
+ ch = src[i]
218
+ if ch == "/" and i + 1 < n and src[i + 1] == "/":
219
+ j = src.find("\n", i)
220
+ i = n if j == -1 else j + 1
221
+ continue
222
+ if ch == "/" and i + 1 < n and src[i + 1] == "*":
223
+ j = src.find("*/", i + 2)
224
+ i = n if j == -1 else j + 2
225
+ continue
226
+ if ch in ("'", '"', "`"):
227
+ j = i + 1
228
+ while j < n:
229
+ if src[j] == "\\":
230
+ j += 2
231
+ continue
232
+ if src[j] == ch:
233
+ break
234
+ j += 1
235
+ i = j + 1
236
+ continue
237
+ if ch == "{":
238
+ depth += 1
239
+ elif ch == "}":
240
+ depth -= 1
241
+ if depth == 0:
242
+ return i
243
+ i += 1
244
+ return -1
245
+
246
+
247
+ def _top_level_object_keys(src: str, open_idx: int) -> list[str]:
248
+ """Extract identifier keys at depth-1 of the object starting at ``{``.
249
+
250
+ Skips nested ``{}``, ``[]``, ``()`` blocks, comments and strings; reads
251
+ only the leading identifier of each property. Tolerant — any token it
252
+ doesn't recognise is silently skipped.
253
+ """
254
+ close = _balance_braces(src, open_idx)
255
+ if close < 0:
256
+ return []
257
+ keys: list[str] = []
258
+ i = open_idx + 1
259
+ n = close
260
+ while i < n:
261
+ ch = src[i]
262
+ # skip whitespace / commas
263
+ if ch.isspace() or ch == ",":
264
+ i += 1
265
+ continue
266
+ # skip comments
267
+ if ch == "/" and i + 1 < n and src[i + 1] == "/":
268
+ j = src.find("\n", i)
269
+ i = n if j == -1 or j > n else j + 1
270
+ continue
271
+ if ch == "/" and i + 1 < n and src[i + 1] == "*":
272
+ j = src.find("*/", i + 2)
273
+ i = n if j == -1 or j > n else j + 2
274
+ continue
275
+ # spread / computed keys we don't try to parse
276
+ if ch in ("[", "."):
277
+ i += 1
278
+ continue
279
+ # quoted-string key — skip its value
280
+ if ch in ("'", '"', "`"):
281
+ i = _skip_string(src, i) + 1
282
+ continue
283
+ # identifier?
284
+ m = re.match(r"(?:async\s+)?(?:get\s+|set\s+)?([A-Za-z_$][\w$]*)",
285
+ src[i:n])
286
+ if not m:
287
+ i += 1
288
+ continue
289
+ name = m.group(1)
290
+ if name not in ("async", "get", "set") and name not in keys:
291
+ keys.append(name)
292
+ # skip past identifier
293
+ i += m.end()
294
+ # skip past the value: jump to next top-level comma
295
+ while i < n:
296
+ c2 = src[i]
297
+ if c2 == "{":
298
+ e = _balance_braces(src, i)
299
+ i = (e + 1) if e > i else (i + 1)
300
+ continue
301
+ if c2 == "[":
302
+ e = _balance_brackets(src, i)
303
+ i = (e + 1) if e > i else (i + 1)
304
+ continue
305
+ if c2 == "(":
306
+ e = _balance_parens(src, i)
307
+ i = (e + 1) if e > i else (i + 1)
308
+ continue
309
+ if c2 in ("'", '"', "`"):
310
+ i = _skip_string(src, i) + 1
311
+ continue
312
+ if c2 == "/" and i + 1 < n and src[i + 1] == "/":
313
+ j = src.find("\n", i)
314
+ i = n if j == -1 or j > n else j + 1
315
+ continue
316
+ if c2 == "/" and i + 1 < n and src[i + 1] == "*":
317
+ j = src.find("*/", i + 2)
318
+ i = n if j == -1 or j > n else j + 2
319
+ continue
320
+ if c2 == ",":
321
+ i += 1
322
+ break
323
+ i += 1
324
+ return keys
325
+
326
+
327
+ def _skip_string(src: str, i: int) -> int:
328
+ """Return the index of the closing quote of a string literal at ``src[i]``."""
329
+ quote = src[i]
330
+ j = i + 1
331
+ n = len(src)
332
+ while j < n:
333
+ if src[j] == "\\":
334
+ j += 2
335
+ continue
336
+ if src[j] == quote:
337
+ return j
338
+ j += 1
339
+ return n - 1
340
+
341
+
342
+ def _balance_brackets(src: str, open_idx: int) -> int:
343
+ return _balance_pair(src, open_idx, "[", "]")
344
+
345
+
346
+ def _balance_parens(src: str, open_idx: int) -> int:
347
+ return _balance_pair(src, open_idx, "(", ")")
348
+
349
+
350
+ def _balance_pair(src: str, open_idx: int, opener: str, closer: str) -> int:
351
+ if open_idx >= len(src) or src[open_idx] != opener:
352
+ return -1
353
+ depth = 0
354
+ i = open_idx
355
+ n = len(src)
356
+ while i < n:
357
+ ch = src[i]
358
+ if ch in ("'", '"', "`"):
359
+ i = _skip_string(src, i) + 1
360
+ continue
361
+ if ch == opener:
362
+ depth += 1
363
+ elif ch == closer:
364
+ depth -= 1
365
+ if depth == 0:
366
+ return i
367
+ i += 1
368
+ return -1
369
+
370
+
371
+ def parse_surface(src: str) -> JsSurface:
372
+ """Return a ``JsSurface`` describing the file's exports + signals.
373
+
374
+ Forgiving: any regex misfire on malformed source produces an empty
375
+ field rather than raising. Block comments and string literals are
376
+ stripped first so we don't pick up imports inside docs.
377
+ """
378
+ # Two views:
379
+ # ``cleaned`` — comments + string contents stripped (for surface regexes)
380
+ # ``no_comm`` — comments stripped only (for brace-walking, which needs
381
+ # the real braces)
382
+ cleaned = strip_comments_and_strings(src)
383
+ no_comm = strip_comments(src)
384
+ s = JsSurface()
385
+
386
+ s.imports = parse_imports(src)
387
+ s.exported_functions = _unique(_EXPORT_FUNCTION_RE.findall(cleaned))
388
+ s.exported_classes = _unique(_EXPORT_CLASS_RE.findall(cleaned))
389
+ s.exported_consts = _unique(_EXPORT_CONST_RE.findall(cleaned))
390
+
391
+ default_obj = _EXPORT_DEFAULT_OBJECT_OPEN_RE.search(no_comm)
392
+ default_fn = _EXPORT_DEFAULT_FUNCTION_RE.search(cleaned)
393
+ default_cls = _EXPORT_DEFAULT_CLASS_RE.search(cleaned)
394
+ if default_obj:
395
+ s.default_export_kind = "object"
396
+ # The match ends just past the opening ``{`` — step back one to point
397
+ # at the brace.
398
+ open_idx = default_obj.end() - 1
399
+ s.default_export_keys = _top_level_object_keys(no_comm, open_idx)
400
+ if "fetch" in s.default_export_keys:
401
+ s.has_worker_default_fetch = True
402
+ if "scheduled" in s.default_export_keys:
403
+ s.has_scheduled_handler = True
404
+ elif default_fn:
405
+ s.default_export_kind = "function"
406
+ if default_fn.group(1):
407
+ s.default_export_keys = [default_fn.group(1)]
408
+ elif default_cls:
409
+ s.default_export_kind = "class"
410
+ if default_cls.group(1):
411
+ s.default_export_keys = [default_cls.group(1)]
412
+
413
+ if _MODULE_EXPORTS_OBJECT_RE.search(cleaned):
414
+ s.has_module_exports = True
415
+ s.exported_consts.extend(
416
+ n for n in _EXPORTS_PROPERTY_RE.findall(cleaned)
417
+ if n not in s.exported_consts
418
+ )
419
+
420
+ s.top_level_functions = _unique(_TOP_FUNCTION_RE.findall(cleaned))
421
+ s.top_level_classes = _unique(_TOP_CLASS_RE.findall(cleaned))
422
+ s.top_level_consts = _unique(_TOP_CONST_RE.findall(cleaned))
423
+
424
+ s.has_class = bool(s.top_level_classes or s.exported_classes)
425
+ if not s.has_scheduled_handler and re.search(r"\bscheduled\s*\(", cleaned):
426
+ s.has_scheduled_handler = True
427
+
428
+ s.statement_count = sum(1 for line in cleaned.splitlines() if line.strip())
429
+ return s
430
+
431
+
432
+ def _unique(items: list[str]) -> list[str]:
433
+ seen: list[str] = []
434
+ for x in items:
435
+ if x and x not in seen:
436
+ seen.append(x)
437
+ return seen
438
+
439
+
440
+ # --- tier classification --------------------------------------------------
441
+
442
+ def classify_js_tier(*, path: str, surface: JsSurface) -> str:
443
+ """Return the canonical tier directory for a JS/TS file.
444
+
445
+ Honours an explicit ``aN_*`` directory placement first; otherwise infers
446
+ from the parsed surface:
447
+
448
+ * ``a4_sy_orchestration`` — Cloudflare Worker default ``{ fetch }``,
449
+ scheduled handlers, top-level ``server.listen``, or a CLI shebang.
450
+ * ``a0_qk_constants`` — only ``export const`` declarations, no
451
+ functions, no classes.
452
+ * ``a2_mo_composites`` — has a class or holds module-level state.
453
+ * ``a3_og_features`` — directory hint or feature-flavoured name.
454
+ * ``a1_at_functions`` — pure function module (default).
455
+ """
456
+ norm = path.replace("\\", "/").lower()
457
+ for tier in (
458
+ "a0_qk_constants",
459
+ "a1_at_functions",
460
+ "a2_mo_composites",
461
+ "a3_og_features",
462
+ "a4_sy_orchestration",
463
+ ):
464
+ if f"/{tier}/" in f"/{norm}/" or norm.startswith(f"{tier}/"):
465
+ return tier
466
+
467
+ if surface.has_worker_default_fetch or surface.has_scheduled_handler:
468
+ return "a4_sy_orchestration"
469
+
470
+ name = norm.rsplit("/", 1)[-1]
471
+ if (
472
+ name.endswith(("_main.js", "_cli.js", "_runner.js", "_server.js",
473
+ "_main.ts", "_cli.ts", "_runner.ts", "_server.ts"))
474
+ or name in {"server.js", "main.js", "index.js", "worker.js",
475
+ "server.ts", "main.ts", "index.ts", "worker.ts"}
476
+ or "/bin/" in f"/{norm}"
477
+ ):
478
+ # index.js / main.ts often *are* the top entry point of a package
479
+ return "a4_sy_orchestration"
480
+
481
+ only_consts = (
482
+ surface.exported_consts
483
+ and not surface.exported_functions
484
+ and not surface.exported_classes
485
+ and not surface.top_level_functions
486
+ and not surface.top_level_classes
487
+ and not surface.default_export_kind
488
+ )
489
+ if only_consts:
490
+ return "a0_qk_constants"
491
+
492
+ if surface.has_class:
493
+ return "a2_mo_composites"
494
+
495
+ if any(tag in name for tag in ("_feature", "_pipeline", "_gate", "_service")):
496
+ return "a3_og_features"
497
+
498
+ if any(
499
+ tag in name
500
+ for tag in ("_client", "_store", "_registry", "_core", "_state")
501
+ ):
502
+ return "a2_mo_composites"
503
+
504
+ if any(
505
+ tag in name
506
+ for tag in ("_utils", "_helpers", "_validators", "_parsers",
507
+ "_format", "_formatter")
508
+ ):
509
+ return "a1_at_functions"
510
+
511
+ return "a1_at_functions"
512
+
513
+
514
+ # --- effect inference -----------------------------------------------------
515
+
516
+ _NETWORK_RE = re.compile(
517
+ r"\b(fetch|XMLHttpRequest|WebSocket|http\.request|https\.request)\b"
518
+ )
519
+ _FS_RE = re.compile(
520
+ r"\b(fs\.(?:read|write|append|unlink|mkdir)|require\(['\"]fs['\"]\))"
521
+ )
522
+ _STATE_RE = re.compile(r"\b(let|var)\b")
523
+
524
+
525
+ def detect_js_effects(src: str) -> list[str]:
526
+ """Cheap effect inference. Same shape as the Python ``detect_effects``."""
527
+ cleaned = strip_comments_and_strings(src)
528
+ effects: list[str] = []
529
+ if _NETWORK_RE.search(cleaned) or _FS_RE.search(cleaned):
530
+ effects.append("io")
531
+ if "class " in cleaned or _STATE_RE.search(cleaned):
532
+ effects.append("state")
533
+ if not effects:
534
+ return ["pure"]
535
+ seen: list[str] = []
536
+ for e in effects:
537
+ if e not in seen:
538
+ seen.append(e)
539
+ return seen
@@ -0,0 +1,144 @@
1
+ """Tier a1 — pure Vanguard-style lineage chain primitives.
2
+
3
+ Golden Path Lane A W4 deliverable. Until AAAA-Nexus
4
+ ``/v1/forge/lineage`` ships, every Receipt still gets a real
5
+ ``lineage`` block via a local content-addressed chain:
6
+
7
+ receipt_hash := SHA-256(canonical_json(receipt minus mutable fields))
8
+ link_n.parent_receipt_hash = receipt_hash(receipt_{n-1})
9
+ link_n.chain_depth = link_{n-1}.chain_depth + 1
10
+ link_n.lineage_path = 'local://lineage-chain/<receipt_hash>'
11
+
12
+ Mutable fields excluded from the canonical hash:
13
+ signatures — added by Lane A W2 signer (would change the hash if
14
+ we hashed AFTER signing)
15
+ lineage — circular: hashing the lineage block would require
16
+ the hash to depend on itself
17
+ notes — soft-fail strings appended over time
18
+ generated_at_utc — timestamp drift between emit and sign
19
+
20
+ Once Vanguard ships, ``a2_mo_composites.lineage_chain_store`` will
21
+ also POST each link to ``/v1/forge/lineage`` with the same soft-fail
22
+ contract as W2 signer; the LOCAL chain remains the source of truth.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import hashlib
27
+ import json
28
+ from copy import deepcopy
29
+ from typing import Any
30
+
31
+ from ..a0_qk_constants.receipt_schema import ForgeReceiptV1, ReceiptLineage
32
+
33
+ _HASH_EXCLUDE_FIELDS: frozenset[str] = frozenset({
34
+ "signatures",
35
+ "lineage",
36
+ "notes",
37
+ "generated_at_utc",
38
+ })
39
+
40
+ _LOCAL_LINEAGE_PREFIX: str = "local://lineage-chain/"
41
+
42
+
43
+ def canonical_receipt_hash(receipt: ForgeReceiptV1) -> str:
44
+ """SHA-256 hex digest of the receipt's structural content.
45
+
46
+ Excludes the four mutable fields enumerated in
47
+ ``_HASH_EXCLUDE_FIELDS`` so signing / re-emitting / re-noting a
48
+ receipt does not change its identity. Two receipts with the same
49
+ project + verdict + certify + wire + scout content yield the same
50
+ hash regardless of sign state.
51
+ """
52
+ stripped: dict[str, Any] = {
53
+ k: v for k, v in receipt.items()
54
+ if k not in _HASH_EXCLUDE_FIELDS
55
+ }
56
+ canonical = json.dumps(stripped, sort_keys=True, default=str)
57
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
58
+
59
+
60
+ def link_to_parent(
61
+ receipt: ForgeReceiptV1,
62
+ *,
63
+ parent_receipt_hash: str | None,
64
+ chain_depth: int,
65
+ lineage_path: str | None = None,
66
+ ) -> ForgeReceiptV1:
67
+ """Return a deep-copy of ``receipt`` with the lineage block populated.
68
+
69
+ ``parent_receipt_hash``: SHA-256 hex digest of the immediately
70
+ prior link's receipt (None for chain head).
71
+ ``chain_depth``: 1 for the chain head; n+1 for each link.
72
+ ``lineage_path``: optional override; defaults to a local URI
73
+ of the form ``local://lineage-chain/<hash>``.
74
+
75
+ Pure: input is not mutated.
76
+ """
77
+ if chain_depth < 1:
78
+ raise ValueError("chain_depth must be >= 1")
79
+ out = deepcopy(receipt)
80
+ own_hash = canonical_receipt_hash(out)
81
+ path = lineage_path or f"{_LOCAL_LINEAGE_PREFIX}{own_hash}"
82
+ out["lineage"] = ReceiptLineage(
83
+ lineage_path=path,
84
+ parent_receipt_hash=parent_receipt_hash,
85
+ chain_depth=chain_depth,
86
+ )
87
+ return out
88
+
89
+
90
+ def is_local_lineage(lineage_path: str | None) -> bool:
91
+ """True when the lineage_path is a local-chain pointer.
92
+
93
+ Useful for downstream tools (CS-1 PDF, Lane B Studio) to render a
94
+ 'local-only' badge until the AAAA-Nexus Vanguard wire-up ships.
95
+ """
96
+ return bool(lineage_path) and str(lineage_path).startswith(_LOCAL_LINEAGE_PREFIX)
97
+
98
+
99
+ def verify_chain_link(
100
+ child: ForgeReceiptV1,
101
+ parent: ForgeReceiptV1 | None,
102
+ ) -> tuple[bool, list[str]]:
103
+ """Verify that ``child`` is a valid successor of ``parent``.
104
+
105
+ Returns (ok, problems). ``ok`` is True when:
106
+ * child.lineage.parent_receipt_hash == hash(parent) (or both None)
107
+ * child.lineage.chain_depth == parent.lineage.chain_depth + 1 (or 1)
108
+
109
+ Pure check; no I/O. Used by the chain store and by anyone who
110
+ wants to audit a saved chain offline.
111
+ """
112
+ problems: list[str] = []
113
+ lineage = child.get("lineage") or {}
114
+ if not lineage:
115
+ problems.append("child receipt has no lineage block")
116
+ return False, problems
117
+ declared_parent = lineage.get("parent_receipt_hash")
118
+ declared_depth = lineage.get("chain_depth")
119
+
120
+ if parent is None:
121
+ if declared_parent is not None:
122
+ problems.append(
123
+ "chain head must have parent_receipt_hash=None; "
124
+ f"got {declared_parent!r}"
125
+ )
126
+ if declared_depth != 1:
127
+ problems.append(
128
+ f"chain head must have chain_depth=1; got {declared_depth!r}"
129
+ )
130
+ else:
131
+ expected_parent_hash = canonical_receipt_hash(parent)
132
+ if declared_parent != expected_parent_hash:
133
+ problems.append(
134
+ f"parent_receipt_hash mismatch: declared={declared_parent!r} "
135
+ f"expected={expected_parent_hash!r}"
136
+ )
137
+ parent_lineage = parent.get("lineage") or {}
138
+ parent_depth = int(parent_lineage.get("chain_depth", 0))
139
+ if declared_depth != parent_depth + 1:
140
+ problems.append(
141
+ f"chain_depth not monotonic: declared={declared_depth!r} "
142
+ f"parent_depth={parent_depth!r}"
143
+ )
144
+ return (not problems), problems