devtime-ei 0.3.0__tar.gz → 0.5.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.
Files changed (77) hide show
  1. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/PKG-INFO +15 -23
  2. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/README.md +13 -21
  3. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/pyproject.toml +2 -2
  4. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/__init__.py +1 -1
  5. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/cli.py +133 -11
  6. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/intelligence/verification.py +415 -36
  7. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/mcp/transport.py +30 -9
  8. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime_ei.egg-info/PKG-INFO +15 -23
  9. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/LICENSE +0 -0
  10. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/setup.cfg +0 -0
  11. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/ai/__init__.py +0 -0
  12. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/ai/local.py +0 -0
  13. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/ai/prompts.py +0 -0
  14. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/ai/providers.py +0 -0
  15. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/assets/devtimeignore.starter +0 -0
  16. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/config.py +0 -0
  17. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/db/__init__.py +0 -0
  18. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/db/connection.py +0 -0
  19. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/db/migrations.py +0 -0
  20. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/db/repository.py +0 -0
  21. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/db/schema.sql +0 -0
  22. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/demo.py +0 -0
  23. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/fixtures/__init__.py +0 -0
  24. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/fixtures/assertions.py +0 -0
  25. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/fixtures/loader.py +0 -0
  26. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/fixtures/runner.py +0 -0
  27. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/intelligence/__init__.py +0 -0
  28. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/intelligence/claims.py +0 -0
  29. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/intelligence/concepts.py +0 -0
  30. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/intelligence/context_pack.py +0 -0
  31. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/intelligence/evidence.py +0 -0
  32. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/intelligence/lineage.py +0 -0
  33. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/intelligence/risk.py +0 -0
  34. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/intelligence/scoring.py +0 -0
  35. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/mcp/__init__.py +0 -0
  36. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/mcp/schemas.py +0 -0
  37. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/mcp/server.py +0 -0
  38. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/mcp/tools.py +0 -0
  39. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/output/__init__.py +0 -0
  40. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/output/json_export.py +0 -0
  41. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/output/markdown.py +0 -0
  42. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/output/terminal.py +0 -0
  43. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/paths.py +0 -0
  44. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/privacy.py +0 -0
  45. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/resources/demo-saas/.devtimeignore +0 -0
  46. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/resources/demo-saas/README.md +0 -0
  47. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/resources/demo-saas/docs/decisions/0001-use-jwt.md +0 -0
  48. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/resources/demo-saas/package.json +0 -0
  49. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/resources/demo-saas/src/admin/permissions.ts +0 -0
  50. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/resources/demo-saas/src/auth/login.ts +0 -0
  51. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/resources/demo-saas/src/auth/middleware.ts +0 -0
  52. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/resources/demo-saas/src/auth/tokens.ts +0 -0
  53. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/resources/demo-saas/src/billing/stripe-webhook.ts +0 -0
  54. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/resources/demo-saas/src/billing/subscription-service.ts +0 -0
  55. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/resources/demo-saas/src/export/export-csv.ts +0 -0
  56. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/resources/demo-saas/src/jobs/email-worker.ts +0 -0
  57. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/resources/demo-saas/src/jobs/queues.ts +0 -0
  58. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/resources/demo-saas/tests/auth-login.test.ts +0 -0
  59. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/resources/demo-saas/tests/stripe-signature.test.ts +0 -0
  60. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/scanner/__init__.py +0 -0
  61. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/scanner/extractors/__init__.py +0 -0
  62. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/scanner/extractors/base.py +0 -0
  63. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/scanner/extractors/config_files.py +0 -0
  64. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/scanner/extractors/docs.py +0 -0
  65. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/scanner/extractors/nextjs.py +0 -0
  66. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/scanner/extractors/python.py +0 -0
  67. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/scanner/extractors/tests.py +0 -0
  68. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/scanner/extractors/typescript.py +0 -0
  69. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/scanner/file_walker.py +0 -0
  70. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/scanner/ignore.py +0 -0
  71. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/scanner/language.py +0 -0
  72. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime/scanner/signals.py +0 -0
  73. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime_ei.egg-info/SOURCES.txt +0 -0
  74. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime_ei.egg-info/dependency_links.txt +0 -0
  75. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime_ei.egg-info/entry_points.txt +0 -0
  76. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime_ei.egg-info/requires.txt +0 -0
  77. {devtime_ei-0.3.0 → devtime_ei-0.5.0}/src/devtime_ei.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devtime-ei
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Local-first Engineering Intelligence for software repositories
5
5
  Author-email: Aviad Shakargi <aviad94@gmail.com>
6
6
  Maintainer-email: Aviad Shakargi <aviad94@gmail.com>
@@ -8,7 +8,7 @@ License: Apache-2.0
8
8
  Project-URL: Homepage, https://github.com/Shakargy/devtime
9
9
  Project-URL: Repository, https://github.com/Shakargy/devtime
10
10
  Project-URL: Issues, https://github.com/Shakargy/devtime/issues
11
- Project-URL: Release Notes, https://github.com/Shakargy/devtime/releases/tag/v0.3.0
11
+ Project-URL: Release Notes, https://github.com/Shakargy/devtime/releases/tag/v0.5.0
12
12
  Project-URL: Demo, https://youtu.be/1Hiu3Y9J_SI
13
13
  Keywords: devtools,cli,static-analysis,repository-analysis,engineering-intelligence,local-first
14
14
  Classifier: Development Status :: 3 - Alpha
@@ -204,7 +204,7 @@ Anything outside these six is out of scope for V0. See [LIMITATIONS.md](LIMITATI
204
204
  | `dtc context <concept>` | Create a governed Context Pack for agents or humans. |
205
205
  | `dtc risk --diff` | Review a git diff for risky changes using local evidence (advisory). |
206
206
  | `dtc decision add` | Add a local decision record that can reduce uncertainty. |
207
- | `dtc verify [claim]` | Verify a repository claim against evidence: status, contradictions, freshness (experimental). |
207
+ | `dtc verify [claim]` | Verify repository claims against evidence: status, contradictions, freshness (experimental). |
208
208
 
209
209
  (Also available: `dtc evidence`, `dtc debt`, `dtc status`, `dtc doctor --privacy`,
210
210
  `dtc export`, `dtc reset`, `dtc mcp start`.)
@@ -299,26 +299,18 @@ pip install -e ".[dev]"
299
299
  ## Verify claims (experimental)
300
300
 
301
301
  DevTime is growing into a verification layer: ask whether a statement about the
302
- repository is actually supported.
303
-
304
- ```bash
305
- dtc verify billing-webhook-signature
306
- ```
307
-
308
- ```
309
- Billing Webhook Signature Verification
310
- Claim: Incoming billing webhooks verify the payment provider's signature.
311
- Status: CONTRADICTED
312
-
313
- Contradictions:
314
- - The billing webhook endpoint cannot verify signatures because it is a disabled stub.
315
- claimed: apps/web/pages/api/stripe/webhook.ts is named and routed as a billing webhook endpoint.
316
- observed: The handler's only behavior is a 404/501 response.
317
- ```
318
-
319
- Statuses are SUPPORTED, WEAK, CONTRADICTED, or UNKNOWN; contradictions always
320
- show both sides; changed evidence marks a claim STALE. Two built-in claims ship
321
- (billing webhook signatures, JWT authentication). See **[VERIFICATION.md](VERIFICATION.md)**.
302
+ repository is actually supported. Watch a claim live through its whole life -
303
+ supported, contradicted by a stub, restored, and stale after the evidence
304
+ changes:
305
+
306
+ ![dtc verify demo - a claim goes from SUPPORTED to CONTRADICTED to STALE](assets/devtime-verify-demo.svg)
307
+
308
+ Statuses are SUPPORTED, WEAK, CONTRADICTED, UNKNOWN, or NOT_APPLICABLE;
309
+ contradictions always show both sides; changed evidence marks a claim STALE.
310
+ Four built-in claims ship: route test coverage, admin authorization, billing
311
+ webhook signatures, and JWT authentication. `dtc verify` leads with what it can
312
+ actually verify here, and when nothing applies it says what would make a claim
313
+ verifiable instead of dead-ending. See **[VERIFICATION.md](VERIFICATION.md)**.
322
314
 
323
315
  ## Example output
324
316
 
@@ -165,7 +165,7 @@ Anything outside these six is out of scope for V0. See [LIMITATIONS.md](LIMITATI
165
165
  | `dtc context <concept>` | Create a governed Context Pack for agents or humans. |
166
166
  | `dtc risk --diff` | Review a git diff for risky changes using local evidence (advisory). |
167
167
  | `dtc decision add` | Add a local decision record that can reduce uncertainty. |
168
- | `dtc verify [claim]` | Verify a repository claim against evidence: status, contradictions, freshness (experimental). |
168
+ | `dtc verify [claim]` | Verify repository claims against evidence: status, contradictions, freshness (experimental). |
169
169
 
170
170
  (Also available: `dtc evidence`, `dtc debt`, `dtc status`, `dtc doctor --privacy`,
171
171
  `dtc export`, `dtc reset`, `dtc mcp start`.)
@@ -260,26 +260,18 @@ pip install -e ".[dev]"
260
260
  ## Verify claims (experimental)
261
261
 
262
262
  DevTime is growing into a verification layer: ask whether a statement about the
263
- repository is actually supported.
264
-
265
- ```bash
266
- dtc verify billing-webhook-signature
267
- ```
268
-
269
- ```
270
- Billing Webhook Signature Verification
271
- Claim: Incoming billing webhooks verify the payment provider's signature.
272
- Status: CONTRADICTED
273
-
274
- Contradictions:
275
- - The billing webhook endpoint cannot verify signatures because it is a disabled stub.
276
- claimed: apps/web/pages/api/stripe/webhook.ts is named and routed as a billing webhook endpoint.
277
- observed: The handler's only behavior is a 404/501 response.
278
- ```
279
-
280
- Statuses are SUPPORTED, WEAK, CONTRADICTED, or UNKNOWN; contradictions always
281
- show both sides; changed evidence marks a claim STALE. Two built-in claims ship
282
- (billing webhook signatures, JWT authentication). See **[VERIFICATION.md](VERIFICATION.md)**.
263
+ repository is actually supported. Watch a claim live through its whole life -
264
+ supported, contradicted by a stub, restored, and stale after the evidence
265
+ changes:
266
+
267
+ ![dtc verify demo - a claim goes from SUPPORTED to CONTRADICTED to STALE](assets/devtime-verify-demo.svg)
268
+
269
+ Statuses are SUPPORTED, WEAK, CONTRADICTED, UNKNOWN, or NOT_APPLICABLE;
270
+ contradictions always show both sides; changed evidence marks a claim STALE.
271
+ Four built-in claims ship: route test coverage, admin authorization, billing
272
+ webhook signatures, and JWT authentication. `dtc verify` leads with what it can
273
+ actually verify here, and when nothing applies it says what would make a claim
274
+ verifiable instead of dead-ending. See **[VERIFICATION.md](VERIFICATION.md)**.
283
275
 
284
276
  ## Example output
285
277
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devtime-ei"
3
- version = "0.3.0"
3
+ version = "0.5.0"
4
4
  description = "Local-first Engineering Intelligence for software repositories"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -50,7 +50,7 @@ dev = [
50
50
  Homepage = "https://github.com/Shakargy/devtime"
51
51
  Repository = "https://github.com/Shakargy/devtime"
52
52
  Issues = "https://github.com/Shakargy/devtime/issues"
53
- "Release Notes" = "https://github.com/Shakargy/devtime/releases/tag/v0.3.0"
53
+ "Release Notes" = "https://github.com/Shakargy/devtime/releases/tag/v0.5.0"
54
54
  Demo = "https://youtu.be/1Hiu3Y9J_SI"
55
55
 
56
56
  [project.scripts]
@@ -1,6 +1,6 @@
1
1
  """DevTime - local-first Engineering Intelligence for repository memory."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.5.0"
4
4
 
5
5
  # Version metadata (Builder Edition, Chapter 20).
6
6
  EVIDENCE_MODEL = "2026.06.1"
@@ -97,51 +97,71 @@ def verify(
97
97
  conn = connection.connect()
98
98
  try:
99
99
  if list_claims:
100
+ # Relevance is computed live so the list answers the useful question:
101
+ # which of these claims apply to THIS repository?
102
+ current = {r.claim_slug: r for r in ver.verify_all(conn)}
100
103
  rows = []
101
104
  for slug, definition in ver.BUILTIN_CLAIMS.items():
102
105
  latest = ver.load_latest_verification(conn, slug)
103
106
  freshness, changed = ver.freshness_for(conn, slug)
107
+ live = current.get(slug)
104
108
  rows.append(
105
109
  {
106
110
  "claim_id": slug,
107
111
  "name": definition.name,
108
112
  "statement": definition.statement,
113
+ "applies_here": bool(
114
+ live and live.status in ver.APPLICABLE_STATUSES
115
+ ),
116
+ "current_status": live.status if live else None,
109
117
  "last_status": latest[0]["status"] if latest else None,
110
118
  "freshness": freshness,
111
119
  "changed_evidence": changed,
112
120
  }
113
121
  )
122
+ rows.sort(key=lambda r: (not r["applies_here"], r["claim_id"]))
114
123
  if as_json:
115
- console.print_json(_json.dumps({"schema_version": "1", "claims": rows}))
124
+ console.print_json(_json.dumps({"schema_version": "2", "claims": rows}))
116
125
  else:
117
126
  console.print("[bold]Built-in claims[/bold]\n")
118
127
  for r in rows:
128
+ if not r["applies_here"]:
129
+ console.print(f" [dim]{r['claim_id']} (not applicable here)[/dim]")
130
+ continue
119
131
  status_txt = r["last_status"] or "never verified"
120
132
  console.print(f" {r['claim_id']}")
121
133
  console.print(f" {r['statement']}")
122
- console.print(f" last status: {status_txt} freshness: {r['freshness']}")
134
+ console.print(
135
+ f" current: {r['current_status']} "
136
+ f"last verified: {status_txt} freshness: {r['freshness']}"
137
+ )
123
138
  for p in r["changed_evidence"]:
124
139
  console.print(f" changed since verification: {p}", markup=False)
125
140
  console.print("")
126
141
  return
127
142
 
128
- slugs = [claim] if claim else list(ver.BUILTIN_CLAIMS.keys())
129
- results = []
130
- for slug in slugs:
143
+ if claim:
131
144
  try:
132
- result = ver.verify_claim(conn, slug)
145
+ results = [ver.verify_claim(conn, claim)]
133
146
  except KeyError:
134
- console.print(f"[red]Unknown claim:[/red] {slug}")
147
+ console.print(f"[red]Unknown claim:[/red] {claim}")
135
148
  console.print("Run [bold]dtc verify --list[/bold] to see built-in claims.")
136
149
  raise typer.Exit(code=1)
137
- ver.save_verification(conn, result)
138
- results.append(result)
150
+ else:
151
+ results = ver.verify_all(conn)
152
+
153
+ # Only real verifications are recorded. A claim that does not apply to
154
+ # this repository was not verified, so storing it would pollute
155
+ # freshness and diff impact with claims that have no evidence.
156
+ for result in results:
157
+ if result.status in ver.APPLICABLE_STATUSES:
158
+ ver.save_verification(conn, result)
139
159
 
140
160
  if as_json:
141
161
  console.print_json(
142
162
  _json.dumps(
143
163
  {
144
- "schema_version": "1",
164
+ "schema_version": "2",
145
165
  "command": "verify",
146
166
  "results": [r.to_dict() for r in results],
147
167
  }
@@ -149,11 +169,94 @@ def verify(
149
169
  )
150
170
  return
151
171
 
152
- for result in results:
172
+ _print_report(results, single=bool(claim))
173
+ finally:
174
+ conn.close()
175
+
176
+
177
+ def _print_report(results: list, single: bool) -> None:
178
+ """Report card: what DevTime can and cannot verify about this repository."""
179
+ from devtime.intelligence import verification as ver
180
+
181
+ applicable = [r for r in results if r.status in ver.APPLICABLE_STATUSES]
182
+ not_applicable = [r for r in results if r.status == ver.NOT_APPLICABLE]
183
+
184
+ for result in applicable:
185
+ _print_verification(result)
186
+
187
+ if not_applicable and not single:
188
+ console.print("[dim]Not applicable to this repository:[/dim]")
189
+ for r in not_applicable:
190
+ reason = r.why[0] if r.why else "No relevant surface was found."
191
+ console.print(f" - {r.claim_slug}: {reason}", markup=False)
192
+ console.print("")
193
+
194
+ if single and not applicable:
195
+ # An explicitly requested claim that does not apply still explains itself.
196
+ for result in not_applicable:
153
197
  _print_verification(result)
198
+ return
199
+
200
+ if not applicable:
201
+ _print_nothing_verifiable()
202
+
203
+
204
+ def _print_nothing_verifiable() -> None:
205
+ """Never a dead end: say what was scanned and what would unlock a claim."""
206
+ from devtime.db import connection
207
+
208
+ conn = connection.connect()
209
+ try:
210
+ scan = conn.execute(
211
+ "SELECT id, file_count, signal_count FROM scans WHERE status = 'completed' "
212
+ "ORDER BY started_at DESC LIMIT 1"
213
+ ).fetchone()
214
+ kinds = []
215
+ if scan:
216
+ kinds = conn.execute(
217
+ "SELECT kind, COUNT(*) c FROM signals WHERE scan_id = ? "
218
+ "GROUP BY kind ORDER BY c DESC LIMIT 6",
219
+ (scan["id"],),
220
+ ).fetchall()
154
221
  finally:
155
222
  conn.close()
156
223
 
224
+ console.print("[bold]No built-in claim applies to this repository yet.[/bold]")
225
+ console.print("")
226
+ if scan:
227
+ console.print(
228
+ f"DevTime scanned {scan['file_count']} files and found "
229
+ f"{scan['signal_count']} signals.",
230
+ markup=False,
231
+ )
232
+ if kinds:
233
+ summary = ", ".join(f"{k['kind']}={k['c']}" for k in kinds)
234
+ console.print(f"Evidence collected: {summary}", markup=False)
235
+ else:
236
+ console.print(
237
+ "No evidence was extracted, which usually means this repository's "
238
+ "language or framework is outside current scanner coverage.",
239
+ markup=False,
240
+ )
241
+ console.print("")
242
+ console.print("Built-in claims become verifiable when a repository has:")
243
+ console.print(" - HTTP routes and tests (route-test-coverage)")
244
+ console.print(" - admin, staff, or back-office routes (admin-authorization)")
245
+ console.print(" - JWT usage or a JWT dependency (jwt-authentication)")
246
+ console.print(" - billing webhooks or a payment provider (billing-webhook-signature)")
247
+ console.print("")
248
+ console.print(
249
+ "This is a coverage limit, not a verdict on your repository. "
250
+ "Scanner support is strongest on TypeScript, Next.js, Express, and "
251
+ "FastAPI-style code; see LIMITATIONS.md.",
252
+ markup=False,
253
+ )
254
+ console.print(
255
+ "If DevTime missed something your repository clearly has, that is worth "
256
+ "an issue: https://github.com/Shakargy/devtime/issues",
257
+ markup=False,
258
+ )
259
+
157
260
 
158
261
  def _print_verification(result) -> None:
159
262
  color = {
@@ -161,6 +264,7 @@ def _print_verification(result) -> None:
161
264
  "WEAK": "yellow",
162
265
  "CONTRADICTED": "red",
163
266
  "UNKNOWN": "cyan",
267
+ "NOT_APPLICABLE": "dim",
164
268
  }.get(result.status, "white")
165
269
  console.print(f"[bold]{result.claim_name}[/bold]")
166
270
  console.print(f"Claim: {result.statement}")
@@ -347,11 +451,29 @@ def risk(
347
451
  conn = connection.connect()
348
452
  try:
349
453
  intelligence = repository.load_all_concepts(conn)
454
+ from devtime.intelligence.verification import claims_affected_by_paths
455
+
456
+ claim_impact = claims_affected_by_paths(conn, info.changed_files)
350
457
  finally:
351
458
  conn.close()
352
459
 
353
460
  review = review_diff(info, intelligence)
354
461
  console.print(render_risk_review(review), markup=False)
462
+
463
+ # v0.4.0: claim impact. A diff is not just risky in general - it can
464
+ # destabilize a previously verified claim. Only evidence files count;
465
+ # nothing is printed when no verified claim is affected.
466
+ if claim_impact:
467
+ console.print("\nClaim impact:", markup=False)
468
+ for item in claim_impact:
469
+ console.print(
470
+ f" - {item['claim_id']} (previous status: {item['previous_status']})",
471
+ markup=False,
472
+ )
473
+ for p in item["changed_evidence"]:
474
+ console.print(f" changed evidence: {p}", markup=False)
475
+ console.print(f" re-verify: {item['suggested_action']}", markup=False)
476
+
355
477
  if review.state == STATE_REVIEW_FAILED:
356
478
  raise typer.Exit(code=1)
357
479
 
@@ -6,19 +6,22 @@ persisted scan evidence and answers with a status, both-sided contradictions,
6
6
  missing evidence, coverage limitations, and freshness - never with confidence
7
7
  the evidence cannot back.
8
8
 
9
- V0.2 scope, deliberately narrow:
9
+ Scope, deliberately narrow:
10
10
  - Built-in claims only (no user-defined claim files yet).
11
- - One claim domain: billing webhook signature verification.
12
- - Four statuses: SUPPORTED, WEAK, CONTRADICTED, UNKNOWN.
13
- - Freshness from file fingerprints: FRESH, STALE, NEEDS_VERIFICATION.
14
11
  - Deterministic and rule-driven. No AI, no network, no code execution.
12
+ - Freshness from file fingerprints: FRESH, STALE, NEEDS_VERIFICATION.
15
13
 
16
14
  Statuses (documented meaning, per repository evidence policy - not formal proof):
17
- SUPPORTED required behavior evidence exists in the current scan.
18
- WEAK the claim's surface exists, but the proving evidence is missing.
19
- CONTRADICTED credible evidence conflicts with the claim; both sides are shown.
20
- UNKNOWN the repository shows no relevant surface, or coverage cannot
21
- responsibly decide.
15
+ SUPPORTED required behavior evidence exists in the current scan.
16
+ WEAK the claim's surface exists, but the proving evidence is missing.
17
+ CONTRADICTED credible evidence conflicts with the claim; both sides are shown.
18
+ UNKNOWN the surface exists but coverage cannot responsibly decide.
19
+ NOT_APPLICABLE the repository has no surface this claim is about (v0.5.0).
20
+
21
+ NOT_APPLICABLE matters as much as the others. A repository with no billing code
22
+ is not "unknown" for a billing claim - the claim simply does not apply, and
23
+ saying so plainly is more honest than an ominous UNKNOWN. UNKNOWN is reserved
24
+ for the harder case: the surface exists, but the evidence cannot decide.
22
25
 
23
26
  Freshness is separate from truth:
24
27
  FRESH supporting evidence files are unchanged since verification.
@@ -41,6 +44,10 @@ SUPPORTED = "SUPPORTED"
41
44
  WEAK = "WEAK"
42
45
  CONTRADICTED = "CONTRADICTED"
43
46
  UNKNOWN = "UNKNOWN"
47
+ NOT_APPLICABLE = "NOT_APPLICABLE"
48
+
49
+ # Statuses that mean "this claim has something to say about this repository".
50
+ APPLICABLE_STATUSES = (SUPPORTED, WEAK, CONTRADICTED, UNKNOWN)
44
51
 
45
52
  # Freshness
46
53
  FRESH = "FRESH"
@@ -117,8 +124,11 @@ class VerificationResult:
117
124
  engine_version: str
118
125
 
119
126
  def to_dict(self) -> dict:
127
+ # schema_version 2 (v0.5.0): adds the NOT_APPLICABLE status value. All
128
+ # version 1 fields are unchanged, so consumers that ignore unknown
129
+ # status values keep working.
120
130
  return {
121
- "schema_version": "1",
131
+ "schema_version": "2",
122
132
  "claim_id": self.claim_slug,
123
133
  "claim_name": self.claim_name,
124
134
  "statement": self.statement,
@@ -155,6 +165,18 @@ BUILTIN_CLAIMS: dict[str, ClaimDefinition] = {
155
165
  statement="Authentication uses JWT access tokens.",
156
166
  category="authentication",
157
167
  ),
168
+ "route-test-coverage": ClaimDefinition(
169
+ slug="route-test-coverage",
170
+ name="Route Test Coverage",
171
+ statement="HTTP routes are exercised by tests.",
172
+ category="testing",
173
+ ),
174
+ "admin-authorization": ClaimDefinition(
175
+ slug="admin-authorization",
176
+ name="Admin Authorization",
177
+ statement="Administrative routes require an authorization check.",
178
+ category="security",
179
+ ),
158
180
  }
159
181
 
160
182
  # JWT library names for dependency evidence.
@@ -210,6 +232,23 @@ def _is_billingish(hay: str) -> bool:
210
232
  # The engine
211
233
  # --------------------------------------------------------------------------- #
212
234
 
235
+ def _no_scan_result(definition: ClaimDefinition) -> VerificationResult:
236
+ return VerificationResult(
237
+ claim_slug=definition.slug,
238
+ claim_name=definition.name,
239
+ statement=definition.statement,
240
+ status=UNKNOWN,
241
+ why=["No completed scan exists. Run dtc scan first."],
242
+ supporting=[],
243
+ contradictions=[],
244
+ missing=["A completed repository scan."],
245
+ limitations=_LIMITATIONS,
246
+ scan_id=None,
247
+ verified_at=_now(),
248
+ engine_version=__version__,
249
+ )
250
+
251
+
213
252
  def verify_claim(conn: sqlite3.Connection, slug: str) -> VerificationResult:
214
253
  """Verify one built-in claim against the latest completed scan."""
215
254
  definition = BUILTIN_CLAIMS.get(slug)
@@ -218,24 +257,31 @@ def verify_claim(conn: sqlite3.Connection, slug: str) -> VerificationResult:
218
257
 
219
258
  scan_id = _latest_scan_id(conn)
220
259
  if scan_id is None:
221
- return VerificationResult(
222
- claim_slug=definition.slug,
223
- claim_name=definition.name,
224
- statement=definition.statement,
225
- status=UNKNOWN,
226
- why=["No completed scan exists. Run dtc scan first."],
227
- supporting=[],
228
- contradictions=[],
229
- missing=["A completed repository scan."],
230
- limitations=_LIMITATIONS,
231
- scan_id=None,
232
- verified_at=_now(),
233
- engine_version=__version__,
234
- )
260
+ return _no_scan_result(definition)
235
261
 
236
262
  rows = _load_signals(conn, scan_id)
237
- evaluator = _EVALUATORS[slug]
238
- return evaluator(definition, rows, scan_id)
263
+ return _EVALUATORS[slug](definition, rows, scan_id)
264
+
265
+
266
+ def verify_all(conn: sqlite3.Connection) -> list[VerificationResult]:
267
+ """Verify every built-in claim, loading scan evidence exactly once.
268
+
269
+ Applicable results (something to say about this repository) sort first, so
270
+ the first thing a user reads is what DevTime actually found.
271
+ """
272
+ scan_id = _latest_scan_id(conn)
273
+ if scan_id is None:
274
+ return [_no_scan_result(d) for d in BUILTIN_CLAIMS.values()]
275
+
276
+ rows = _load_signals(conn, scan_id)
277
+ results = [
278
+ _EVALUATORS[slug](definition, rows, scan_id)
279
+ for slug, definition in BUILTIN_CLAIMS.items()
280
+ ]
281
+ # Contradictions first: they are the findings a user most needs to see.
282
+ order = {CONTRADICTED: 0, SUPPORTED: 1, WEAK: 2, UNKNOWN: 3, NOT_APPLICABLE: 4}
283
+ results.sort(key=lambda r: (order.get(r.status, 9), r.claim_slug))
284
+ return results
239
285
 
240
286
 
241
287
  def _verify_billing_webhook_signature(
@@ -335,12 +381,12 @@ def _verify_billing_webhook_signature(
335
381
  if not signature_tests:
336
382
  missing.append("A test that exercises webhook signature verification.")
337
383
  else:
338
- status = UNKNOWN
339
- why.append(
340
- "No billing webhook surface was found in the scanned files. "
341
- "The claim does not apply, or the surface is outside scanner coverage."
384
+ return _not_applicable(
385
+ definition,
386
+ scan_id,
387
+ "No billing webhook surface was found in the scanned files.",
388
+ "A billing webhook route, handler, or payment provider dependency.",
342
389
  )
343
- missing.append("Any billing webhook route, handler, or provider dependency.")
344
390
 
345
391
  return VerificationResult(
346
392
  claim_slug=definition.slug,
@@ -458,12 +504,12 @@ def _verify_jwt_authentication(
458
504
  )
459
505
  missing.append("JWT access-token usage (login/bearer/authorization context).")
460
506
  else:
461
- status = UNKNOWN
462
- why.append(
463
- "No JWT evidence was found in the scanned files. The claim does not "
464
- "apply, or the usage is outside scanner coverage."
507
+ return _not_applicable(
508
+ definition,
509
+ scan_id,
510
+ "No JWT evidence was found in the scanned files.",
511
+ "JWT usage, a JWT library dependency, or documentation referencing JWT.",
465
512
  )
466
- missing.append("Any JWT usage, dependency, or documentation.")
467
513
 
468
514
  return VerificationResult(
469
515
  claim_slug=definition.slug,
@@ -481,9 +527,310 @@ def _verify_jwt_authentication(
481
527
  )
482
528
 
483
529
 
530
+ # --------------------------------------------------------------------------- #
531
+ # Route test coverage (v0.5.0)
532
+ # --------------------------------------------------------------------------- #
533
+
534
+ # Route path segments that carry no identity and must not be used for matching.
535
+ _GENERIC_SEGMENTS = {"api", "v1", "v2", "v3", "app", "index", "route", "routes", "src"}
536
+
537
+
538
+ def _module_token(path: str) -> str:
539
+ """The distinctive file stem of an implementation file, lowercased."""
540
+ stem = path.rsplit("/", 1)[-1]
541
+ for suffix in (".ts", ".tsx", ".js", ".jsx", ".py", ".mjs", ".cjs"):
542
+ if stem.endswith(suffix):
543
+ stem = stem[: -len(suffix)]
544
+ break
545
+ return stem.lower()
546
+
547
+
548
+ def _route_tokens(route_path: str) -> list[str]:
549
+ """Distinctive, non-generic segments of a route path."""
550
+ out = []
551
+ for seg in route_path.lower().replace("\\", "/").split("/"):
552
+ seg = seg.strip()
553
+ if not seg or seg.startswith(("[", ":", "{", "<")) or seg in _GENERIC_SEGMENTS:
554
+ continue
555
+ if len(seg) < 3:
556
+ continue
557
+ out.append(seg)
558
+ return out
559
+
560
+
561
+ def _verify_route_test_coverage(
562
+ definition: ClaimDefinition, rows: list[sqlite3.Row], scan_id: str
563
+ ) -> VerificationResult:
564
+ """Verify that HTTP routes are exercised by tests.
565
+
566
+ Matching is deliberately conservative and explainable. A route counts as
567
+ covered when a test file either imports the route's implementation module,
568
+ or names a distinctive segment of the route path. Test files are aggregated
569
+ first so the comparison stays linear in test FILES, not test cases (large
570
+ repos have thousands of test cases across a few dozen files).
571
+
572
+ Absence of tests is missing evidence, never a contradiction.
573
+ """
574
+ # Aggregate tests per file: imports + a single blob of test names.
575
+ test_imports: dict[str, set[str]] = {}
576
+ test_blobs: dict[str, list[str]] = {}
577
+ for row in rows:
578
+ if row["kind"] != "test":
579
+ continue
580
+ try:
581
+ meta = json.loads(row["metadata_json"] or "{}")
582
+ except json.JSONDecodeError:
583
+ meta = {}
584
+ if meta.get("e2e"):
585
+ # E2E specs match by accident (Reality Validation finding); they are
586
+ # weak evidence for concepts and unreliable for route attribution.
587
+ continue
588
+ path = row["path"]
589
+ imports = test_imports.setdefault(path, set())
590
+ for imp in meta.get("imports") or []:
591
+ imports.add(str(imp).lower())
592
+ test_blobs.setdefault(path, []).append(str(row["name"] or "").lower())
593
+
594
+ # One joined blob per test file keeps matching linear in test FILES and turns
595
+ # each check into a single substring scan.
596
+ test_name_blob = {p: " ".join(names) for p, names in test_blobs.items()}
597
+ test_import_blob = {p: " ".join(sorted(i)) for p, i in test_imports.items()}
598
+
599
+ # Deduplicate routes: several methods on one path are one surface to cover.
600
+ routes: dict[tuple[str, str], sqlite3.Row] = {}
601
+ for row in rows:
602
+ if row["kind"] != "route":
603
+ continue
604
+ try:
605
+ meta = json.loads(row["metadata_json"] or "{}")
606
+ except json.JSONDecodeError:
607
+ meta = {}
608
+ route_path = str(meta.get("path") or row["name"] or "").strip()
609
+ routes.setdefault((row["path"], route_path.lower()), row)
610
+
611
+ if not routes:
612
+ return _not_applicable(
613
+ definition,
614
+ scan_id,
615
+ "No HTTP routes were found in the scanned files.",
616
+ "Any HTTP route (Express, Next.js, or FastAPI style).",
617
+ )
618
+
619
+ covered: list[tuple[str, str, str]] = [] # (impl path, route path, reason)
620
+ uncovered: list[tuple[str, str]] = []
621
+ for (impl_path, route_path), row in sorted(routes.items()):
622
+ token = _module_token(impl_path)
623
+ reason = ""
624
+ # 1. A test that imports the implementation module.
625
+ if token and len(token) >= 3:
626
+ for test_path, blob in test_import_blob.items():
627
+ if token in blob:
628
+ reason = f"{test_path} imports {token}"
629
+ break
630
+ # 2. A test whose names mention a distinctive segment of the route path.
631
+ if not reason:
632
+ segments = _route_tokens(route_path)
633
+ for test_path, blob in test_name_blob.items():
634
+ if segments and any(seg in blob for seg in segments):
635
+ reason = f"{test_path} names {segments[0]}"
636
+ break
637
+ if reason:
638
+ covered.append((impl_path, route_path, reason))
639
+ else:
640
+ uncovered.append((impl_path, route_path))
641
+
642
+ total = len(routes)
643
+ n_covered = len(covered)
644
+ # Evidence is bounded (responses and stored fingerprints must stay bounded),
645
+ # and truncation is disclosed below rather than hidden.
646
+ _EVIDENCE_CAP = 25
647
+ sha_by_path = {row["path"]: row["sha256"] for row in rows}
648
+ supporting = [
649
+ EvidenceRef(
650
+ path=impl,
651
+ observation=f"Route {route or impl} is referenced by a test ({reason}).",
652
+ kind="route",
653
+ strength="moderate",
654
+ sha256=sha_by_path.get(impl),
655
+ )
656
+ for impl, route, reason in covered[:_EVIDENCE_CAP]
657
+ ]
658
+
659
+ why = [f"{n_covered} of {total} routes have a referencing test."]
660
+ missing: list[str] = []
661
+ if n_covered == total:
662
+ status = SUPPORTED
663
+ why.append("Every detected route has at least one test referencing it.")
664
+ else:
665
+ status = WEAK
666
+ why.append(
667
+ "Routes without a referencing test are not proven to be exercised."
668
+ )
669
+ shown = [r or p for p, r in uncovered[:8]]
670
+ missing.append(
671
+ f"Tests referencing {total - n_covered} route(s): " + ", ".join(shown)
672
+ + (" ..." if len(uncovered) > 8 else "")
673
+ )
674
+
675
+ limitations = _LIMITATIONS + [
676
+ "Coverage is attributed by test imports and route names, not by executing "
677
+ "tests; a route exercised only indirectly may be reported as uncovered.",
678
+ "End-to-end specs are excluded from attribution because they match by "
679
+ "accident.",
680
+ ]
681
+ if len(covered) > _EVIDENCE_CAP:
682
+ limitations.append(
683
+ f"Evidence is capped at {_EVIDENCE_CAP} routes; freshness tracks only "
684
+ f"those recorded files, not all {len(covered)} covered routes."
685
+ )
686
+ return VerificationResult(
687
+ claim_slug=definition.slug,
688
+ claim_name=definition.name,
689
+ statement=definition.statement,
690
+ status=status,
691
+ why=why,
692
+ supporting=supporting,
693
+ contradictions=[],
694
+ missing=missing,
695
+ limitations=limitations,
696
+ scan_id=scan_id,
697
+ verified_at=_now(),
698
+ engine_version=__version__,
699
+ )
700
+
701
+
702
+ # --------------------------------------------------------------------------- #
703
+ # Admin authorization (v0.5.0)
704
+ # --------------------------------------------------------------------------- #
705
+
706
+ _ADMIN_TOKENS = ("admin", "superuser", "staff", "backoffice", "back-office")
707
+ _AUTHZ_TOKENS = (
708
+ "requireadmin", "require_admin", "isadmin", "is_admin", "adminonly",
709
+ "admin_only", "hasrole", "has_role", "authorize", "authorization",
710
+ "permission", "rbac", "requireauth", "require_auth", "isauthenticated",
711
+ "current_user", "get_current_user", "authmiddleware", "auth_middleware",
712
+ )
713
+
714
+
715
+ def _verify_admin_authorization(
716
+ definition: ClaimDefinition, rows: list[sqlite3.Row], scan_id: str
717
+ ) -> VerificationResult:
718
+ """Verify that administrative routes require an authorization check.
719
+
720
+ Honesty rule for this claim: a missing authorization signal is WEAK, never
721
+ CONTRADICTED. Authorization can be applied globally, by a decorator, or by a
722
+ wrapper the scanner cannot see. Telling someone their admin endpoint is
723
+ unprotected when it is not would destroy the trust this tool is built on.
724
+ """
725
+ admin_routes: list[sqlite3.Row] = []
726
+ authz_files: set[str] = set()
727
+ authz_rows: list[sqlite3.Row] = []
728
+
729
+ for row in rows:
730
+ hay = _hay(row)
731
+ kind = row["kind"]
732
+ if kind == "route" and any(t in hay for t in _ADMIN_TOKENS):
733
+ admin_routes.append(row)
734
+ if kind in ("middleware", "auth_dependency") or any(
735
+ t in hay for t in _AUTHZ_TOKENS
736
+ ):
737
+ if kind in ("middleware", "auth_dependency", "route", "test"):
738
+ authz_files.add(row["path"])
739
+ if kind in ("middleware", "auth_dependency"):
740
+ authz_rows.append(row)
741
+
742
+ if not admin_routes:
743
+ return _not_applicable(
744
+ definition,
745
+ scan_id,
746
+ "No administrative routes were found in the scanned files.",
747
+ "An admin, staff, or back-office route.",
748
+ )
749
+
750
+ protected: list[sqlite3.Row] = []
751
+ unprotected: list[sqlite3.Row] = []
752
+ for row in admin_routes:
753
+ hay = _hay(row)
754
+ # Authorization evidence in the route's own file, or in the route itself.
755
+ if row["path"] in authz_files or any(t in hay for t in _AUTHZ_TOKENS):
756
+ protected.append(row)
757
+ else:
758
+ unprotected.append(row)
759
+
760
+ supporting = [
761
+ _ref(r, "Admin route shows an authorization check in its file.", "moderate")
762
+ for r in protected[:5]
763
+ ] + [
764
+ _ref(r, "Authorization middleware or dependency.", "moderate")
765
+ for r in authz_rows[:2]
766
+ ]
767
+
768
+ total = len(admin_routes)
769
+ why = [f"{len(protected)} of {total} administrative route(s) show an "
770
+ "authorization check."]
771
+ missing: list[str] = []
772
+
773
+ if not unprotected:
774
+ status = SUPPORTED
775
+ why.append("Every detected admin route has authorization evidence.")
776
+ else:
777
+ status = WEAK
778
+ why.append(
779
+ "No authorization evidence was found for the remaining admin route(s). "
780
+ "This is missing evidence, not proof that they are unprotected."
781
+ )
782
+ missing.append(
783
+ "Authorization evidence for: "
784
+ + ", ".join(sorted({r["path"] for r in unprotected})[:6])
785
+ )
786
+
787
+ limitations = _LIMITATIONS + [
788
+ "Authorization applied globally (a server-wide middleware, a router "
789
+ "mount, or a framework decorator the scanner does not parse) is not "
790
+ "detected. A WEAK result means DevTime found no evidence, never that a "
791
+ "route is confirmed unprotected.",
792
+ ]
793
+ return VerificationResult(
794
+ claim_slug=definition.slug,
795
+ claim_name=definition.name,
796
+ statement=definition.statement,
797
+ status=status,
798
+ why=why,
799
+ supporting=supporting,
800
+ contradictions=[],
801
+ missing=missing,
802
+ limitations=limitations,
803
+ scan_id=scan_id,
804
+ verified_at=_now(),
805
+ engine_version=__version__,
806
+ )
807
+
808
+
809
+ def _not_applicable(
810
+ definition: ClaimDefinition, scan_id: str, reason: str, would_need: str
811
+ ) -> VerificationResult:
812
+ """This claim has no surface in this repository. Say so plainly."""
813
+ return VerificationResult(
814
+ claim_slug=definition.slug,
815
+ claim_name=definition.name,
816
+ statement=definition.statement,
817
+ status=NOT_APPLICABLE,
818
+ why=[reason, "This claim does not apply to this repository."],
819
+ supporting=[],
820
+ contradictions=[],
821
+ missing=[f"Would become verifiable with: {would_need}"],
822
+ limitations=_LIMITATIONS,
823
+ scan_id=scan_id,
824
+ verified_at=_now(),
825
+ engine_version=__version__,
826
+ )
827
+
828
+
484
829
  _EVALUATORS = {
485
830
  "billing-webhook-signature": _verify_billing_webhook_signature,
486
831
  "jwt-authentication": _verify_jwt_authentication,
832
+ "route-test-coverage": _verify_route_test_coverage,
833
+ "admin-authorization": _verify_admin_authorization,
487
834
  }
488
835
 
489
836
 
@@ -580,6 +927,38 @@ def load_latest_verification(
580
927
  )
581
928
 
582
929
 
930
+ def claims_affected_by_paths(
931
+ conn: sqlite3.Connection, changed_paths: list[str]
932
+ ) -> list[dict]:
933
+ """Claim impact for a set of changed paths (v0.4.0, diff integration).
934
+
935
+ For every claim with a stored verification, report it when a changed path
936
+ is one of its recorded evidence files. Only evidence files count: a diff
937
+ touching unrelated files never flags a claim. Advisory output - the caller
938
+ decides what to do with it.
939
+ """
940
+ changed = set(changed_paths)
941
+ out: list[dict] = []
942
+ for slug in BUILTIN_CLAIMS:
943
+ latest = load_latest_verification(conn, slug)
944
+ if latest is None:
945
+ continue
946
+ result, fingerprints, created_at = latest
947
+ evidence_paths = {fp["path"] for fp in fingerprints}
948
+ hits = sorted(evidence_paths & changed)
949
+ if hits:
950
+ out.append(
951
+ {
952
+ "claim_id": slug,
953
+ "previous_status": result["status"],
954
+ "verified_at": created_at,
955
+ "changed_evidence": hits,
956
+ "suggested_action": f"dtc verify {slug}",
957
+ }
958
+ )
959
+ return out
960
+
961
+
583
962
  def freshness_for(conn: sqlite3.Connection, slug: str) -> tuple[str, list[str]]:
584
963
  """Compare stored evidence fingerprints against current file hashes.
585
964
 
@@ -52,14 +52,32 @@ class McpDependencyMissing(RuntimeError):
52
52
  INSTALL_HINT = 'MCP support needs the optional dependency: pip install "devtime-ei[mcp]"'
53
53
 
54
54
 
55
- def build_server():
56
- """Build the FastMCP server with the read-only tool surface registered."""
57
- try:
55
+ def _server_class():
56
+ """Return the SDK's server class across MCP SDK generations.
57
+
58
+ The SDK renamed its high-level server in 2.0: `mcp.server.fastmcp.FastMCP`
59
+ became `mcp.server.MCPServer`. Both expose the surface DevTime uses (a
60
+ `tool()` decorator, async `list_tools`/`call_tool`, and a stdio `run`), so
61
+ both are supported rather than pinning users to one generation.
62
+ """
63
+ try: # MCP SDK 2.x
64
+ from mcp.server import MCPServer
65
+
66
+ return MCPServer
67
+ except ImportError:
68
+ pass
69
+ try: # MCP SDK 1.x
58
70
  from mcp.server.fastmcp import FastMCP
71
+
72
+ return FastMCP
59
73
  except ImportError as exc: # pragma: no cover - exercised via CLI test
60
74
  raise McpDependencyMissing(McpDependencyMissing.INSTALL_HINT) from exc
61
75
 
62
- server = FastMCP(SERVER_NAME, instructions=SERVER_INSTRUCTIONS)
76
+
77
+ def build_server():
78
+ """Build the MCP server with the read-only tool surface registered."""
79
+ server_class = _server_class()
80
+ server = server_class(name=SERVER_NAME, instructions=SERVER_INSTRUCTIONS)
63
81
 
64
82
  @server.tool()
65
83
  def list_concepts(limit: int = 50) -> list[dict] | dict:
@@ -100,11 +118,14 @@ def build_server():
100
118
  def verify_claim(claim_id: str = "") -> dict:
101
119
  """Verify a repository claim against scanned evidence (read-only compute).
102
120
 
103
- Returns status (SUPPORTED / WEAK / CONTRADICTED / UNKNOWN), why,
104
- supporting evidence with file paths, both-sided contradictions, missing
105
- evidence, and coverage limitations. Call with no claim_id to list the
106
- built-in claims. Results are computed fresh and NOT persisted (this
107
- server stays read-only); use `dtc verify` in a terminal to record one.
121
+ Returns status (SUPPORTED / WEAK / CONTRADICTED / UNKNOWN /
122
+ NOT_APPLICABLE), why, supporting evidence with file paths, both-sided
123
+ contradictions, missing evidence, and coverage limitations.
124
+ NOT_APPLICABLE means the repository has no surface this claim is about,
125
+ which is different from UNKNOWN (surface exists, evidence cannot
126
+ decide). Call with no claim_id to list the built-in claims. Results are
127
+ computed fresh and NOT persisted (this server stays read-only); use
128
+ `dtc verify` in a terminal to record one.
108
129
  """
109
130
  if not paths.is_initialized():
110
131
  return _NOT_INITIALIZED
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devtime-ei
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Local-first Engineering Intelligence for software repositories
5
5
  Author-email: Aviad Shakargi <aviad94@gmail.com>
6
6
  Maintainer-email: Aviad Shakargi <aviad94@gmail.com>
@@ -8,7 +8,7 @@ License: Apache-2.0
8
8
  Project-URL: Homepage, https://github.com/Shakargy/devtime
9
9
  Project-URL: Repository, https://github.com/Shakargy/devtime
10
10
  Project-URL: Issues, https://github.com/Shakargy/devtime/issues
11
- Project-URL: Release Notes, https://github.com/Shakargy/devtime/releases/tag/v0.3.0
11
+ Project-URL: Release Notes, https://github.com/Shakargy/devtime/releases/tag/v0.5.0
12
12
  Project-URL: Demo, https://youtu.be/1Hiu3Y9J_SI
13
13
  Keywords: devtools,cli,static-analysis,repository-analysis,engineering-intelligence,local-first
14
14
  Classifier: Development Status :: 3 - Alpha
@@ -204,7 +204,7 @@ Anything outside these six is out of scope for V0. See [LIMITATIONS.md](LIMITATI
204
204
  | `dtc context <concept>` | Create a governed Context Pack for agents or humans. |
205
205
  | `dtc risk --diff` | Review a git diff for risky changes using local evidence (advisory). |
206
206
  | `dtc decision add` | Add a local decision record that can reduce uncertainty. |
207
- | `dtc verify [claim]` | Verify a repository claim against evidence: status, contradictions, freshness (experimental). |
207
+ | `dtc verify [claim]` | Verify repository claims against evidence: status, contradictions, freshness (experimental). |
208
208
 
209
209
  (Also available: `dtc evidence`, `dtc debt`, `dtc status`, `dtc doctor --privacy`,
210
210
  `dtc export`, `dtc reset`, `dtc mcp start`.)
@@ -299,26 +299,18 @@ pip install -e ".[dev]"
299
299
  ## Verify claims (experimental)
300
300
 
301
301
  DevTime is growing into a verification layer: ask whether a statement about the
302
- repository is actually supported.
303
-
304
- ```bash
305
- dtc verify billing-webhook-signature
306
- ```
307
-
308
- ```
309
- Billing Webhook Signature Verification
310
- Claim: Incoming billing webhooks verify the payment provider's signature.
311
- Status: CONTRADICTED
312
-
313
- Contradictions:
314
- - The billing webhook endpoint cannot verify signatures because it is a disabled stub.
315
- claimed: apps/web/pages/api/stripe/webhook.ts is named and routed as a billing webhook endpoint.
316
- observed: The handler's only behavior is a 404/501 response.
317
- ```
318
-
319
- Statuses are SUPPORTED, WEAK, CONTRADICTED, or UNKNOWN; contradictions always
320
- show both sides; changed evidence marks a claim STALE. Two built-in claims ship
321
- (billing webhook signatures, JWT authentication). See **[VERIFICATION.md](VERIFICATION.md)**.
302
+ repository is actually supported. Watch a claim live through its whole life -
303
+ supported, contradicted by a stub, restored, and stale after the evidence
304
+ changes:
305
+
306
+ ![dtc verify demo - a claim goes from SUPPORTED to CONTRADICTED to STALE](assets/devtime-verify-demo.svg)
307
+
308
+ Statuses are SUPPORTED, WEAK, CONTRADICTED, UNKNOWN, or NOT_APPLICABLE;
309
+ contradictions always show both sides; changed evidence marks a claim STALE.
310
+ Four built-in claims ship: route test coverage, admin authorization, billing
311
+ webhook signatures, and JWT authentication. `dtc verify` leads with what it can
312
+ actually verify here, and when nothing applies it says what would make a claim
313
+ verifiable instead of dead-ending. See **[VERIFICATION.md](VERIFICATION.md)**.
322
314
 
323
315
  ## Example output
324
316
 
File without changes
File without changes