mcp-reqcheck 0.1.0__tar.gz
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.
- mcp_reqcheck-0.1.0/PKG-INFO +144 -0
- mcp_reqcheck-0.1.0/README.md +133 -0
- mcp_reqcheck-0.1.0/pyproject.toml +27 -0
- mcp_reqcheck-0.1.0/pyproject.toml.orig +27 -0
- mcp_reqcheck-0.1.0/src/mcp_reqcheck/__init__.py +17 -0
- mcp_reqcheck-0.1.0/src/mcp_reqcheck/ambiguity_rules.yaml +237 -0
- mcp_reqcheck-0.1.0/src/mcp_reqcheck/server.py +561 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: mcp-reqcheck
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for reqCheck
|
|
5
|
+
Author: Hongjin Chen
|
|
6
|
+
Author-email: Hongjin Chen <188007812+hjchen114514@users.noreply.github.com>
|
|
7
|
+
Requires-Dist: mcp[cli]>=2.1.1
|
|
8
|
+
Requires-Dist: pyyaml>=6.0
|
|
9
|
+
Requires-Python: >=3.13
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# ReqCheck MCP server
|
|
13
|
+
|
|
14
|
+
Finds ambiguous terms in a requirements document, checked against
|
|
15
|
+
**ISO/IEC/IEEE 29148:2018**, and hands the client the question templates needed
|
|
16
|
+
to ask the author what they actually meant.
|
|
17
|
+
|
|
18
|
+
Detection is deterministic — regex over a versioned rule catalog, no model
|
|
19
|
+
involved — so the same text always produces the same findings, and every
|
|
20
|
+
finding cites the clause it came from.
|
|
21
|
+
|
|
22
|
+
Part of [ReqCheck](https://github.com/hjchen114514/ReqCheck), which also ships a
|
|
23
|
+
web app for the same checks.
|
|
24
|
+
|
|
25
|
+
## What it exposes
|
|
26
|
+
|
|
27
|
+
| Kind | Name | Purpose |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| Tool | `ambiguity_analysis(text)` | Every flagged term with its statement, severity, explanation, ISO citation, and question templates |
|
|
30
|
+
| Resource | `reqcheck://knowledge-base` | The full rule catalog as authored, with citations |
|
|
31
|
+
| Prompt | `check_requirements(text)` | Analyses a document and reports findings in a fixed format |
|
|
32
|
+
|
|
33
|
+
## Install from PyPI
|
|
34
|
+
|
|
35
|
+
Once published, nothing needs cloning.
|
|
36
|
+
|
|
37
|
+
**Claude Code:**
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
claude mcp add reqcheck -- uvx mcp-reqcheck
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**Claude Desktop** — add to
|
|
44
|
+
`~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"mcpServers": {
|
|
49
|
+
"ReqCheck": { "command": "uvx", "args": ["mcp-reqcheck"] }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Then restart Desktop.
|
|
55
|
+
|
|
56
|
+
## Install from source
|
|
57
|
+
|
|
58
|
+
**1. Install uv** (skip if you already have it):
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**2. Get the code:**
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
git clone https://github.com/hjchen114514/ReqCheck.git
|
|
68
|
+
cd ReqCheck/apps/mcp_reqCheck
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**3a. Claude Desktop:**
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
uv run mcp install src/mcp_reqcheck/server.py --with PyYAML
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Restart Desktop. `ReqCheck` should show green under Settings → Developer.
|
|
78
|
+
|
|
79
|
+
**3b. Claude Code** — needs the absolute path. Print it from this folder:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pwd
|
|
83
|
+
# /Users/you/ReqCheck/apps/mcp_reqCheck
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
claude mcp add reqcheck -- uv run --with "mcp[cli]" --with PyYAML mcp run <paste-path>/src/mcp_reqcheck/server.py
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Check it with `claude mcp list`.
|
|
91
|
+
|
|
92
|
+
> **`--with PyYAML` is required when running from source.** The client launches
|
|
93
|
+
> the server from its own working directory, so uv never reads this project's
|
|
94
|
+
> `pyproject.toml` and builds an environment from the `--with` flags alone.
|
|
95
|
+
> Without it the catalog loader fails on `import yaml` and the client reports
|
|
96
|
+
> "Server disconnected". Installed users don't need it — `uvx` resolves
|
|
97
|
+
> declared dependencies.
|
|
98
|
+
|
|
99
|
+
## Use it
|
|
100
|
+
|
|
101
|
+
Ask your client something like:
|
|
102
|
+
|
|
103
|
+
> Check these requirements for ambiguity:
|
|
104
|
+
> The system shall be user-friendly and load significantly faster than the old site.
|
|
105
|
+
|
|
106
|
+
It calls `ambiguity_analysis`, gets back the flagged terms with their reasons
|
|
107
|
+
and citations, and asks you follow-up questions rather than rewriting your
|
|
108
|
+
requirement for you.
|
|
109
|
+
|
|
110
|
+
## Develop
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
uv run mcp dev src/mcp_reqcheck/server.py
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Opens the MCP Inspector, where you can call the tool, read the resource, and
|
|
117
|
+
render the prompt without a client.
|
|
118
|
+
|
|
119
|
+
### Layout
|
|
120
|
+
|
|
121
|
+
`server.py` holds the records, the rule catalog, the analyser, and the MCP
|
|
122
|
+
surface in one module, with `ambiguity_rules.yaml` beside it. That is
|
|
123
|
+
deliberate: a client may load the file directly (`mcp run server.py`, with no
|
|
124
|
+
package around it) or run the installed console script (`uvx mcp-reqcheck`,
|
|
125
|
+
with one), and imports between sibling files resolve differently in those two
|
|
126
|
+
cases. Importing nothing of its own makes both behave identically.
|
|
127
|
+
|
|
128
|
+
Two details in that file must stay as they are, or the file-based launch
|
|
129
|
+
breaks:
|
|
130
|
+
|
|
131
|
+
- **no `from __future__ import annotations`** — it turns annotations into
|
|
132
|
+
strings, which `dataclasses` resolves through `sys.modules[cls.__module__]`,
|
|
133
|
+
and that is `None` for a directly-loaded file
|
|
134
|
+
- **`Pattern[str]`, not `re.Pattern[str]`** — same reason
|
|
135
|
+
|
|
136
|
+
The catalog and analyser are copied from `apps/api`. Rule changes made there
|
|
137
|
+
need copying here too.
|
|
138
|
+
|
|
139
|
+
## Publishing
|
|
140
|
+
```bash
|
|
141
|
+
uv build
|
|
142
|
+
uv publish --index testpypi # sandbox first
|
|
143
|
+
uv publish # then the real index
|
|
144
|
+
```
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# ReqCheck MCP server
|
|
2
|
+
|
|
3
|
+
Finds ambiguous terms in a requirements document, checked against
|
|
4
|
+
**ISO/IEC/IEEE 29148:2018**, and hands the client the question templates needed
|
|
5
|
+
to ask the author what they actually meant.
|
|
6
|
+
|
|
7
|
+
Detection is deterministic — regex over a versioned rule catalog, no model
|
|
8
|
+
involved — so the same text always produces the same findings, and every
|
|
9
|
+
finding cites the clause it came from.
|
|
10
|
+
|
|
11
|
+
Part of [ReqCheck](https://github.com/hjchen114514/ReqCheck), which also ships a
|
|
12
|
+
web app for the same checks.
|
|
13
|
+
|
|
14
|
+
## What it exposes
|
|
15
|
+
|
|
16
|
+
| Kind | Name | Purpose |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| Tool | `ambiguity_analysis(text)` | Every flagged term with its statement, severity, explanation, ISO citation, and question templates |
|
|
19
|
+
| Resource | `reqcheck://knowledge-base` | The full rule catalog as authored, with citations |
|
|
20
|
+
| Prompt | `check_requirements(text)` | Analyses a document and reports findings in a fixed format |
|
|
21
|
+
|
|
22
|
+
## Install from PyPI
|
|
23
|
+
|
|
24
|
+
Once published, nothing needs cloning.
|
|
25
|
+
|
|
26
|
+
**Claude Code:**
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
claude mcp add reqcheck -- uvx mcp-reqcheck
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Claude Desktop** — add to
|
|
33
|
+
`~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"mcpServers": {
|
|
38
|
+
"ReqCheck": { "command": "uvx", "args": ["mcp-reqcheck"] }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Then restart Desktop.
|
|
44
|
+
|
|
45
|
+
## Install from source
|
|
46
|
+
|
|
47
|
+
**1. Install uv** (skip if you already have it):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**2. Get the code:**
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
git clone https://github.com/hjchen114514/ReqCheck.git
|
|
57
|
+
cd ReqCheck/apps/mcp_reqCheck
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**3a. Claude Desktop:**
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
uv run mcp install src/mcp_reqcheck/server.py --with PyYAML
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Restart Desktop. `ReqCheck` should show green under Settings → Developer.
|
|
67
|
+
|
|
68
|
+
**3b. Claude Code** — needs the absolute path. Print it from this folder:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pwd
|
|
72
|
+
# /Users/you/ReqCheck/apps/mcp_reqCheck
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
claude mcp add reqcheck -- uv run --with "mcp[cli]" --with PyYAML mcp run <paste-path>/src/mcp_reqcheck/server.py
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Check it with `claude mcp list`.
|
|
80
|
+
|
|
81
|
+
> **`--with PyYAML` is required when running from source.** The client launches
|
|
82
|
+
> the server from its own working directory, so uv never reads this project's
|
|
83
|
+
> `pyproject.toml` and builds an environment from the `--with` flags alone.
|
|
84
|
+
> Without it the catalog loader fails on `import yaml` and the client reports
|
|
85
|
+
> "Server disconnected". Installed users don't need it — `uvx` resolves
|
|
86
|
+
> declared dependencies.
|
|
87
|
+
|
|
88
|
+
## Use it
|
|
89
|
+
|
|
90
|
+
Ask your client something like:
|
|
91
|
+
|
|
92
|
+
> Check these requirements for ambiguity:
|
|
93
|
+
> The system shall be user-friendly and load significantly faster than the old site.
|
|
94
|
+
|
|
95
|
+
It calls `ambiguity_analysis`, gets back the flagged terms with their reasons
|
|
96
|
+
and citations, and asks you follow-up questions rather than rewriting your
|
|
97
|
+
requirement for you.
|
|
98
|
+
|
|
99
|
+
## Develop
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
uv run mcp dev src/mcp_reqcheck/server.py
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Opens the MCP Inspector, where you can call the tool, read the resource, and
|
|
106
|
+
render the prompt without a client.
|
|
107
|
+
|
|
108
|
+
### Layout
|
|
109
|
+
|
|
110
|
+
`server.py` holds the records, the rule catalog, the analyser, and the MCP
|
|
111
|
+
surface in one module, with `ambiguity_rules.yaml` beside it. That is
|
|
112
|
+
deliberate: a client may load the file directly (`mcp run server.py`, with no
|
|
113
|
+
package around it) or run the installed console script (`uvx mcp-reqcheck`,
|
|
114
|
+
with one), and imports between sibling files resolve differently in those two
|
|
115
|
+
cases. Importing nothing of its own makes both behave identically.
|
|
116
|
+
|
|
117
|
+
Two details in that file must stay as they are, or the file-based launch
|
|
118
|
+
breaks:
|
|
119
|
+
|
|
120
|
+
- **no `from __future__ import annotations`** — it turns annotations into
|
|
121
|
+
strings, which `dataclasses` resolves through `sys.modules[cls.__module__]`,
|
|
122
|
+
and that is `None` for a directly-loaded file
|
|
123
|
+
- **`Pattern[str]`, not `re.Pattern[str]`** — same reason
|
|
124
|
+
|
|
125
|
+
The catalog and analyser are copied from `apps/api`. Rule changes made there
|
|
126
|
+
need copying here too.
|
|
127
|
+
|
|
128
|
+
## Publishing
|
|
129
|
+
```bash
|
|
130
|
+
uv build
|
|
131
|
+
uv publish --index testpypi # sandbox first
|
|
132
|
+
uv publish # then the real index
|
|
133
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mcp-reqcheck"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MCP server for reqCheck"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"mcp[cli]>=2.1.1",
|
|
9
|
+
"PyYAML>=6.0",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[[project.authors]]
|
|
13
|
+
name = "Hongjin Chen"
|
|
14
|
+
email = "188007812+hjchen114514@users.noreply.github.com"
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
mcp-reqcheck = "mcp_reqcheck:main"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["uv_build>=0.12.6,<0.13.0"]
|
|
21
|
+
build-backend = "uv_build"
|
|
22
|
+
|
|
23
|
+
[[tool.uv.index]]
|
|
24
|
+
name = "testpypi"
|
|
25
|
+
url = "https://test.pypi.org/simple/"
|
|
26
|
+
publish-url = "https://test.pypi.org/legacy/"
|
|
27
|
+
explicit = true
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mcp-reqcheck"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MCP server for reqCheck"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Hongjin Chen", email = "188007812+hjchen114514@users.noreply.github.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"mcp[cli]>=2.1.1",
|
|
12
|
+
# The rule catalog ships as ambiguity_rules.yaml beside server.py.
|
|
13
|
+
"PyYAML>=6.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
mcp-reqcheck = "mcp_reqcheck:main"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["uv_build>=0.12.6,<0.13.0"]
|
|
21
|
+
build-backend = "uv_build"
|
|
22
|
+
|
|
23
|
+
[[tool.uv.index]]
|
|
24
|
+
name= "testpypi"
|
|
25
|
+
url="https://test.pypi.org/simple/"
|
|
26
|
+
publish-url="https://test.pypi.org/legacy/"
|
|
27
|
+
explicit=true
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""ReqCheck MCP server package."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def main() -> None:
|
|
5
|
+
"""Console-script entry point. `uvx mcp-reqcheck` lands here.
|
|
6
|
+
|
|
7
|
+
The import is deferred so that `import mcp_reqcheck` on its own does not
|
|
8
|
+
build the server. server.py imports back through this package, and a
|
|
9
|
+
module-level import here would load and initialise it twice, compiling the
|
|
10
|
+
rule catalog once per copy.
|
|
11
|
+
"""
|
|
12
|
+
from mcp_reqcheck.server import mcp
|
|
13
|
+
|
|
14
|
+
mcp.run()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = ["main"]
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# ReqCheck ambiguity knowledge base
|
|
2
|
+
#
|
|
3
|
+
# Every rule below is derived from ISO/IEC/IEEE 29148:2018 (see sources.md).
|
|
4
|
+
# Rules are team-authored paraphrases with citations, not copied standard text.
|
|
5
|
+
#
|
|
6
|
+
# Detection is purely deterministic: C3 AmbiguityAnalyzer matches these
|
|
7
|
+
# phrases with word boundaries and computes exact character offsets.
|
|
8
|
+
# No AI is involved in detection.
|
|
9
|
+
#
|
|
10
|
+
# Severity guide:
|
|
11
|
+
# high - the requirement almost certainly cannot be verified as written
|
|
12
|
+
# medium - the requirement is likely to be read two different ways
|
|
13
|
+
# low - worth a look, but frequently acceptable in context
|
|
14
|
+
|
|
15
|
+
catalog_version: "1.0.0"
|
|
16
|
+
|
|
17
|
+
sources:
|
|
18
|
+
ISO29148:
|
|
19
|
+
title: "ISO/IEC/IEEE 29148:2018 - Systems and software engineering - Life cycle processes - Requirements engineering"
|
|
20
|
+
short_name: "ISO/IEC/IEEE 29148:2018"
|
|
21
|
+
clause: "p.22 (well-formed requirements, unbounded and ambiguous terms); p.12 (Unambiguous)"
|
|
22
|
+
url: "https://www.iso.org/standard/72089.html"
|
|
23
|
+
|
|
24
|
+
rules:
|
|
25
|
+
# --- p.22: "superlatives (such as 'best', 'most')" -----------------------
|
|
26
|
+
- id: AMB-SUPER-001
|
|
27
|
+
phrases:
|
|
28
|
+
["best", "worst", "most", "least", "optimal", "optimum",
|
|
29
|
+
"state of the art", "state-of-the-art", "cutting edge", "world class"]
|
|
30
|
+
category: superlative
|
|
31
|
+
category_label: "Superlative"
|
|
32
|
+
severity: high
|
|
33
|
+
explanation: >-
|
|
34
|
+
Superlatives assert an extreme without naming a baseline or a way to
|
|
35
|
+
measure it, so no test can confirm the requirement was met.
|
|
36
|
+
question_templates:
|
|
37
|
+
- "Compared with what baseline is this the best or the most? Name the alternative."
|
|
38
|
+
- "What measurable value would demonstrate this extreme was achieved?"
|
|
39
|
+
source: ISO29148
|
|
40
|
+
|
|
41
|
+
# --- p.22: "subjective language (such as 'user friendly', 'easy to use',
|
|
42
|
+
# 'cost effective')" ----------------------------------------
|
|
43
|
+
- id: AMB-SUBJ-001
|
|
44
|
+
phrases:
|
|
45
|
+
["user friendly", "user-friendly", "easy to use", "easy", "simple to use",
|
|
46
|
+
"cost effective", "cost-effective", "efficient", "efficiently", "robust",
|
|
47
|
+
"flexible", "seamless", "seamlessly", "intuitive", "convenient",
|
|
48
|
+
"user experience", "modern", "clean", "nice", "good", "powerful"]
|
|
49
|
+
category: subjective_language
|
|
50
|
+
category_label: "Subjective language"
|
|
51
|
+
severity: high
|
|
52
|
+
explanation: >-
|
|
53
|
+
Subjective language expresses an opinion rather than a property of the
|
|
54
|
+
system, so two readers can reasonably disagree about whether the
|
|
55
|
+
requirement has been satisfied.
|
|
56
|
+
question_templates:
|
|
57
|
+
- "What specific, observable behaviour would prove this requirement is satisfied?"
|
|
58
|
+
- "Who judges this, and what measured value counts as passing?"
|
|
59
|
+
source: ISO29148
|
|
60
|
+
|
|
61
|
+
# --- p.22: "vague pronouns (such as 'it', 'this', 'that')" ---------------
|
|
62
|
+
- id: AMB-PRON-001
|
|
63
|
+
phrases: ["it", "this", "that", "they", "them", "these", "those", "such"]
|
|
64
|
+
category: vague_pronoun
|
|
65
|
+
category_label: "Vague pronoun"
|
|
66
|
+
severity: low
|
|
67
|
+
explanation: >-
|
|
68
|
+
A pronoun with no explicit antecedent forces the reader to guess which
|
|
69
|
+
noun it refers to, which permits more than one interpretation.
|
|
70
|
+
question_templates:
|
|
71
|
+
- "Which exact noun does this pronoun refer to? Replace the pronoun with that noun."
|
|
72
|
+
source: ISO29148
|
|
73
|
+
|
|
74
|
+
# --- p.22: "ambiguous terms such as adverbs and adjectives (such as
|
|
75
|
+
# 'almost always', 'significant', 'minimal')" -----------------
|
|
76
|
+
- id: AMB-ADV-001
|
|
77
|
+
phrases:
|
|
78
|
+
["almost always", "almost never", "significant", "significantly",
|
|
79
|
+
"minimal", "minimally", "quickly", "rapidly", "fast", "slow", "timely",
|
|
80
|
+
"approximately", "about", "nearly", "roughly", "several", "some",
|
|
81
|
+
"many", "few", "various", "sufficient", "sufficiently", "adequate",
|
|
82
|
+
"adequately", "reasonable", "reasonably", "appropriate",
|
|
83
|
+
"appropriately", "acceptable", "normal", "typical", "typically",
|
|
84
|
+
"regularly", "periodically", "frequently", "occasionally", "large",
|
|
85
|
+
"small", "high", "low", "quality", "as soon as possible",
|
|
86
|
+
"in real time", "real-time", "instantly", "immediately"]
|
|
87
|
+
category: ambiguous_modifier
|
|
88
|
+
category_label: "Ambiguous adverb or adjective"
|
|
89
|
+
severity: medium
|
|
90
|
+
explanation: >-
|
|
91
|
+
Ambiguous adverbs and adjectives describe a degree without a number or a
|
|
92
|
+
unit, so the threshold that separates pass from fail is undefined.
|
|
93
|
+
question_templates:
|
|
94
|
+
- "What exact number, unit, and tolerance replaces this word?"
|
|
95
|
+
- "At what measured value does this requirement pass, and at what value does it fail?"
|
|
96
|
+
source: ISO29148
|
|
97
|
+
|
|
98
|
+
# --- p.22: "ambiguous logical statements (such as 'or', 'and/or')"
|
|
99
|
+
# NOTE 1: consider multiple requirements when encountering these.
|
|
100
|
+
# Split into two rules: bare 'and' is far more common in ordinary prose,
|
|
101
|
+
# so it carries low severity to keep the signal readable.
|
|
102
|
+
- id: AMB-LOGIC-001
|
|
103
|
+
phrases: ["and/or", "or"]
|
|
104
|
+
category: ambiguous_logic
|
|
105
|
+
category_label: "Ambiguous logical statement"
|
|
106
|
+
severity: medium
|
|
107
|
+
explanation: >-
|
|
108
|
+
Joining conditions with 'or' or 'and/or' leaves it unclear whether one,
|
|
109
|
+
several, or all of them are required. The standard advises splitting
|
|
110
|
+
such a statement into multiple requirements.
|
|
111
|
+
question_templates:
|
|
112
|
+
- "Is this one requirement or several? Split it into one statement per testable condition."
|
|
113
|
+
- "Must every joined condition hold, or is any single one sufficient?"
|
|
114
|
+
source: ISO29148
|
|
115
|
+
|
|
116
|
+
- id: AMB-LOGIC-002
|
|
117
|
+
phrases: ["and"]
|
|
118
|
+
category: ambiguous_logic
|
|
119
|
+
category_label: "Ambiguous logical statement"
|
|
120
|
+
severity: low
|
|
121
|
+
explanation: >-
|
|
122
|
+
A requirement joined by 'and' often hides two separate requirements,
|
|
123
|
+
each of which needs its own acceptance test.
|
|
124
|
+
question_templates:
|
|
125
|
+
- "Does this sentence contain more than one testable requirement? If so, split it."
|
|
126
|
+
source: ISO29148
|
|
127
|
+
|
|
128
|
+
# --- p.22: "open-ended, non-verifiable terms (such as 'provide support',
|
|
129
|
+
# 'but not limited to', 'as a minimum')" ---------------------
|
|
130
|
+
- id: AMB-OPEN-001
|
|
131
|
+
phrases:
|
|
132
|
+
["provide support", "support for", "including but not limited to",
|
|
133
|
+
"but not limited to", "as a minimum", "at a minimum", "and so on",
|
|
134
|
+
"such as", "among others", "to be determined", "tbd", "etc.", "etc",
|
|
135
|
+
"and more", "e.g.", "for example"]
|
|
136
|
+
category: open_ended
|
|
137
|
+
category_label: "Open-ended, non-verifiable term"
|
|
138
|
+
severity: high
|
|
139
|
+
explanation: >-
|
|
140
|
+
Open-ended terms leave the boundary of the requirement undefined, so it
|
|
141
|
+
can never be shown to be completely implemented.
|
|
142
|
+
question_templates:
|
|
143
|
+
- "List every item this covers. What is explicitly excluded?"
|
|
144
|
+
- "What is the complete, closed set of cases this requirement applies to?"
|
|
145
|
+
source: ISO29148
|
|
146
|
+
|
|
147
|
+
# --- p.22: "comparative phrases (such as 'better than', 'higher quality')"
|
|
148
|
+
- id: AMB-COMP-001
|
|
149
|
+
phrases:
|
|
150
|
+
["better than", "worse than", "higher quality", "lower cost",
|
|
151
|
+
"faster than", "slower than", "more than before", "improved",
|
|
152
|
+
"enhanced", "superior", "at least as good as", "comparable to",
|
|
153
|
+
"similar to", "as good as"]
|
|
154
|
+
category: comparative
|
|
155
|
+
category_label: "Comparative phrase"
|
|
156
|
+
severity: high
|
|
157
|
+
explanation: >-
|
|
158
|
+
A comparative phrase needs both a named reference point and a
|
|
159
|
+
measurement method. Without both, the comparison cannot be verified.
|
|
160
|
+
question_templates:
|
|
161
|
+
- "Better than what, measured how, and by how much?"
|
|
162
|
+
- "What is the reference system or previous value being compared against?"
|
|
163
|
+
source: ISO29148
|
|
164
|
+
|
|
165
|
+
# --- p.22: "loopholes (such as 'if possible', 'as appropriate',
|
|
166
|
+
# 'as applicable')" ---------------------------------------
|
|
167
|
+
- id: AMB-LOOP-001
|
|
168
|
+
phrases:
|
|
169
|
+
["if possible", "if needed", "if necessary", "if required",
|
|
170
|
+
"as appropriate", "as applicable", "as required", "as needed",
|
|
171
|
+
"where feasible", "where possible", "to the extent practical",
|
|
172
|
+
"to the extent possible", "optionally", "preferably", "ideally"]
|
|
173
|
+
category: loophole
|
|
174
|
+
category_label: "Loophole"
|
|
175
|
+
severity: high
|
|
176
|
+
explanation: >-
|
|
177
|
+
A loophole makes the requirement conditional on unstated judgement,
|
|
178
|
+
which lets an implementation skip it entirely and still claim conformance.
|
|
179
|
+
question_templates:
|
|
180
|
+
- "Under exactly which stated condition does this apply? Write that condition as the trigger."
|
|
181
|
+
- "If the condition is not met, what must the system do instead?"
|
|
182
|
+
source: ISO29148
|
|
183
|
+
|
|
184
|
+
# --- p.22: "terms that imply totality (such as 'all', 'always', 'never',
|
|
185
|
+
# 'every')". NOTE 2: very difficult to verify. -----------------
|
|
186
|
+
- id: AMB-TOTAL-001
|
|
187
|
+
phrases:
|
|
188
|
+
["all", "always", "never", "every", "none", "any", "everything",
|
|
189
|
+
"nothing", "anyone", "everyone", "each and every", "at all times",
|
|
190
|
+
"100% of the time", "under all circumstances", "completely", "fully",
|
|
191
|
+
"totally", "entirely"]
|
|
192
|
+
category: totality
|
|
193
|
+
category_label: "Term implying totality"
|
|
194
|
+
severity: medium
|
|
195
|
+
explanation: >-
|
|
196
|
+
Terms implying totality are very difficult to verify, because exhaustive
|
|
197
|
+
testing of every possible case is usually impossible.
|
|
198
|
+
question_templates:
|
|
199
|
+
- "Which finite, enumerable set does this actually cover, and how will it be tested?"
|
|
200
|
+
- "Can this be restated as a measurable rate or a bounded set instead of an absolute?"
|
|
201
|
+
source: ISO29148
|
|
202
|
+
|
|
203
|
+
# --- p.12 "Unambiguous", and the shall/should distinction the standard
|
|
204
|
+
# relies on throughout. ---------------------------------------------
|
|
205
|
+
- id: AMB-MODAL-001
|
|
206
|
+
phrases:
|
|
207
|
+
["should", "may", "might", "could", "can", "would", "will be able to",
|
|
208
|
+
"is expected to", "are expected to", "ought to", "is supposed to"]
|
|
209
|
+
category: weak_modal
|
|
210
|
+
category_label: "Weak modal verb"
|
|
211
|
+
severity: medium
|
|
212
|
+
explanation: >-
|
|
213
|
+
Only 'shall' expresses a binding requirement. A weak modal leaves it
|
|
214
|
+
unclear whether the behaviour is mandatory, so it may never be
|
|
215
|
+
implemented or tested.
|
|
216
|
+
question_templates:
|
|
217
|
+
- "Is this mandatory? If so, replace the modal verb with 'shall'."
|
|
218
|
+
- "If it is genuinely optional, move it out of the requirements into a note."
|
|
219
|
+
source: ISO29148
|
|
220
|
+
|
|
221
|
+
# --- p.22: "incomplete references (not specifying the reference with its
|
|
222
|
+
# date and version number)" ---------------------------------
|
|
223
|
+
- id: AMB-REF-001
|
|
224
|
+
phrases:
|
|
225
|
+
["the standard", "the specification", "the document", "the manual",
|
|
226
|
+
"applicable standards", "relevant standards", "industry standard",
|
|
227
|
+
"the guidelines", "per the spec", "see documentation"]
|
|
228
|
+
category: incomplete_reference
|
|
229
|
+
category_label: "Incomplete reference"
|
|
230
|
+
severity: medium
|
|
231
|
+
explanation: >-
|
|
232
|
+
A reference without its title, date, and version number cannot be
|
|
233
|
+
resolved to one exact document, so the scope of verification is unclear.
|
|
234
|
+
question_templates:
|
|
235
|
+
- "Which exact document, in which dated version, and which clause within it?"
|
|
236
|
+
- "Which specific parts of that reference apply to this requirement?"
|
|
237
|
+
source: ISO29148
|
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
"""ReqCheck MCP server.
|
|
2
|
+
|
|
3
|
+
Finds ambiguous terms in a requirements document, checked against
|
|
4
|
+
ISO/IEC/IEEE 29148:2018. Detection is regex over a versioned catalog -- no
|
|
5
|
+
model is involved, so identical text always produces identical findings, each
|
|
6
|
+
traceable to a numbered rule and a clause of the standard.
|
|
7
|
+
|
|
8
|
+
Everything lives in this one module on purpose. A client may launch this file
|
|
9
|
+
directly (`mcp run server.py`, where there is no package around it) or through
|
|
10
|
+
the installed console script (`uvx mcp-reqcheck`, where there is). Imports
|
|
11
|
+
between sibling files resolve differently in those two cases, so this file
|
|
12
|
+
imports nothing of its own and both launch styles behave identically.
|
|
13
|
+
|
|
14
|
+
The rule catalog stays in ambiguity_rules.yaml beside this file: it is data,
|
|
15
|
+
not code, and is read at import.
|
|
16
|
+
|
|
17
|
+
The catalog and analyser below are copied from apps/api in the ReqCheck web
|
|
18
|
+
application. Rule changes made there need copying here too.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# Deliberately no `from __future__ import annotations`. It turns every
|
|
22
|
+
# annotation into a string, and dataclasses then resolves those strings via
|
|
23
|
+
# sys.modules[cls.__module__] -- which is None when `mcp run <file>` loads
|
|
24
|
+
# this file directly without registering it as a module. Python 3.13
|
|
25
|
+
# evaluates these annotations natively, so nothing is lost.
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from re import Pattern
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
import yaml
|
|
34
|
+
from mcp.server import MCPServer
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --------------------------------------------------------------- records
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Statement:
|
|
42
|
+
"""One requirement sentence, with absolute offsets into the document."""
|
|
43
|
+
|
|
44
|
+
index: int
|
|
45
|
+
text: str
|
|
46
|
+
start_offset: int
|
|
47
|
+
end_offset: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Finding:
|
|
52
|
+
"""An exact-span ambiguity issue tied to one catalog rule."""
|
|
53
|
+
|
|
54
|
+
rule_id: str
|
|
55
|
+
category: str
|
|
56
|
+
severity: str
|
|
57
|
+
explanation: str
|
|
58
|
+
source_id: str
|
|
59
|
+
start_offset: int
|
|
60
|
+
end_offset: int
|
|
61
|
+
matched_text: str
|
|
62
|
+
statement_index: int
|
|
63
|
+
id: str | None = None
|
|
64
|
+
status: str = "open"
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def length(self) -> int:
|
|
68
|
+
return self.end_offset - self.start_offset
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --------------------------------------------------------------- catalog
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
VALID_SEVERITIES = {"low", "medium", "high"}
|
|
75
|
+
DEFAULT_CATALOG_PATH = Path(__file__).parent / "ambiguity_rules.yaml"
|
|
76
|
+
|
|
77
|
+
REQUIRED_FIELDS = (
|
|
78
|
+
"id",
|
|
79
|
+
"phrases",
|
|
80
|
+
"category",
|
|
81
|
+
"severity",
|
|
82
|
+
"explanation",
|
|
83
|
+
"question_templates",
|
|
84
|
+
"source",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CatalogError(Exception):
|
|
89
|
+
"""Raised when the YAML catalog is missing, malformed, or inconsistent."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
#dataclass here is used to create a simple class that holds data. The frozen=True parameter makes the class immutable, meaning that its attributes cannot be changed after it is created.
|
|
94
|
+
class AmbiguityRule:
|
|
95
|
+
id: str
|
|
96
|
+
phrases: tuple[str, ...]
|
|
97
|
+
category: str
|
|
98
|
+
category_label: str
|
|
99
|
+
severity: str
|
|
100
|
+
explanation: str
|
|
101
|
+
question_templates: tuple[str, ...]
|
|
102
|
+
source: str
|
|
103
|
+
# Annotated without the `re.` prefix: dataclasses resolves dotted
|
|
104
|
+
# annotations through sys.modules, which `mcp run <file>` does not
|
|
105
|
+
# populate for a directly-loaded file.
|
|
106
|
+
pattern: Pattern[str]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class AmbiguityRuleCatalog:
|
|
110
|
+
"""C2 - loads, validates, compiles, and serves the versioned rule catalog.
|
|
111
|
+
|
|
112
|
+
Behaviour this class owns:
|
|
113
|
+
- reads a versioned YAML catalog and refuses to load a broken one
|
|
114
|
+
- rejects duplicate rule ids, invalid severities, and unknown source ids
|
|
115
|
+
- compiles each rule's phrase list into one word-boundary regex, with
|
|
116
|
+
longer phrases taking precedence over shorter ones
|
|
117
|
+
- serves explanations, severities, sources, and question templates to
|
|
118
|
+
C3 (detection) and C5 (coaching)
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
catalog_version: str,
|
|
124
|
+
rules: list[AmbiguityRule],
|
|
125
|
+
sources: dict[str, dict[str, str]],
|
|
126
|
+
) -> None:
|
|
127
|
+
self._catalog_version = catalog_version
|
|
128
|
+
self._rules = rules
|
|
129
|
+
self._sources = sources
|
|
130
|
+
self._by_id = {rule.id: rule for rule in rules}
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------- load
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
# a classmethod is used here so that the catalog can be loaded without instantiating the class
|
|
136
|
+
def from_yaml(cls, path: Path | None = None) -> "AmbiguityRuleCatalog":
|
|
137
|
+
path = path or DEFAULT_CATALOG_PATH
|
|
138
|
+
if not path.exists():
|
|
139
|
+
raise CatalogError(f"Catalog file not found: {path}")
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
143
|
+
except yaml.YAMLError as exc:
|
|
144
|
+
raise CatalogError(f"Catalog is not valid YAML: {exc}") from exc
|
|
145
|
+
|
|
146
|
+
version = raw.get("catalog_version")
|
|
147
|
+
if not version:
|
|
148
|
+
raise CatalogError("catalog_version is required")
|
|
149
|
+
|
|
150
|
+
sources = raw.get("sources") or {}
|
|
151
|
+
if not sources:
|
|
152
|
+
raise CatalogError("at least one source is required")
|
|
153
|
+
|
|
154
|
+
entries = raw.get("rules") or []
|
|
155
|
+
if not entries:
|
|
156
|
+
raise CatalogError("catalog contains no rules")
|
|
157
|
+
|
|
158
|
+
rules: list[AmbiguityRule] = []
|
|
159
|
+
seen: set[str] = set()
|
|
160
|
+
for entry in entries:
|
|
161
|
+
rule = cls._build_rule(entry, sources)
|
|
162
|
+
#cls here is used to refer to the class itself.
|
|
163
|
+
if rule.id in seen:
|
|
164
|
+
raise CatalogError(f"duplicate rule id: {rule.id}")
|
|
165
|
+
seen.add(rule.id)
|
|
166
|
+
rules.append(rule)
|
|
167
|
+
|
|
168
|
+
return cls(str(version), rules, sources)
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _build_rule(
|
|
172
|
+
entry: dict, sources: dict[str, dict[str, str]]
|
|
173
|
+
) -> AmbiguityRule:
|
|
174
|
+
# isinstance here is used to check if the entry is a dictionary. If it is not, a CatalogError is raised.
|
|
175
|
+
if not isinstance(entry, dict):
|
|
176
|
+
raise CatalogError(f"rule entry is not a mapping: {entry!r}")
|
|
177
|
+
|
|
178
|
+
rule_id = entry.get("id", "<unknown>")
|
|
179
|
+
for field_name in REQUIRED_FIELDS:
|
|
180
|
+
if not entry.get(field_name):
|
|
181
|
+
raise CatalogError(f"rule {rule_id} is missing '{field_name}'")
|
|
182
|
+
|
|
183
|
+
severity = entry["severity"]
|
|
184
|
+
if severity not in VALID_SEVERITIES:
|
|
185
|
+
raise CatalogError(
|
|
186
|
+
f"rule {rule_id} has invalid severity '{severity}' "
|
|
187
|
+
f"(expected one of {sorted(VALID_SEVERITIES)})"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
source = entry["source"]
|
|
191
|
+
if source not in sources:
|
|
192
|
+
raise CatalogError(f"rule {rule_id} cites unknown source '{source}'")
|
|
193
|
+
|
|
194
|
+
# Longest phrase first, so 'and/or' beats 'or' and
|
|
195
|
+
# 'including but not limited to' beats 'but not limited to'.
|
|
196
|
+
phrases = sorted(
|
|
197
|
+
{p.strip().lower() for p in entry["phrases"] if str(p).strip()},
|
|
198
|
+
#key=len here is used to sort the phrases by length in descending order.
|
|
199
|
+
key=len,
|
|
200
|
+
reverse=True,
|
|
201
|
+
)
|
|
202
|
+
if not phrases:
|
|
203
|
+
raise CatalogError(f"rule {rule_id} has no usable phrases")
|
|
204
|
+
|
|
205
|
+
# (?<!\w) and (?!\w) act as word boundaries that still work when a
|
|
206
|
+
# phrase begins or ends with punctuation, such as 'etc.' or 'e.g.'.
|
|
207
|
+
# Plain \b would fail on those because '.' is not a word character.
|
|
208
|
+
|
|
209
|
+
#pattern here simply is to compile the phrases into a regular expression pattern that can be used to match the phrases in the text.
|
|
210
|
+
#This allows for matching phrases that may contain punctuation or special characters.
|
|
211
|
+
alternation = "|".join(re.escape(p) for p in phrases)
|
|
212
|
+
try:
|
|
213
|
+
pattern = re.compile(rf"(?<!\w)(?:{alternation})(?!\w)", re.IGNORECASE)
|
|
214
|
+
except re.error as exc:
|
|
215
|
+
raise CatalogError(f"rule {rule_id} produced an invalid pattern: {exc}") from exc
|
|
216
|
+
|
|
217
|
+
# Human-readable name shown in the UI. Falls back to a readable form
|
|
218
|
+
# of the slug so a rule is never displayed as raw snake_case.
|
|
219
|
+
category_label = entry.get("category_label") or entry["category"].replace("_", " ").capitalize()
|
|
220
|
+
|
|
221
|
+
return AmbiguityRule(
|
|
222
|
+
id=entry["id"],
|
|
223
|
+
phrases=tuple(phrases),
|
|
224
|
+
category=entry["category"],
|
|
225
|
+
category_label=category_label,
|
|
226
|
+
severity=severity,
|
|
227
|
+
explanation=" ".join(str(entry["explanation"]).split()),
|
|
228
|
+
question_templates=tuple(entry["question_templates"]),
|
|
229
|
+
source=source,
|
|
230
|
+
pattern=pattern,
|
|
231
|
+
#pattern here is used to compile the phrases into a regular expression pattern that can be used to match the phrases in the text.
|
|
232
|
+
# pattern replaces the phrases.
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# ---------------------------------------------------------------- query
|
|
236
|
+
#property here is used to define a read-only attribute that can be accessed like a regular attribute, but is computed on-the-fly when accessed. In this case, it returns the catalog version of the rule catalog.
|
|
237
|
+
@property
|
|
238
|
+
def catalog_version(self) -> str:
|
|
239
|
+
return self._catalog_version
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def rules(self) -> list[AmbiguityRule]:
|
|
243
|
+
return list(self._rules)
|
|
244
|
+
|
|
245
|
+
def get(self, rule_id: str) -> AmbiguityRule:
|
|
246
|
+
if rule_id not in self._by_id:
|
|
247
|
+
raise CatalogError(f"unknown rule id: {rule_id}")
|
|
248
|
+
return self._by_id[rule_id]
|
|
249
|
+
|
|
250
|
+
def question_templates_for(self, rule_id: str) -> list[str]:
|
|
251
|
+
return list(self.get(rule_id).question_templates)
|
|
252
|
+
|
|
253
|
+
def source_for(self, rule_id: str) -> dict[str, str]:
|
|
254
|
+
return dict(self._sources.get(self.get(rule_id).source, {}))
|
|
255
|
+
|
|
256
|
+
def display_for(self, rule_id: str) -> dict[str, str]:
|
|
257
|
+
"""Human-readable labels for the UI.
|
|
258
|
+
|
|
259
|
+
Internal identifiers such as AMB-SUBJ-001 and 'subjective_language'
|
|
260
|
+
are traceability data, not something a student should have to decode.
|
|
261
|
+
"""
|
|
262
|
+
try:
|
|
263
|
+
rule = self.get(rule_id)
|
|
264
|
+
except CatalogError:
|
|
265
|
+
# A finding stored under a rule that has since left the catalog.
|
|
266
|
+
return {
|
|
267
|
+
"category_label": "Unknown category",
|
|
268
|
+
"source_label": "Unknown source",
|
|
269
|
+
"source_clause": "",
|
|
270
|
+
}
|
|
271
|
+
source = self._sources.get(rule.source, {})
|
|
272
|
+
short = source.get("short_name") or source.get("title") or rule.source
|
|
273
|
+
clause = source.get("clause", "")
|
|
274
|
+
return {
|
|
275
|
+
"category_label": rule.category_label,
|
|
276
|
+
"source_label": f"Cited from {short}",
|
|
277
|
+
"source_clause": clause,
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
def describe(self) -> dict[str, object]:
|
|
281
|
+
"""Summary used by the UI to show which catalog produced a finding."""
|
|
282
|
+
return {
|
|
283
|
+
"catalog_version": self._catalog_version,
|
|
284
|
+
"rule_count": len(self._rules),
|
|
285
|
+
"phrase_count": sum(len(r.phrases) for r in self._rules),
|
|
286
|
+
"sources": self._sources,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# -------------------------------------------------------------- analyser
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
SEVERITY_RANK = {"high": 0, "medium": 1, "low": 2}
|
|
294
|
+
|
|
295
|
+
# A statement ends at sentence punctuation followed by whitespace, or at any
|
|
296
|
+
# run of newlines (so bulleted or numbered requirement lists split correctly).
|
|
297
|
+
_STATEMENT_BREAK = re.compile(r"(?<=[.!?])\s+|\n+")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class AmbiguityAnalyzer:
|
|
301
|
+
"""C3 - deterministic exact-span detection.
|
|
302
|
+
|
|
303
|
+
No AI, no network, no randomness, no clock. The same text and the same
|
|
304
|
+
catalog always produce byte-identical findings, which is what makes the
|
|
305
|
+
detection explainable and reproducible.
|
|
306
|
+
|
|
307
|
+
Behaviour this class owns:
|
|
308
|
+
- segments a document into statements while preserving absolute offsets
|
|
309
|
+
- runs every compiled catalog rule over the text
|
|
310
|
+
- resolves overlaps by a fixed precedence so results never vary
|
|
311
|
+
- computes exact character offsets for highlighting
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
def __init__(self, catalog: AmbiguityRuleCatalog) -> None:
|
|
315
|
+
self._catalog = catalog
|
|
316
|
+
|
|
317
|
+
@property
|
|
318
|
+
def catalog_version(self) -> str:
|
|
319
|
+
return self._catalog.catalog_version
|
|
320
|
+
|
|
321
|
+
# ------------------------------------------------------------ segment
|
|
322
|
+
|
|
323
|
+
def segment(self, text: str) -> list[Statement]:
|
|
324
|
+
"""Split text into statements, keeping absolute offsets into `text`.
|
|
325
|
+
|
|
326
|
+
Offsets are computed from match positions rather than by searching for
|
|
327
|
+
each chunk, so repeated identical sentences still get correct, distinct
|
|
328
|
+
offsets.
|
|
329
|
+
"""
|
|
330
|
+
|
|
331
|
+
#this method returns all statements(sentence chunks) in the text, with their start and end offsets
|
|
332
|
+
statements: list[Statement] = []
|
|
333
|
+
index = 0
|
|
334
|
+
cursor = 0
|
|
335
|
+
|
|
336
|
+
# separator is the match object for the next statement break, which is either
|
|
337
|
+
# a sentence-ending punctuation followed by whitespace, or any run of newlines.
|
|
338
|
+
for separator in _STATEMENT_BREAK.finditer(text):
|
|
339
|
+
chunk = text[cursor : separator.start()]
|
|
340
|
+
if chunk.strip():
|
|
341
|
+
statements.append(
|
|
342
|
+
Statement(
|
|
343
|
+
index=index,
|
|
344
|
+
text=chunk,
|
|
345
|
+
start_offset=cursor,
|
|
346
|
+
end_offset=separator.start(),
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
index += 1
|
|
350
|
+
cursor = separator.end()
|
|
351
|
+
|
|
352
|
+
tail = text[cursor:]
|
|
353
|
+
if tail.strip():
|
|
354
|
+
statements.append(
|
|
355
|
+
Statement(
|
|
356
|
+
index=index,
|
|
357
|
+
text=tail,
|
|
358
|
+
start_offset=cursor,
|
|
359
|
+
end_offset=len(text),
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
return statements
|
|
364
|
+
|
|
365
|
+
# ------------------------------------------------------------ analyze
|
|
366
|
+
|
|
367
|
+
def analyze(self, text: str) -> list[Finding]:
|
|
368
|
+
"""Return non-overlapping findings, ordered by position."""
|
|
369
|
+
if not text or not text.strip():
|
|
370
|
+
return []
|
|
371
|
+
#statements later is used to find the statement index for each finding, which is needed for context when asking C5 questions.
|
|
372
|
+
statements = self.segment(text)
|
|
373
|
+
candidates: list[Finding] = []
|
|
374
|
+
|
|
375
|
+
for rule in self._catalog.rules:
|
|
376
|
+
# instead of phrases, use patterns for actual phrases as specified in rule_catalog
|
|
377
|
+
# this for loop is to find the matches of the rule's pattern in the text and create Finding objects for each match
|
|
378
|
+
for match in rule.pattern.finditer(text):
|
|
379
|
+
start, end = match.start(), match.end()
|
|
380
|
+
candidates.append(
|
|
381
|
+
Finding(
|
|
382
|
+
rule_id=rule.id,
|
|
383
|
+
category=rule.category,
|
|
384
|
+
severity=rule.severity,
|
|
385
|
+
explanation=rule.explanation,
|
|
386
|
+
source_id=rule.source,
|
|
387
|
+
start_offset=start,
|
|
388
|
+
end_offset=end,
|
|
389
|
+
matched_text=text[start:end],
|
|
390
|
+
statement_index=self._statement_index_for(statements, start),
|
|
391
|
+
# for each finding, we find the statement index that contains the start offset of the match. This is used for context when asking C5 questions.
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# Fixed precedence, so the result never depends on rule ordering:
|
|
396
|
+
# 1. earliest start position
|
|
397
|
+
# 2. longest span ('as a minimum' beats 'minimum')
|
|
398
|
+
# 3. highest severity (high beats medium beats low)
|
|
399
|
+
# 4. rule id (alphabetical, purely to break remaining ties)
|
|
400
|
+
candidates.sort(
|
|
401
|
+
key=lambda f: (
|
|
402
|
+
f.start_offset,
|
|
403
|
+
-f.length,
|
|
404
|
+
SEVERITY_RANK[f.severity],
|
|
405
|
+
f.rule_id,
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
#this for loop is just ot make sure that the findings do not overlap. If a finding overlaps with the previous one, it is discarded.
|
|
410
|
+
accepted: list[Finding] = []
|
|
411
|
+
last_end = -1
|
|
412
|
+
for finding in candidates:
|
|
413
|
+
if finding.start_offset >= last_end:
|
|
414
|
+
accepted.append(finding)
|
|
415
|
+
last_end = finding.end_offset
|
|
416
|
+
return accepted
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def statement_for_offset(self, text: str, offset: int) -> str:
|
|
420
|
+
"""The full statement containing `offset`. Used by C5 for prompt context."""
|
|
421
|
+
for statement in self.segment(text):
|
|
422
|
+
if statement.start_offset <= offset < statement.end_offset:
|
|
423
|
+
return statement.text.strip()
|
|
424
|
+
return text.strip()
|
|
425
|
+
|
|
426
|
+
@staticmethod
|
|
427
|
+
def _statement_index_for(statements: list[Statement], offset: int) -> int:
|
|
428
|
+
for statement in statements:
|
|
429
|
+
if statement.start_offset <= offset < statement.end_offset:
|
|
430
|
+
return statement.index
|
|
431
|
+
return 0
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# ------------------------------------------------------------ mcp server
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
mcp = MCPServer("ReqCheck")
|
|
438
|
+
|
|
439
|
+
# Built once at import. Loading is pure file IO and the catalog is read-only,
|
|
440
|
+
# so every request reuses the compiled patterns.
|
|
441
|
+
_catalog = AmbiguityRuleCatalog.from_yaml()
|
|
442
|
+
_analyzer = AmbiguityAnalyzer(_catalog)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@mcp.tool()
|
|
446
|
+
def ambiguity_analysis(text: str) -> dict[str, Any]:
|
|
447
|
+
"""Find ambiguous terms in a requirements document, checked against
|
|
448
|
+
ISO/IEC/IEEE 29148:2018.
|
|
449
|
+
|
|
450
|
+
Detection is fully deterministic: the same text always returns the same
|
|
451
|
+
findings, with no model involved and every finding traceable to a numbered
|
|
452
|
+
rule and a clause of the standard.
|
|
453
|
+
|
|
454
|
+
Each finding carries the flagged term, the statement it appears in, why it
|
|
455
|
+
was flagged, the standard it came from, and question templates for that
|
|
456
|
+
rule.
|
|
457
|
+
|
|
458
|
+
Use the question templates to ask the user what they actually mean --
|
|
459
|
+
what measurable value, what baseline, whose judgement. Do not rewrite the
|
|
460
|
+
requirement for them; the point is that they decide the wording.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
text: The requirements document. Plain text -- extract it first if the
|
|
464
|
+
source is a PDF or Word file.
|
|
465
|
+
"""
|
|
466
|
+
findings = _analyzer.analyze(text)
|
|
467
|
+
# Segment once and index it, rather than re-scanning the document for every
|
|
468
|
+
# finding, which would be quadratic on a long document.
|
|
469
|
+
statements = {s.index: s.text.strip() for s in _analyzer.segment(text)}
|
|
470
|
+
|
|
471
|
+
results: list[dict[str, Any]] = []
|
|
472
|
+
for finding in findings:
|
|
473
|
+
display = _catalog.display_for(finding.rule_id)
|
|
474
|
+
results.append({
|
|
475
|
+
"matched_text": finding.matched_text,
|
|
476
|
+
"statement": statements.get(finding.statement_index, ""),
|
|
477
|
+
"statement_index": finding.statement_index,
|
|
478
|
+
"rule_id": finding.rule_id,
|
|
479
|
+
"category": finding.category,
|
|
480
|
+
"category_label": display["category_label"],
|
|
481
|
+
"severity": finding.severity,
|
|
482
|
+
"explanation": finding.explanation,
|
|
483
|
+
"source_id": finding.source_id,
|
|
484
|
+
"source_label": display["source_label"],
|
|
485
|
+
"source_clause": display["source_clause"],
|
|
486
|
+
"question_templates": list(
|
|
487
|
+
_catalog.question_templates_for(finding.rule_id)
|
|
488
|
+
),
|
|
489
|
+
# Kept because matched_text alone is ambiguous when the same term
|
|
490
|
+
# appears more than once; these identify which occurrence.
|
|
491
|
+
"start_offset": finding.start_offset,
|
|
492
|
+
"end_offset": finding.end_offset,
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
return {
|
|
496
|
+
"catalog_version": _catalog.catalog_version,
|
|
497
|
+
"statement_count": len(statements),
|
|
498
|
+
"finding_count": len(results),
|
|
499
|
+
"findings": results,
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
@mcp.resource(
|
|
504
|
+
"reqcheck://knowledge-base",
|
|
505
|
+
name="ReqCheck ambiguity rule catalog",
|
|
506
|
+
description=(
|
|
507
|
+
"The full YAML knowledge base: every rule, its phrases, severity, "
|
|
508
|
+
"explanation, question templates, and ISO/IEC/IEEE 29148 citation."
|
|
509
|
+
),
|
|
510
|
+
mime_type="text/yaml",
|
|
511
|
+
)
|
|
512
|
+
def knowledge_base() -> str:
|
|
513
|
+
"""The rule catalog exactly as authored, including source citations."""
|
|
514
|
+
return DEFAULT_CATALOG_PATH.read_text(encoding="utf-8")
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
@mcp.prompt(
|
|
518
|
+
name="check_requirements",
|
|
519
|
+
title="Check requirements for ambiguity",
|
|
520
|
+
description=(
|
|
521
|
+
"Analyse a requirements document with ReqCheck and report every "
|
|
522
|
+
"ambiguous term with follow-up questions."
|
|
523
|
+
),
|
|
524
|
+
)
|
|
525
|
+
def check_requirements(text: str) -> str:
|
|
526
|
+
"""Ask the client to analyse a document and report findings in a fixed shape."""
|
|
527
|
+
return f"""Use the ReqCheck MCP tool `ambiguity_analysis` to find the ambiguous terms in the requirements below.
|
|
528
|
+
|
|
529
|
+
Report every finding in exactly this format, numbered, one block per finding:
|
|
530
|
+
|
|
531
|
+
**N. <matched_text>**
|
|
532
|
+
- Statement: <statement>
|
|
533
|
+
- Location: statement <statement_index> of the document. If the source was a PDF or Word file, give the page and section instead.
|
|
534
|
+
- Source: <source_label>, <source_clause>
|
|
535
|
+
- Severity: <severity>
|
|
536
|
+
- Category: <category_label>
|
|
537
|
+
- Why: <explanation>
|
|
538
|
+
- Questions to answer:
|
|
539
|
+
1. ...
|
|
540
|
+
2. ...
|
|
541
|
+
3. ...
|
|
542
|
+
|
|
543
|
+
For the questions, adapt the `question_templates` for that finding to this specific statement, using what you know about the document. Ask what measurable value, baseline, or acceptance condition the author means.
|
|
544
|
+
|
|
545
|
+
Do not rewrite the requirement, and do not suggest a number, unit, or threshold yourself -- ask the author for it. Do not show the question templates verbatim.
|
|
546
|
+
|
|
547
|
+
If there are no findings, say the document has no terms matching the catalog and note that this checks wording, not completeness.
|
|
548
|
+
|
|
549
|
+
Here are the requirements:
|
|
550
|
+
|
|
551
|
+
{text}
|
|
552
|
+
"""
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def main() -> None:
|
|
556
|
+
"""Entry point for the `mcp-reqcheck` script and `python server.py`."""
|
|
557
|
+
mcp.run()
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
if __name__ == "__main__":
|
|
561
|
+
main()
|