tactus 0.33.0__py3-none-any.whl → 0.34.0__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 (100) hide show
  1. tactus/__init__.py +1 -1
  2. tactus/adapters/__init__.py +18 -1
  3. tactus/adapters/broker_log.py +127 -34
  4. tactus/adapters/channels/__init__.py +153 -0
  5. tactus/adapters/channels/base.py +174 -0
  6. tactus/adapters/channels/broker.py +179 -0
  7. tactus/adapters/channels/cli.py +448 -0
  8. tactus/adapters/channels/host.py +225 -0
  9. tactus/adapters/channels/ipc.py +297 -0
  10. tactus/adapters/channels/sse.py +305 -0
  11. tactus/adapters/cli_hitl.py +223 -1
  12. tactus/adapters/control_loop.py +879 -0
  13. tactus/adapters/file_storage.py +35 -2
  14. tactus/adapters/ide_log.py +7 -1
  15. tactus/backends/http_backend.py +0 -1
  16. tactus/broker/client.py +31 -1
  17. tactus/broker/server.py +416 -92
  18. tactus/cli/app.py +270 -7
  19. tactus/cli/control.py +393 -0
  20. tactus/core/config_manager.py +33 -6
  21. tactus/core/dsl_stubs.py +102 -18
  22. tactus/core/execution_context.py +265 -8
  23. tactus/core/lua_sandbox.py +8 -9
  24. tactus/core/registry.py +19 -2
  25. tactus/core/runtime.py +235 -27
  26. tactus/docker/Dockerfile.pypi +49 -0
  27. tactus/docs/__init__.py +33 -0
  28. tactus/docs/extractor.py +326 -0
  29. tactus/docs/html_renderer.py +72 -0
  30. tactus/docs/models.py +121 -0
  31. tactus/docs/templates/base.html +204 -0
  32. tactus/docs/templates/index.html +58 -0
  33. tactus/docs/templates/module.html +96 -0
  34. tactus/dspy/agent.py +382 -22
  35. tactus/dspy/broker_lm.py +57 -6
  36. tactus/dspy/config.py +14 -3
  37. tactus/dspy/history.py +2 -1
  38. tactus/dspy/module.py +136 -11
  39. tactus/dspy/signature.py +0 -1
  40. tactus/ide/server.py +300 -9
  41. tactus/primitives/human.py +619 -47
  42. tactus/primitives/system.py +0 -1
  43. tactus/protocols/__init__.py +25 -0
  44. tactus/protocols/control.py +427 -0
  45. tactus/protocols/notification.py +207 -0
  46. tactus/sandbox/container_runner.py +79 -11
  47. tactus/sandbox/docker_manager.py +23 -0
  48. tactus/sandbox/entrypoint.py +26 -0
  49. tactus/sandbox/protocol.py +3 -0
  50. tactus/stdlib/README.md +77 -0
  51. tactus/stdlib/__init__.py +27 -1
  52. tactus/stdlib/classify/__init__.py +165 -0
  53. tactus/stdlib/classify/classify.spec.tac +195 -0
  54. tactus/stdlib/classify/classify.tac +257 -0
  55. tactus/stdlib/classify/fuzzy.py +282 -0
  56. tactus/stdlib/classify/llm.py +319 -0
  57. tactus/stdlib/classify/primitive.py +287 -0
  58. tactus/stdlib/core/__init__.py +57 -0
  59. tactus/stdlib/core/base.py +320 -0
  60. tactus/stdlib/core/confidence.py +211 -0
  61. tactus/stdlib/core/models.py +161 -0
  62. tactus/stdlib/core/retry.py +171 -0
  63. tactus/stdlib/core/validation.py +274 -0
  64. tactus/stdlib/extract/__init__.py +125 -0
  65. tactus/stdlib/extract/llm.py +330 -0
  66. tactus/stdlib/extract/primitive.py +256 -0
  67. tactus/stdlib/tac/tactus/classify/base.tac +51 -0
  68. tactus/stdlib/tac/tactus/classify/fuzzy.tac +87 -0
  69. tactus/stdlib/tac/tactus/classify/index.md +77 -0
  70. tactus/stdlib/tac/tactus/classify/init.tac +29 -0
  71. tactus/stdlib/tac/tactus/classify/llm.tac +150 -0
  72. tactus/stdlib/tac/tactus/classify.spec.tac +191 -0
  73. tactus/stdlib/tac/tactus/extract/base.tac +138 -0
  74. tactus/stdlib/tac/tactus/extract/index.md +96 -0
  75. tactus/stdlib/tac/tactus/extract/init.tac +27 -0
  76. tactus/stdlib/tac/tactus/extract/llm.tac +201 -0
  77. tactus/stdlib/tac/tactus/extract.spec.tac +153 -0
  78. tactus/stdlib/tac/tactus/generate/base.tac +142 -0
  79. tactus/stdlib/tac/tactus/generate/index.md +195 -0
  80. tactus/stdlib/tac/tactus/generate/init.tac +28 -0
  81. tactus/stdlib/tac/tactus/generate/llm.tac +169 -0
  82. tactus/stdlib/tac/tactus/generate.spec.tac +210 -0
  83. tactus/testing/behave_integration.py +171 -7
  84. tactus/testing/context.py +0 -1
  85. tactus/testing/evaluation_runner.py +0 -1
  86. tactus/testing/gherkin_parser.py +0 -1
  87. tactus/testing/mock_hitl.py +0 -1
  88. tactus/testing/mock_tools.py +0 -1
  89. tactus/testing/models.py +0 -1
  90. tactus/testing/steps/builtin.py +0 -1
  91. tactus/testing/steps/custom.py +81 -22
  92. tactus/testing/steps/registry.py +0 -1
  93. tactus/testing/test_runner.py +7 -1
  94. tactus/validation/semantic_visitor.py +11 -5
  95. tactus/validation/validator.py +0 -1
  96. {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/METADATA +14 -2
  97. {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/RECORD +100 -49
  98. {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/WHEEL +0 -0
  99. {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/entry_points.txt +0 -0
  100. {tactus-0.33.0.dist-info → tactus-0.34.0.dist-info}/licenses/LICENSE +0 -0
@@ -21,6 +21,7 @@ from tactus.protocols.models import (
21
21
  SourceLocation,
22
22
  ExecutionRun,
23
23
  )
24
+ from tactus.core.exceptions import ProcedureWaitingForHuman
24
25
 
25
26
  logger = logging.getLogger(__name__)
26
27
 
@@ -153,9 +154,20 @@ class BaseExecutionContext(ExecutionContext):
153
154
  # Lua sandbox reference for debug.getinfo access
154
155
  self.lua_sandbox: Optional[Any] = None
155
156
 
157
+ # Rich metadata for HITL notifications
158
+ self.procedure_name: str = procedure_id # Use procedure_id as default name
159
+ self.invocation_id: str = str(uuid.uuid4())
160
+ self._started_at: datetime = datetime.now(timezone.utc)
161
+ self._input_data: Any = None
162
+
156
163
  # Load procedure metadata (contains execution_log and replay_index)
157
164
  self.metadata = self.storage.load_procedure_metadata(procedure_id)
158
165
 
166
+ # CRITICAL: Reset replay_index to 0 when starting a new execution
167
+ # The replay_index tracks our position when replaying the execution_log
168
+ # It must start at 0 for each new run, even though it was incremented during the previous run
169
+ self.metadata.replay_index = 0
170
+
159
171
  def set_run_id(self, run_id: str) -> None:
160
172
  """Set the run_id for subsequent checkpoints in this execution."""
161
173
  self.current_run_id = run_id
@@ -175,6 +187,21 @@ class BaseExecutionContext(ExecutionContext):
175
187
  """Store reference to Lua sandbox for debug.getinfo access."""
176
188
  self.lua_sandbox = lua_sandbox
177
189
 
190
+ def set_procedure_metadata(
191
+ self, procedure_name: Optional[str] = None, input_data: Any = None
192
+ ) -> None:
193
+ """
194
+ Set rich metadata for HITL notifications.
195
+
196
+ Args:
197
+ procedure_name: Human-readable name for the procedure
198
+ input_data: Input data passed to the procedure
199
+ """
200
+ if procedure_name is not None:
201
+ self.procedure_name = procedure_name
202
+ if input_data is not None:
203
+ self._input_data = input_data
204
+
178
205
  def checkpoint(
179
206
  self,
180
207
  fn: Callable[[], Any],
@@ -187,17 +214,50 @@ class BaseExecutionContext(ExecutionContext):
187
214
  On replay, returns cached result from execution log.
188
215
  On first execution, runs fn(), records in log, and returns result.
189
216
  """
190
- logger.debug(
191
- f"[CHECKPOINT] checkpoint() called, type={checkpoint_type}, has_log_handler={self.log_handler is not None}"
217
+ logger.info(
218
+ f"[CHECKPOINT] checkpoint() called, type={checkpoint_type}, position={self.metadata.replay_index}, "
219
+ f"current_run_id={self.current_run_id}, has_log_handler={self.log_handler is not None}"
192
220
  )
193
221
  current_position = self.metadata.replay_index
194
222
 
195
223
  # Check if we're in replay mode (checkpoint exists at this position)
196
224
  if current_position < len(self.metadata.execution_log):
197
- # Replay mode: return cached result
198
225
  entry = self.metadata.execution_log[current_position]
199
- self.metadata.replay_index += 1
200
- return entry.result
226
+ logger.info(
227
+ f"[CHECKPOINT] Found existing checkpoint at position {current_position}: "
228
+ f"type={entry.type}, run_id={entry.run_id}, result_type={type(entry.result).__name__}"
229
+ )
230
+
231
+ # CRITICAL: Only replay checkpoints from the CURRENT run
232
+ # Each new run should execute fresh, not use cached results from previous runs
233
+ if entry.run_id != self.current_run_id:
234
+ logger.info(
235
+ f"[CHECKPOINT] Checkpoint is from DIFFERENT run "
236
+ f"(checkpoint run_id={entry.run_id}, current run_id={self.current_run_id}), "
237
+ f"executing fresh (NOT replaying)"
238
+ )
239
+ # Fall through to execute mode - this is a new run
240
+ # Special case: HITL checkpoints may have result=None if saved before response arrived
241
+ # In this case, re-execute to check for cached response from control loop
242
+ elif entry.result is None and checkpoint_type.startswith("hitl_"):
243
+ logger.info(
244
+ f"[CHECKPOINT] HITL checkpoint at position {current_position} has no result, "
245
+ f"re-executing to check for cached response"
246
+ )
247
+ # Fall through to execute mode - will check for cached response
248
+ else:
249
+ # Normal replay: return cached result from CURRENT run
250
+ self.metadata.replay_index += 1
251
+ logger.info(
252
+ f"[CHECKPOINT] REPLAYING checkpoint at position {current_position}, "
253
+ f"type={entry.type}, run_id={entry.run_id}, returning cached result"
254
+ )
255
+ return entry.result
256
+ else:
257
+ logger.info(
258
+ f"[CHECKPOINT] No checkpoint at position {current_position} "
259
+ f"(only {len(self.metadata.execution_log)} checkpoints exist), executing fresh"
260
+ )
201
261
 
202
262
  # Execute mode: run function with checkpoint scope tracking
203
263
  old_checkpoint_flag = self._inside_checkpoint
@@ -239,12 +299,56 @@ class BaseExecutionContext(ExecutionContext):
239
299
  self.metadata.state.copy() if hasattr(self.metadata, "state") else None
240
300
  ),
241
301
  )
302
+ except ProcedureWaitingForHuman:
303
+ # CRITICAL: For HITL checkpoints, we need to save the checkpoint BEFORE exiting
304
+ # This enables transparent resume - on restart, we'll have a checkpoint at this position
305
+ # with result=None, and the control loop will check for cached responses
306
+ duration_ms = (time.time() - start_time) * 1000
307
+ entry = CheckpointEntry(
308
+ position=current_position,
309
+ type=checkpoint_type,
310
+ result=None, # Will be filled in when response arrives
311
+ timestamp=datetime.now(timezone.utc),
312
+ duration_ms=duration_ms,
313
+ run_id=self.current_run_id,
314
+ source_location=source_location,
315
+ captured_vars=(
316
+ self.metadata.state.copy() if hasattr(self.metadata, "state") else None
317
+ ),
318
+ )
319
+ # Only append if checkpoint doesn't already exist (from previous failed attempt)
320
+ if current_position < len(self.metadata.execution_log):
321
+ # Checkpoint already exists - update it
322
+ logger.debug(
323
+ f"[CHECKPOINT] Updating existing HITL checkpoint at position {current_position} before exit"
324
+ )
325
+ self.metadata.execution_log[current_position] = entry
326
+ else:
327
+ # New checkpoint - append and increment
328
+ logger.debug(
329
+ f"[CHECKPOINT] Creating new HITL checkpoint at position {current_position} before exit"
330
+ )
331
+ self.metadata.execution_log.append(entry)
332
+ self.metadata.replay_index += 1
333
+
334
+ self.storage.save_procedure_metadata(self.procedure_id, self.metadata)
335
+ # Restore checkpoint flag and re-raise
336
+ self._inside_checkpoint = old_checkpoint_flag
337
+ raise
242
338
  finally:
243
339
  # Always restore checkpoint flag, even if fn() raises
244
340
  self._inside_checkpoint = old_checkpoint_flag
245
341
 
246
- # Add to execution log
247
- self.metadata.execution_log.append(entry)
342
+ # Add to execution log (or update if checkpoint already exists from HITL exit)
343
+ if current_position < len(self.metadata.execution_log):
344
+ # Checkpoint already exists (saved during HITL exit) - update it with the result
345
+ logger.debug(
346
+ f"[CHECKPOINT] Updating existing HITL checkpoint at position {current_position} with result"
347
+ )
348
+ self.metadata.execution_log[current_position] = entry
349
+ else:
350
+ # New checkpoint - append to log
351
+ self.metadata.execution_log.append(entry)
248
352
  self.metadata.replay_index += 1
249
353
 
250
354
  # Emit checkpoint created event if we have a log handler
@@ -298,8 +402,14 @@ class BaseExecutionContext(ExecutionContext):
298
402
 
299
403
  Delegates to the HITLHandler protocol implementation.
300
404
  """
405
+ logger.debug(
406
+ f"[HITL] wait_for_human called: type={request_type}, message={message[:50] if message else 'None'}, hitl_handler={self.hitl}"
407
+ )
301
408
  if not self.hitl:
302
409
  # No HITL handler - return default immediately
410
+ logger.warning(
411
+ f"[HITL] No HITL handler configured - returning default value: {default_value}"
412
+ )
303
413
  return HITLResponse(
304
414
  value=default_value, responded_at=datetime.now(timezone.utc), timed_out=True
305
415
  )
@@ -315,7 +425,8 @@ class BaseExecutionContext(ExecutionContext):
315
425
  )
316
426
 
317
427
  # Delegate to HITL handler (may raise ProcedureWaitingForHuman)
318
- return self.hitl.request_interaction(self.procedure_id, request)
428
+ # Pass self (execution_context) for deterministic request ID generation
429
+ return self.hitl.request_interaction(self.procedure_id, request, execution_context=self)
319
430
 
320
431
  def sleep(self, seconds: int) -> None:
321
432
  """
@@ -457,6 +568,152 @@ class BaseExecutionContext(ExecutionContext):
457
568
 
458
569
  return run_id
459
570
 
571
+ def get_subject(self) -> Optional[str]:
572
+ """
573
+ Return a human-readable subject line for this execution.
574
+
575
+ Returns:
576
+ Subject line combining procedure name and current checkpoint position
577
+ """
578
+ checkpoint_pos = self.next_position()
579
+ if self.procedure_name:
580
+ return f"{self.procedure_name} (checkpoint {checkpoint_pos})"
581
+ return f"Procedure {self.procedure_id} (checkpoint {checkpoint_pos})"
582
+
583
+ def get_started_at(self) -> Optional[datetime]:
584
+ """
585
+ Return when this execution started.
586
+
587
+ Returns:
588
+ Timestamp when execution context was created
589
+ """
590
+ return self._started_at
591
+
592
+ def get_input_summary(self) -> Optional[Dict[str, Any]]:
593
+ """
594
+ Return a summary of the initial input to this procedure.
595
+
596
+ Returns:
597
+ Dict of input data, or None if no input
598
+ """
599
+ if self._input_data is None:
600
+ return None
601
+
602
+ # If input_data is already a dict, return it
603
+ if isinstance(self._input_data, dict):
604
+ return self._input_data
605
+
606
+ # Otherwise wrap it in a dict
607
+ return {"value": self._input_data}
608
+
609
+ def get_conversation_history(self) -> Optional[List[Dict]]:
610
+ """
611
+ Return conversation history if available.
612
+
613
+ Returns:
614
+ List of conversation messages, or None if not tracked
615
+ """
616
+ # For now, return None - could be extended to track agent conversations
617
+ # in future implementations
618
+ return None
619
+
620
+ def get_prior_control_interactions(self) -> Optional[List[Dict]]:
621
+ """
622
+ Return list of prior HITL interactions in this execution.
623
+
624
+ Returns:
625
+ List of HITL checkpoint entries from execution log
626
+ """
627
+ if not self.metadata or not self.metadata.execution_log:
628
+ return None
629
+
630
+ # Filter execution log for HITL checkpoints
631
+ hitl_checkpoints = [
632
+ {
633
+ "position": entry.position,
634
+ "type": entry.type,
635
+ "timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
636
+ "duration_ms": entry.duration_ms,
637
+ }
638
+ for entry in self.metadata.execution_log
639
+ if entry.type.startswith("hitl_")
640
+ ]
641
+
642
+ return hitl_checkpoints if hitl_checkpoints else None
643
+
644
+ def get_lua_source_line(self) -> Optional[int]:
645
+ """
646
+ Get the current source line from Lua debug.getinfo.
647
+
648
+ Returns:
649
+ Line number or None if unavailable
650
+ """
651
+ if not self.lua_sandbox:
652
+ return None
653
+
654
+ try:
655
+ # Access Lua debug module to get current line
656
+ debug_mod = self.lua_sandbox.globals().debug
657
+ if debug_mod and hasattr(debug_mod, "getinfo"):
658
+ # getinfo(2) gets info about the calling function
659
+ # We need to go up the stack to find the user's code
660
+ for level in range(2, 10):
661
+ try:
662
+ info = debug_mod.getinfo(level, "Sl")
663
+ if info:
664
+ line = info.get("currentline")
665
+ source = info.get("source", "")
666
+ # Skip internal sources (start with @)
667
+ if line and line > 0 and not source.startswith("@"):
668
+ return int(line)
669
+ except Exception:
670
+ break
671
+ except Exception as e:
672
+ logger.debug(f"Could not get Lua source line: {e}")
673
+
674
+ return None
675
+
676
+ def get_runtime_context(self) -> Dict[str, Any]:
677
+ """
678
+ Build RuntimeContext dict for HITL requests.
679
+
680
+ Captures source location, execution position, elapsed time, and backtrace.
681
+
682
+ Returns:
683
+ Dict with runtime context fields
684
+ """
685
+ # Calculate elapsed time
686
+ elapsed = 0.0
687
+ if self._started_at:
688
+ elapsed = (datetime.now(timezone.utc) - self._started_at).total_seconds()
689
+
690
+ # Get current source location
691
+ source_line = self.get_lua_source_line()
692
+
693
+ # Build backtrace from execution log
694
+ backtrace = []
695
+ if self.metadata and self.metadata.execution_log:
696
+ for entry in self.metadata.execution_log:
697
+ bt_entry = {
698
+ "checkpoint_type": entry.type,
699
+ "duration_ms": entry.duration_ms,
700
+ }
701
+ if entry.source_location:
702
+ bt_entry["line"] = entry.source_location.line
703
+ bt_entry["function_name"] = entry.source_location.function
704
+ backtrace.append(bt_entry)
705
+
706
+ return {
707
+ "source_line": source_line,
708
+ "source_file": self.current_tac_file,
709
+ "checkpoint_position": self.next_position(),
710
+ "procedure_name": self.procedure_name,
711
+ "invocation_id": self.invocation_id,
712
+ "started_at": self._started_at.isoformat() if self._started_at else None,
713
+ "elapsed_seconds": elapsed,
714
+ "backtrace": backtrace,
715
+ }
716
+
460
717
 
461
718
  class InMemoryExecutionContext(BaseExecutionContext):
462
719
  """
@@ -125,16 +125,14 @@ class LuaSandbox:
125
125
  # Whitelist only safe debug functions for source location tracking
126
126
  # Keep debug.getinfo but remove dangerous debug functions
127
127
  if "debug" in lua_globals:
128
- self.lua.execute(
129
- """
128
+ self.lua.execute("""
130
129
  if debug then
131
130
  local safe_debug = {
132
131
  getinfo = debug.getinfo
133
132
  }
134
133
  debug = safe_debug
135
134
  end
136
- """
137
- )
135
+ """)
138
136
  logger.debug("Replaced debug module with safe_debug (only getinfo allowed)")
139
137
 
140
138
  def _setup_safe_require(self):
@@ -158,11 +156,14 @@ class LuaSandbox:
158
156
  # Build search paths:
159
157
  # 1. User's project directory (existing behavior)
160
158
  # 2. Tactus stdlib .tac files
159
+ # Both single-file modules (?.tac) and directory modules (?/init.tac) are supported
161
160
  user_path = os.path.join(self.base_path, "?.tac")
161
+ user_init_path = os.path.join(self.base_path, "?", "init.tac")
162
162
  stdlib_path = os.path.join(stdlib_tac_path, "?.tac")
163
+ stdlib_init_path = os.path.join(stdlib_tac_path, "?", "init.tac")
163
164
 
164
165
  # Normalize backslashes for cross-platform compatibility
165
- paths = [user_path, stdlib_path]
166
+ paths = [user_path, user_init_path, stdlib_path, stdlib_init_path]
166
167
  paths = [p.replace("\\", "/") for p in paths]
167
168
 
168
169
  # Join with Lua's path separator (semicolon)
@@ -202,8 +203,7 @@ class LuaSandbox:
202
203
 
203
204
  # Add to package.loaders (Lua 5.1) or package.searchers (Lua 5.2+)
204
205
  # Lupa uses LuaJIT which follows Lua 5.1 conventions
205
- self.lua.execute(
206
- """
206
+ self.lua.execute("""
207
207
  -- Add Python stdlib loader to package.loaders
208
208
  -- Insert after the preload loader but before path loader
209
209
  local loaders = package.loaders or package.searchers
@@ -221,8 +221,7 @@ class LuaSandbox:
221
221
  -- Insert at position 2 (after preload, before path)
222
222
  table.insert(loaders, 2, python_searcher)
223
223
  end
224
- """
225
- )
224
+ """)
226
225
 
227
226
  logger.debug("Python stdlib loader installed")
228
227
 
tactus/core/registry.py CHANGED
@@ -155,6 +155,7 @@ class ProcedureRegistry(BaseModel):
155
155
 
156
156
  # Gherkin BDD Testing
157
157
  gherkin_specifications: Optional[str] = None # Raw Gherkin text
158
+ specs_from_references: list[str] = Field(default_factory=list) # External spec file paths
158
159
  custom_steps: dict[str, Any] = Field(default_factory=dict) # step_text -> lua_function
159
160
  evaluation_config: dict[str, Any] = Field(default_factory=dict) # runs, parallel, etc.
160
161
 
@@ -413,6 +414,17 @@ class RegistryBuilder:
413
414
  """Register Gherkin BDD specifications."""
414
415
  self.registry.gherkin_specifications = gherkin_text
415
416
 
417
+ def register_specs_from(self, file_path: str) -> None:
418
+ """Register a reference to external specifications.
419
+
420
+ Stores the path for lazy loading. Actual spec content
421
+ is loaded during test execution, not parse time.
422
+
423
+ Args:
424
+ file_path: Path to .spec.tac file or module name
425
+ """
426
+ self.registry.specs_from_references.append(file_path)
427
+
416
428
  def register_custom_step(self, step_text: str, lua_function: Any) -> None:
417
429
  """Register a custom step definition."""
418
430
  self.registry.custom_steps[step_text] = lua_function
@@ -479,11 +491,16 @@ class RegistryBuilder:
479
491
  )
480
492
 
481
493
  # Warnings for missing specifications
482
- if not self.registry.specifications and not self.registry.gherkin_specifications:
494
+ has_specs = (
495
+ self.registry.specifications
496
+ or self.registry.gherkin_specifications
497
+ or self.registry.specs_from_references
498
+ )
499
+ if not has_specs:
483
500
  warnings.append(
484
501
  ValidationMessage(
485
502
  level="warning",
486
- message="No specifications defined - consider adding BDD tests using specifications([[...]])",
503
+ message='No specifications defined - consider adding BDD tests using Specification([[...]]) or Specification { from = "path" }',
487
504
  )
488
505
  )
489
506