Skip to content

Commit dfe6702

Browse files
calvingilesclaude
andauthored
feat: Support file path references in validate-dsl (#37)
This commit adds support for cross-document references using relative file paths in addition to module IDs, resolving issue #36. **Problem:** The DSL validator could not resolve references like `./ADR-011.md` that use relative file paths instead of module IDs. This caused validation failures even when the referenced documents existed and were in scope. **Root Cause:** - Reference extractor classified file paths as module_reference - `_extract_module_id()` stripped `.md` → `./ADR-011` - Registry lookup by ID failed (registry only had `ADR-011`) - No fallback to file path resolution **Solution:** 1. Added `get_module_by_file()` to IDRegistry for file path lookups 2. Added `_is_file_path()` to detect path-style references 3. Added `_resolve_by_file_path()` to resolve relative paths 4. Updated `_resolve_module_reference()` to try file path resolution when direct ID lookup fails **Testing:** - Added comprehensive test case reproducing the issue - Test creates ADRs with file path cross-references - All 275 tests pass **Impact:** This enables spec-check to work with real-world specification repositories that follow standard markdown linking conventions (file paths), not just module ID references. Fixes #36 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent ac91822 commit dfe6702

File tree

4 files changed

+146
-2
lines changed

4 files changed

+146
-2
lines changed

spec_check/dsl/id_registry.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,37 @@ def get_class(self, class_id: str) -> ClassInstance | None:
173173
"""Get a class instance by ID."""
174174
return self.classes.get(class_id)
175175

176+
def get_module_by_file(self, file_path: Path) -> ModuleInstance | None:
177+
"""
178+
Get a module instance by its file path.
179+
180+
This enables resolving cross-document references that use relative file paths
181+
instead of module IDs (e.g., './011-deployment-architecture.md' → 'ADR-011').
182+
183+
Args:
184+
file_path: Absolute or relative path to the module file
185+
186+
Returns:
187+
ModuleInstance if found, None otherwise
188+
"""
189+
# Resolve to absolute path for comparison
190+
try:
191+
target_path = file_path.resolve()
192+
except (OSError, RuntimeError):
193+
# Path doesn't exist or can't be resolved
194+
return None
195+
196+
# Search all registered modules for matching file path
197+
for module in self.modules.values():
198+
try:
199+
if module.file_path.resolve() == target_path:
200+
return module
201+
except (OSError, RuntimeError):
202+
# Module file path can't be resolved, skip it
203+
continue
204+
205+
return None
206+
176207
def find_class_in_module(self, class_id: str, module_id: str) -> ClassInstance | None:
177208
"""
178209
Find a class instance within a specific module.

spec_check/dsl/reference_resolver.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,13 @@ def _resolve_module_reference(
123123
error=f"Could not extract module ID from target: {reference.link_target}",
124124
)
125125

126-
# Look up in registry
126+
# Try to look up by module ID first
127127
target_module = self.registry.get_module(target_id)
128128

129+
# If not found by ID and target looks like a file path, try file path resolution
130+
if not target_module and self._is_file_path(target_id):
131+
target_module = self._resolve_by_file_path(reference.source_file, target_id)
132+
129133
if not target_module:
130134
# Check if there are similar IDs (for helpful error messages)
131135
suggestions = self._find_similar_module_ids(target_id)
@@ -269,6 +273,50 @@ def validate_cardinality(
269273

270274
return violations
271275

276+
def _is_file_path(self, target: str) -> bool:
277+
"""
278+
Check if a target looks like a file path rather than a module ID.
279+
280+
File paths typically contain:
281+
- Directory separators (/ or \\)
282+
- Relative path indicators (./ or ../)
283+
- File extensions before they're stripped
284+
285+
Args:
286+
target: The target string (already processed by _extract_module_id)
287+
288+
Returns:
289+
True if target looks like a file path
290+
"""
291+
# Check for path separators or relative path indicators
292+
return "/" in target or "\\" in target or target.startswith(".")
293+
294+
def _resolve_by_file_path(self, source_file: Path, target_path: str) -> ModuleInstance | None:
295+
"""
296+
Resolve a module reference using a file path.
297+
298+
Converts a relative file path like './011-deployment-architecture'
299+
to an absolute path and looks it up in the registry.
300+
301+
Args:
302+
source_file: Path to the source file containing the reference
303+
target_path: Relative or absolute file path to the target (without .md extension)
304+
305+
Returns:
306+
ModuleInstance if found, None otherwise
307+
"""
308+
# Add back the .md extension that was stripped by _extract_module_id
309+
if not target_path.endswith(".md"):
310+
target_path_with_ext = target_path + ".md"
311+
else:
312+
target_path_with_ext = target_path
313+
314+
# Resolve relative to source file's directory
315+
target_file = source_file.parent / target_path_with_ext
316+
317+
# Look up by file path in registry
318+
return self.registry.get_module_by_file(target_file)
319+
272320
def _extract_module_id(self, link_target: str) -> str | None:
273321
"""Extract module ID from link target."""
274322
# Remove .md extension

tests/test_dsl_coverage.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,71 @@ def test_reference_cardinality_validation(self, tmp_path):
348348
# Should have an error about missing "addresses" reference
349349
assert any("addresses" in msg.lower() for msg in error_messages)
350350

351+
def test_file_path_reference_resolution(self, tmp_path):
352+
"""Test resolving cross-document references using file paths.
353+
354+
This test reproduces issue #36 where validate-dsl cannot resolve
355+
references that use relative file paths instead of module IDs.
356+
357+
ADR documents often link to each other using standard markdown
358+
file path links like './011-deployment-architecture.md' which should
359+
resolve to module ID 'ADR-011'.
360+
"""
361+
(tmp_path / "specs" / "architecture").mkdir(parents=True)
362+
363+
# Create first ADR with a known module ID (using correct pattern ADR-NNN.md)
364+
adr_011 = tmp_path / "specs" / "architecture" / "ADR-011.md"
365+
adr_011.write_text("""# ADR-011: Deployment Architecture
366+
367+
## Status
368+
Accepted
369+
370+
## Context
371+
Deployment architecture decisions.
372+
373+
## Decision
374+
Use cloud infrastructure.
375+
376+
## Consequences
377+
Improved scalability.
378+
""")
379+
380+
# Create second ADR that references the first using a file path
381+
adr_015 = tmp_path / "specs" / "architecture" / "ADR-015.md"
382+
adr_015.write_text("""# ADR-015: Developer Access Control Strategy
383+
384+
## Status
385+
Accepted
386+
387+
## Context
388+
Access control strategy.
389+
390+
## Decision
391+
Implement RBAC.
392+
393+
## Adheres To
394+
- [ADR-011: Deployment Architecture](./ADR-011.md)
395+
396+
## Consequences
397+
Better security.
398+
""")
399+
400+
registry = SpecTypeRegistry.load_builtin_types()
401+
validator = DSLValidator(registry)
402+
403+
# Before the fix, this fails with:
404+
# Module reference './ADR-011' not found
405+
result = validator.validate(tmp_path / "specs")
406+
407+
assert result is not None
408+
409+
# The file path reference should resolve successfully
410+
# Check that there's no error about './ADR-011' not being found
411+
error_messages = [e.message for e in result.errors]
412+
assert not any("'./ADR-011'" in msg for msg in error_messages), (
413+
f"File path reference should resolve, but got errors: {error_messages}"
414+
)
415+
351416

352417
class TestRegistryLoading:
353418
"""Test registry loading."""

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)