elspais 0.9.1__py3-none-any.whl → 0.11.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.
- elspais/cli.py +123 -1
- elspais/commands/changed.py +160 -0
- elspais/commands/hash_cmd.py +72 -26
- elspais/commands/reformat_cmd.py +458 -0
- elspais/commands/trace.py +157 -3
- elspais/commands/validate.py +81 -18
- elspais/core/git.py +352 -0
- elspais/core/models.py +2 -0
- elspais/core/parser.py +68 -24
- elspais/reformat/__init__.py +50 -0
- elspais/reformat/detector.py +119 -0
- elspais/reformat/hierarchy.py +246 -0
- elspais/reformat/line_breaks.py +220 -0
- elspais/reformat/prompts.py +123 -0
- elspais/reformat/transformer.py +264 -0
- elspais/sponsors/__init__.py +432 -0
- elspais/trace_view/__init__.py +54 -0
- elspais/trace_view/coverage.py +183 -0
- elspais/trace_view/generators/__init__.py +12 -0
- elspais/trace_view/generators/base.py +329 -0
- elspais/trace_view/generators/csv.py +122 -0
- elspais/trace_view/generators/markdown.py +175 -0
- elspais/trace_view/html/__init__.py +31 -0
- elspais/trace_view/html/generator.py +1006 -0
- elspais/trace_view/html/templates/base.html +283 -0
- elspais/trace_view/html/templates/components/code_viewer_modal.html +14 -0
- elspais/trace_view/html/templates/components/file_picker_modal.html +20 -0
- elspais/trace_view/html/templates/components/legend_modal.html +69 -0
- elspais/trace_view/html/templates/components/review_panel.html +118 -0
- elspais/trace_view/html/templates/partials/review/help/help-panel.json +244 -0
- elspais/trace_view/html/templates/partials/review/help/onboarding.json +77 -0
- elspais/trace_view/html/templates/partials/review/help/tooltips.json +237 -0
- elspais/trace_view/html/templates/partials/review/review-comments.js +928 -0
- elspais/trace_view/html/templates/partials/review/review-data.js +961 -0
- elspais/trace_view/html/templates/partials/review/review-help.js +679 -0
- elspais/trace_view/html/templates/partials/review/review-init.js +177 -0
- elspais/trace_view/html/templates/partials/review/review-line-numbers.js +429 -0
- elspais/trace_view/html/templates/partials/review/review-packages.js +1029 -0
- elspais/trace_view/html/templates/partials/review/review-position.js +540 -0
- elspais/trace_view/html/templates/partials/review/review-resize.js +115 -0
- elspais/trace_view/html/templates/partials/review/review-status.js +659 -0
- elspais/trace_view/html/templates/partials/review/review-sync.js +992 -0
- elspais/trace_view/html/templates/partials/review-styles.css +2238 -0
- elspais/trace_view/html/templates/partials/scripts.js +1741 -0
- elspais/trace_view/html/templates/partials/styles.css +1756 -0
- elspais/trace_view/models.py +353 -0
- elspais/trace_view/review/__init__.py +60 -0
- elspais/trace_view/review/branches.py +1149 -0
- elspais/trace_view/review/models.py +1205 -0
- elspais/trace_view/review/position.py +609 -0
- elspais/trace_view/review/server.py +1056 -0
- elspais/trace_view/review/status.py +470 -0
- elspais/trace_view/review/storage.py +1367 -0
- elspais/trace_view/scanning.py +213 -0
- elspais/trace_view/specs/README.md +84 -0
- elspais/trace_view/specs/tv-d00001-template-architecture.md +36 -0
- elspais/trace_view/specs/tv-d00002-css-extraction.md +37 -0
- elspais/trace_view/specs/tv-d00003-js-extraction.md +43 -0
- elspais/trace_view/specs/tv-d00004-build-embedding.md +40 -0
- elspais/trace_view/specs/tv-d00005-test-format.md +78 -0
- elspais/trace_view/specs/tv-d00010-review-data-models.md +33 -0
- elspais/trace_view/specs/tv-d00011-review-storage.md +33 -0
- elspais/trace_view/specs/tv-d00012-position-resolution.md +33 -0
- elspais/trace_view/specs/tv-d00013-git-branches.md +31 -0
- elspais/trace_view/specs/tv-d00014-review-api-server.md +31 -0
- elspais/trace_view/specs/tv-d00015-status-modifier.md +27 -0
- elspais/trace_view/specs/tv-d00016-js-integration.md +33 -0
- elspais/trace_view/specs/tv-p00001-html-generator.md +33 -0
- elspais/trace_view/specs/tv-p00002-review-system.md +29 -0
- {elspais-0.9.1.dist-info → elspais-0.11.0.dist-info}/METADATA +78 -26
- elspais-0.11.0.dist-info/RECORD +101 -0
- elspais-0.9.1.dist-info/RECORD +0 -38
- {elspais-0.9.1.dist-info → elspais-0.11.0.dist-info}/WHEEL +0 -0
- {elspais-0.9.1.dist-info → elspais-0.11.0.dist-info}/entry_points.txt +0 -0
- {elspais-0.9.1.dist-info → elspais-0.11.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Implements: REQ-int-d00008 (Reformat Command)
|
|
2
|
+
"""
|
|
3
|
+
Prompts and JSON schema for Claude Code CLI integration.
|
|
4
|
+
|
|
5
|
+
Defines the system prompt and output schema for AI-assisted
|
|
6
|
+
requirement reformatting.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
# JSON Schema for structured output validation
|
|
12
|
+
JSON_SCHEMA = {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"properties": {
|
|
15
|
+
"rationale": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"description": "Non-normative context explaining why this requirement exists. No SHALL/MUST language."
|
|
18
|
+
},
|
|
19
|
+
"assertions": {
|
|
20
|
+
"type": "array",
|
|
21
|
+
"items": {"type": "string"},
|
|
22
|
+
"description": "List of assertions, each starting with 'The system SHALL...' or similar prescriptive language."
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"required": ["rationale", "assertions"]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
JSON_SCHEMA_STR = json.dumps(JSON_SCHEMA, separators=(',', ':'))
|
|
29
|
+
|
|
30
|
+
# System prompt for requirement reformatting
|
|
31
|
+
REFORMAT_SYSTEM_PROMPT = """You are a requirements engineering expert specializing in FDA 21 CFR Part 11 compliant clinical trial systems.
|
|
32
|
+
|
|
33
|
+
Your task is to reformat requirements from an old descriptive format to a new prescriptive assertion-based format.
|
|
34
|
+
|
|
35
|
+
EXTRACTION RULES:
|
|
36
|
+
1. Extract ALL obligations from the old format (body text, bullet points, acceptance criteria)
|
|
37
|
+
2. Convert each distinct obligation to a labeled assertion
|
|
38
|
+
3. Each assertion MUST use "SHALL" for mandatory obligations or "SHALL NOT" for prohibitions
|
|
39
|
+
4. Each assertion MUST be independently testable (decidable as true/false)
|
|
40
|
+
5. Assertions MUST be prescriptive, not descriptive
|
|
41
|
+
6. Maximum 26 assertions (A-Z) - if more needed, consolidate related obligations
|
|
42
|
+
7. Do NOT add obligations that were not in the original
|
|
43
|
+
8. Do NOT remove or weaken any obligations from the original
|
|
44
|
+
9. Combine related acceptance criteria into single assertions when appropriate
|
|
45
|
+
|
|
46
|
+
RATIONALE RULES:
|
|
47
|
+
1. The rationale provides context for WHY this requirement exists
|
|
48
|
+
2. Rationale MUST NOT introduce new obligations
|
|
49
|
+
3. Rationale MUST NOT use SHALL/MUST language
|
|
50
|
+
4. Rationale can explain regulatory context, design decisions, or relationships to other requirements
|
|
51
|
+
|
|
52
|
+
LANGUAGE GUIDELINES:
|
|
53
|
+
- Use "The system SHALL..." for system behaviors
|
|
54
|
+
- Use "The platform SHALL..." for platform-wide requirements
|
|
55
|
+
- Use "Data SHALL..." for data-related requirements
|
|
56
|
+
- Be specific and unambiguous
|
|
57
|
+
- Avoid vague terms like "appropriate", "adequate", "reasonable" unless quantified
|
|
58
|
+
|
|
59
|
+
OUTPUT FORMAT:
|
|
60
|
+
Return a JSON object with:
|
|
61
|
+
- "rationale": A paragraph explaining the requirement's purpose (no SHALL language)
|
|
62
|
+
- "assertions": An array of strings, each being a complete assertion starting with the subject and SHALL
|
|
63
|
+
|
|
64
|
+
Example output:
|
|
65
|
+
{
|
|
66
|
+
"rationale": "This requirement ensures complete audit trails for regulatory compliance. FDA 21 CFR Part 11 mandates that electronic records maintain tamper-evident histories of all modifications.",
|
|
67
|
+
"assertions": [
|
|
68
|
+
"The system SHALL store all data changes as immutable events.",
|
|
69
|
+
"The system SHALL preserve the complete history of all modifications.",
|
|
70
|
+
"Event records SHALL include timestamp, user ID, and action type.",
|
|
71
|
+
"The system SHALL NOT allow modification or deletion of stored events."
|
|
72
|
+
]
|
|
73
|
+
}"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_user_prompt(
|
|
77
|
+
req_id: str,
|
|
78
|
+
title: str,
|
|
79
|
+
level: str,
|
|
80
|
+
status: str,
|
|
81
|
+
implements: list,
|
|
82
|
+
body: str,
|
|
83
|
+
rationale: str = ""
|
|
84
|
+
) -> str:
|
|
85
|
+
"""
|
|
86
|
+
Build the user prompt for reformatting a requirement.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
req_id: Requirement ID (e.g., 'REQ-p00046')
|
|
90
|
+
title: Requirement title
|
|
91
|
+
level: Requirement level (PRD, Dev, Ops)
|
|
92
|
+
status: Requirement status (Draft, Active, etc.)
|
|
93
|
+
implements: List of parent requirement IDs
|
|
94
|
+
body: Current requirement body text
|
|
95
|
+
rationale: Current rationale text (if any)
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
User prompt string
|
|
99
|
+
"""
|
|
100
|
+
implements_str = ", ".join(implements) if implements else "-"
|
|
101
|
+
|
|
102
|
+
prompt = f"""Reformat the following requirement from old format to new assertion-based format.
|
|
103
|
+
|
|
104
|
+
REQUIREMENT ID: {req_id}
|
|
105
|
+
TITLE: {title}
|
|
106
|
+
LEVEL: {level}
|
|
107
|
+
STATUS: {status}
|
|
108
|
+
IMPLEMENTS: {implements_str}
|
|
109
|
+
|
|
110
|
+
CURRENT BODY:
|
|
111
|
+
{body}
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
if rationale and rationale.strip():
|
|
115
|
+
prompt += f"""
|
|
116
|
+
CURRENT RATIONALE:
|
|
117
|
+
{rationale}
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
prompt += """
|
|
121
|
+
Extract all obligations and convert them to labeled assertions. Return ONLY the JSON object with "rationale" and "assertions" fields."""
|
|
122
|
+
|
|
123
|
+
return prompt
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# Implements: REQ-int-d00008 (Reformat Command)
|
|
2
|
+
"""
|
|
3
|
+
AI-assisted requirement transformation using Claude Code CLI.
|
|
4
|
+
|
|
5
|
+
Invokes claude CLI to reformat requirements and assembles the output
|
|
6
|
+
into the new format.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from typing import List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from elspais.reformat.hierarchy import RequirementNode
|
|
15
|
+
from elspais.reformat.prompts import REFORMAT_SYSTEM_PROMPT, JSON_SCHEMA_STR, build_user_prompt
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def reformat_requirement(
|
|
19
|
+
node: RequirementNode,
|
|
20
|
+
model: str = "sonnet",
|
|
21
|
+
verbose: bool = False
|
|
22
|
+
) -> Tuple[Optional[dict], bool, str]:
|
|
23
|
+
"""
|
|
24
|
+
Use Claude CLI to reformat a requirement.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
node: RequirementNode with current content
|
|
28
|
+
model: Claude model to use (sonnet, opus, haiku)
|
|
29
|
+
verbose: Print debug information
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Tuple of (parsed_result, success, error_message)
|
|
33
|
+
parsed_result is a dict with 'rationale' and 'assertions' keys
|
|
34
|
+
"""
|
|
35
|
+
# Build the prompt
|
|
36
|
+
user_prompt = build_user_prompt(
|
|
37
|
+
req_id=node.req_id,
|
|
38
|
+
title=node.title,
|
|
39
|
+
level=node.level,
|
|
40
|
+
status=node.status,
|
|
41
|
+
implements=node.implements,
|
|
42
|
+
body=node.body,
|
|
43
|
+
rationale=node.rationale
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Build the claude command
|
|
47
|
+
cmd = [
|
|
48
|
+
'claude',
|
|
49
|
+
'-p', # Print mode (non-interactive)
|
|
50
|
+
'--output-format', 'json',
|
|
51
|
+
'--json-schema', JSON_SCHEMA_STR,
|
|
52
|
+
'--system-prompt', REFORMAT_SYSTEM_PROMPT,
|
|
53
|
+
'--tools', '', # Disable all tools
|
|
54
|
+
'--model', model,
|
|
55
|
+
user_prompt
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
if verbose:
|
|
59
|
+
print(f" Running: claude -p --output-format json ...", file=sys.stderr)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
result = subprocess.run(
|
|
63
|
+
cmd,
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
timeout=120 # 2 minute timeout
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if result.returncode != 0:
|
|
70
|
+
error_msg = result.stderr.strip() if result.stderr else "Unknown error"
|
|
71
|
+
return None, False, f"Claude CLI failed: {error_msg}"
|
|
72
|
+
|
|
73
|
+
# Parse the JSON response
|
|
74
|
+
parsed = parse_claude_response(result.stdout)
|
|
75
|
+
if parsed is None:
|
|
76
|
+
return None, False, "Failed to parse Claude response"
|
|
77
|
+
|
|
78
|
+
return parsed, True, ""
|
|
79
|
+
|
|
80
|
+
except subprocess.TimeoutExpired:
|
|
81
|
+
return None, False, "Claude CLI timed out"
|
|
82
|
+
except FileNotFoundError:
|
|
83
|
+
return None, False, "Claude CLI not found - ensure 'claude' is in PATH"
|
|
84
|
+
except Exception as e:
|
|
85
|
+
return None, False, f"Unexpected error: {e}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_claude_response(response: str) -> Optional[dict]:
|
|
89
|
+
"""
|
|
90
|
+
Parse the JSON response from Claude CLI.
|
|
91
|
+
|
|
92
|
+
The response format with --output-format json is a JSON object containing:
|
|
93
|
+
- type: "result"
|
|
94
|
+
- subtype: "success" or "error"
|
|
95
|
+
- structured_output: the actual JSON matching our schema
|
|
96
|
+
- result: text result (may be empty with structured output)
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
response: Raw stdout from claude CLI
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Parsed dict with 'rationale' and 'assertions', or None on failure
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
data = json.loads(response)
|
|
106
|
+
|
|
107
|
+
# Check for error
|
|
108
|
+
if data.get('is_error') or data.get('subtype') == 'error':
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
# The structured output is in 'structured_output' field
|
|
112
|
+
if 'structured_output' in data:
|
|
113
|
+
structured = data['structured_output']
|
|
114
|
+
if isinstance(structured, dict) and 'rationale' in structured and 'assertions' in structured:
|
|
115
|
+
return structured
|
|
116
|
+
|
|
117
|
+
# Fallback: Direct result (if schema not used)
|
|
118
|
+
if 'rationale' in data and 'assertions' in data:
|
|
119
|
+
return data
|
|
120
|
+
|
|
121
|
+
# Fallback: Wrapped in result field
|
|
122
|
+
if 'result' in data:
|
|
123
|
+
result = data['result']
|
|
124
|
+
if isinstance(result, dict) and 'rationale' in result:
|
|
125
|
+
return result
|
|
126
|
+
# Result might be a JSON string
|
|
127
|
+
if isinstance(result, str) and result.strip():
|
|
128
|
+
try:
|
|
129
|
+
parsed = json.loads(result)
|
|
130
|
+
if 'rationale' in parsed:
|
|
131
|
+
return parsed
|
|
132
|
+
except json.JSONDecodeError:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
except json.JSONDecodeError:
|
|
138
|
+
# Try to extract JSON from the response
|
|
139
|
+
try:
|
|
140
|
+
json_start = response.find('{')
|
|
141
|
+
json_end = response.rfind('}') + 1
|
|
142
|
+
if json_start >= 0 and json_end > json_start:
|
|
143
|
+
parsed = json.loads(response[json_start:json_end])
|
|
144
|
+
if 'structured_output' in parsed:
|
|
145
|
+
return parsed['structured_output']
|
|
146
|
+
if 'rationale' in parsed and 'assertions' in parsed:
|
|
147
|
+
return parsed
|
|
148
|
+
except json.JSONDecodeError:
|
|
149
|
+
pass
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def assemble_new_format(
|
|
154
|
+
req_id: str,
|
|
155
|
+
title: str,
|
|
156
|
+
level: str,
|
|
157
|
+
status: str,
|
|
158
|
+
implements: List[str],
|
|
159
|
+
rationale: str,
|
|
160
|
+
assertions: List[str]
|
|
161
|
+
) -> str:
|
|
162
|
+
"""
|
|
163
|
+
Assemble the new format requirement markdown.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
req_id: Requirement ID (e.g., 'REQ-p00046')
|
|
167
|
+
title: Requirement title
|
|
168
|
+
level: Requirement level (PRD, Dev, Ops)
|
|
169
|
+
status: Requirement status
|
|
170
|
+
implements: List of parent requirement IDs
|
|
171
|
+
rationale: Rationale text (from AI)
|
|
172
|
+
assertions: List of assertion strings (from AI)
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Complete requirement markdown in new format
|
|
176
|
+
"""
|
|
177
|
+
# Format implements field
|
|
178
|
+
if implements:
|
|
179
|
+
implements_str = ", ".join(implements)
|
|
180
|
+
else:
|
|
181
|
+
implements_str = "-"
|
|
182
|
+
|
|
183
|
+
# Build header
|
|
184
|
+
lines = [
|
|
185
|
+
f"# {req_id}: {title}",
|
|
186
|
+
"",
|
|
187
|
+
f"**Level**: {level} | **Status**: {status} | **Implements**: {implements_str}",
|
|
188
|
+
"",
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
# Add rationale section
|
|
192
|
+
lines.append("## Rationale")
|
|
193
|
+
lines.append("")
|
|
194
|
+
lines.append(rationale.strip())
|
|
195
|
+
lines.append("")
|
|
196
|
+
|
|
197
|
+
# Add assertions section
|
|
198
|
+
lines.append("## Assertions")
|
|
199
|
+
lines.append("")
|
|
200
|
+
|
|
201
|
+
# Label assertions A, B, C, etc.
|
|
202
|
+
for i, assertion in enumerate(assertions):
|
|
203
|
+
label = chr(ord('A') + i)
|
|
204
|
+
# Clean up assertion text
|
|
205
|
+
assertion_text = assertion.strip()
|
|
206
|
+
# Remove any existing label if present
|
|
207
|
+
if len(assertion_text) > 2 and assertion_text[1] == '.' and assertion_text[0].isupper():
|
|
208
|
+
assertion_text = assertion_text[2:].strip()
|
|
209
|
+
lines.append(f"{label}. {assertion_text}")
|
|
210
|
+
|
|
211
|
+
lines.append("")
|
|
212
|
+
|
|
213
|
+
# Add footer with placeholder hash (will be updated by elspais)
|
|
214
|
+
# Use 8 zeros as placeholder - elspais expects valid hex format
|
|
215
|
+
lines.append(f"*End* *{title}* | **Hash**: 00000000")
|
|
216
|
+
lines.append("")
|
|
217
|
+
|
|
218
|
+
return "\n".join(lines)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def validate_reformatted_content(
|
|
222
|
+
original: RequirementNode,
|
|
223
|
+
rationale: str,
|
|
224
|
+
assertions: List[str]
|
|
225
|
+
) -> Tuple[bool, List[str]]:
|
|
226
|
+
"""
|
|
227
|
+
Validate that reformatted content is well-formed.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
original: Original requirement node
|
|
231
|
+
rationale: New rationale text
|
|
232
|
+
assertions: New assertions list
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Tuple of (is_valid, list of warnings)
|
|
236
|
+
"""
|
|
237
|
+
warnings = []
|
|
238
|
+
|
|
239
|
+
# Check assertions exist
|
|
240
|
+
if not assertions:
|
|
241
|
+
warnings.append("No assertions generated")
|
|
242
|
+
return False, warnings
|
|
243
|
+
|
|
244
|
+
# Check each assertion uses SHALL
|
|
245
|
+
for i, assertion in enumerate(assertions):
|
|
246
|
+
label = chr(ord('A') + i)
|
|
247
|
+
if 'SHALL' not in assertion.upper():
|
|
248
|
+
warnings.append(f"Assertion {label} missing SHALL keyword")
|
|
249
|
+
|
|
250
|
+
# Check rationale doesn't use SHALL
|
|
251
|
+
if 'SHALL' in rationale.upper():
|
|
252
|
+
warnings.append("Rationale contains SHALL (should be non-normative)")
|
|
253
|
+
|
|
254
|
+
# Check assertion count
|
|
255
|
+
if len(assertions) > 26:
|
|
256
|
+
warnings.append(f"Too many assertions ({len(assertions)} > 26)")
|
|
257
|
+
return False, warnings
|
|
258
|
+
|
|
259
|
+
# Warning if very few assertions from complex body
|
|
260
|
+
if len(assertions) < 2 and len(original.body) > 500:
|
|
261
|
+
warnings.append("Few assertions from large body - may have missed obligations")
|
|
262
|
+
|
|
263
|
+
is_valid = not any("missing SHALL" in w or "No assertions" in w for w in warnings)
|
|
264
|
+
return is_valid, warnings
|