claude-dev-env 8.6.0 → 8.7.0

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 (106) hide show
  1. package/.agents/skills/privacy-hygiene/reference/sweep-procedure.md +1 -1
  2. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +1 -0
  3. package/_shared/advisor/scripts/model_tier_run_validator.py +3 -2
  4. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +35 -0
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +4 -0
  6. package/_shared/pr-loop/scripts/terminology_sweep.py +155 -18
  7. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +130 -0
  8. package/bin/install.test.mjs +25 -0
  9. package/docs/high-trust-agent-delivery.md +132 -0
  10. package/hooks/advisory/refactor_guard.py +2 -2
  11. package/hooks/advisory/test_refactor_guard.py +21 -0
  12. package/hooks/blocking/code_rules_enforcer_test_support.py +10 -0
  13. package/hooks/blocking/code_rules_shared.py +4 -7
  14. package/hooks/blocking/conftest.py +22 -0
  15. package/hooks/blocking/state_description_blocker.py +232 -90
  16. package/hooks/blocking/test_code_rules_enforcer_duplicate_body.py +2 -11
  17. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +27 -54
  18. package/hooks/blocking/test_code_rules_enforcer_test_support.py +21 -0
  19. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +3 -19
  20. package/hooks/blocking/test_code_rules_shared.py +18 -0
  21. package/hooks/blocking/test_conftest.py +35 -0
  22. package/hooks/blocking/test_state_description_blocker.py +59 -0
  23. package/hooks/git-hooks/git_hooks_constants/__init__.py +1 -14
  24. package/hooks/git-hooks/pull_request_handoff.py +2 -2
  25. package/hooks/git-hooks/test_config.py +10 -3
  26. package/hooks/git-hooks/test_pull_request_handoff.py +49 -0
  27. package/hooks/git-hooks/test_verification_notice_state.py +50 -0
  28. package/hooks/git-hooks/verification_notice_state.py +3 -1
  29. package/hooks/hooks_constants/code_rules_enforcer_constants.py +13 -5
  30. package/hooks/hooks_constants/refactor_guard_constants.py +2 -0
  31. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +3 -4
  32. package/hooks/hooks_constants/state_description_blocker_constants.py +2 -1
  33. package/hooks/hooks_constants/test_state_description_blocker_constants.py +87 -0
  34. package/hooks/validators/exempt_paths.py +3 -18
  35. package/hooks/validators/test_exempt_paths.py +20 -0
  36. package/hooks/validators/test_python_antipattern_checks.py +14 -0
  37. package/package.json +1 -1
  38. package/rules/agent-merges-its-own-green-pull-request.md +54 -0
  39. package/rules/archiving-agent-config.md +10 -0
  40. package/rules/code-standards.md +1 -0
  41. package/rules/correction-lens.md +134 -0
  42. package/rules/flag-non-breaking-findings.md +11 -0
  43. package/rules/no-contrast-framing.md +68 -0
  44. package/rules/review-closure-is-a-check.md +44 -0
  45. package/scripts/Invoke-PesterSuites.ps1 +42 -0
  46. package/scripts/agent_merge_check.py +381 -0
  47. package/scripts/check.ps1 +12 -1
  48. package/scripts/ci/install-playtest.mjs +291 -0
  49. package/scripts/ci/install-playtest.test.mjs +84 -0
  50. package/scripts/ci/installer-lifecycle.mjs +11 -30
  51. package/scripts/ci/installer-lifecycle.test.mjs +14 -0
  52. package/scripts/ci/pstack-command-shim.test.mjs +33 -0
  53. package/scripts/ci/scratch-home-install.mjs +73 -0
  54. package/scripts/ci/scratch-home-install.test.mjs +49 -0
  55. package/scripts/codex_compat_materializer.py +33 -16
  56. package/scripts/contrast_framing.py +101 -0
  57. package/scripts/dev_env_scripts_constants/agent_merge_check_constants.py +121 -0
  58. package/scripts/dev_env_scripts_constants/contrast_framing_constants.py +82 -0
  59. package/scripts/dev_env_scripts_constants/merge_gate_check_constants.py +23 -0
  60. package/scripts/dev_env_scripts_constants/review_closure_constants.py +241 -0
  61. package/scripts/dev_env_scripts_constants/test_code_review_constants.py +12 -0
  62. package/scripts/dev_env_scripts_constants/test_contrast_framing_constants.py +60 -0
  63. package/scripts/durable_post_lint.py +30 -4
  64. package/scripts/followup_cli.py +6 -10
  65. package/scripts/merge_gate_checks.py +236 -0
  66. package/scripts/policy_lint/adapter_contrast_framing.py +59 -0
  67. package/scripts/policy_lint/adapter_detectors.py +1 -1
  68. package/scripts/policy_lint/adapter_support.py +3 -14
  69. package/scripts/policy_lint/adapters.py +30 -1
  70. package/scripts/policy_lint/config/constants.py +2 -0
  71. package/scripts/policy_lint/registry.py +5 -0
  72. package/scripts/repository_checks/config/constants.py +12 -0
  73. package/scripts/repository_checks/config/policy_document.py +28 -2
  74. package/scripts/repository_checks/followups.py +54 -0
  75. package/scripts/repository_checks/match_exemptions.py +67 -0
  76. package/scripts/repository_checks/models.py +46 -2
  77. package/scripts/repository_checks/reporting.py +23 -7
  78. package/scripts/repository_checks/tracked_secrets.py +2 -2
  79. package/scripts/repository_policy.py +4 -2
  80. package/scripts/review_closure.py +134 -0
  81. package/scripts/review_closure_github.py +345 -0
  82. package/scripts/review_closure_model.py +360 -0
  83. package/scripts/test_agent_merge_check.py +317 -0
  84. package/scripts/test_durable_post_lint.py +46 -0
  85. package/scripts/test_followup_cli.py +40 -0
  86. package/scripts/test_merge_gate_checks.py +92 -0
  87. package/scripts/tests/test_code_review_constants.py +78 -0
  88. package/scripts/tests/test_codex_compat_materializer.py +48 -4
  89. package/scripts/tests/test_contrast_framing.py +117 -0
  90. package/scripts/tests/test_followups.py +88 -0
  91. package/scripts/tests/test_match_exemptions.py +291 -0
  92. package/scripts/tests/test_models.py +67 -2
  93. package/scripts/tests/test_package_inventory.py +6 -4
  94. package/scripts/tests/test_pester_suites_run.py +53 -0
  95. package/scripts/tests/test_policy_document.py +39 -1
  96. package/scripts/tests/test_policy_lint_rules_contrast_framing.py +68 -0
  97. package/scripts/tests/test_policy_lint_rules_engine.py +4 -1
  98. package/scripts/tests/test_reporting.py +18 -0
  99. package/scripts/tests/test_repository_policy.py +43 -1
  100. package/scripts/tests/test_review_closure.py +126 -0
  101. package/scripts/tests/test_review_closure_github.py +240 -0
  102. package/scripts/tests/test_review_closure_model.py +299 -0
  103. package/scripts/tests/test_tracked_secrets.py +42 -0
  104. package/hooks/hooks_constants/precommit_code_rules_gate_constants.py +0 -12
  105. package/scripts/repository_checks/email_exemptions.py +0 -39
  106. package/scripts/tests/test_email_exemptions.py +0 -158
@@ -49,7 +49,7 @@ Review each hit. Ignore:
49
49
 
50
50
  - Stage only clean files
51
51
  - Prefer `--body-file` for `gh` posts (also required by the gh-cli-conventions rule)
52
- - Let `pii_prevention_blocker` re-run on the next Write / commit / post
52
+ - No hook re-scans the next Write, commit, or post; `pii_prevention_blocker` was retired. Re-run this sweep before the next commit or post.
53
53
 
54
54
  ## Accepted residual (do not over-scrub)
55
55
 
@@ -105,3 +105,4 @@ CLI_SUCCESS_EXIT_CODE: int = 0
105
105
  ALL_ADVISOR_REPLY_PATHS: frozenset[str] = frozenset(
106
106
  {"native", "sendmessage", "cli", "codex", "none"}
107
107
  )
108
+ EVIDENCE_MUST_BE_OBJECT_MESSAGE: str = "evidence must be an object"
@@ -76,6 +76,7 @@ from advisor_scripts_constants.model_tier_run_validator_constants import (
76
76
  CLI_SUCCESS_EXIT_CODE,
77
77
  CLI_USAGE_MESSAGE,
78
78
  CLI_VALIDATION_FAILURE_EXIT_CODE,
79
+ EVIDENCE_MUST_BE_OBJECT_MESSAGE,
79
80
  HOST_PROFILE_CLAUDE,
80
81
  HOST_PROFILE_CODEX,
81
82
  HOST_PROFILE_JSON_KEY,
@@ -242,7 +243,7 @@ def _validate_advisor_evidence(
242
243
  run: ModelTierRun,
243
244
  ) -> None:
244
245
  if not isinstance(evidence, Mapping):
245
- raise ModelTierRunError("evidence must be an object")
246
+ raise ModelTierRunError(EVIDENCE_MUST_BE_OBJECT_MESSAGE)
246
247
  raw_schema_version = evidence.get("schema_version")
247
248
  if isinstance(raw_schema_version, bool) or raw_schema_version != 1:
248
249
  raise ModelTierRunError("evidence.schema_version must be 1")
@@ -415,7 +416,7 @@ def load_model_tier_run_from_json_path(from_path: Path) -> ModelTierRun:
415
416
  raise TypeError(HOST_PROFILE_MUST_BE_STRING_MESSAGE)
416
417
  raw_evidence = parsed_payload.get("evidence")
417
418
  if raw_evidence is not None and not isinstance(raw_evidence, dict):
418
- raise TypeError("evidence must be an object")
419
+ raise TypeError(EVIDENCE_MUST_BE_OBJECT_MESSAGE)
419
420
  return ModelTierRun(
420
421
  own_tier=parsed_payload["own_tier"],
421
422
  candidate_tiers=list(parsed_payload["candidate_tiers"]),
@@ -35,6 +35,9 @@ load_model_tier_run_from_json_path = (
35
35
  )
36
36
  ADVISOR_MODEL_TIER = model_tier_run_validator.ADVISOR_MODEL_TIER
37
37
  CODEX_BIND_SUCCESS_TOKEN = model_tier_run_validator.CODEX_BIND_SUCCESS_TOKEN
38
+ EVIDENCE_MUST_BE_OBJECT_MESSAGE = (
39
+ model_tier_run_validator.EVIDENCE_MUST_BE_OBJECT_MESSAGE
40
+ )
38
41
 
39
42
 
40
43
  def _native_codex_evidence(
@@ -136,6 +139,38 @@ def test_cli_reads_versioned_advisor_evidence(tmp_path: Path) -> None:
136
139
  assert loaded_run.evidence["consult"]["report_back_status"] == "recorded"
137
140
 
138
141
 
142
+ def test_non_object_evidence_reports_the_same_message_at_validate_and_load(
143
+ tmp_path: Path,
144
+ ) -> None:
145
+ run = ModelTierRun(
146
+ own_tier="Opus",
147
+ candidate_tiers=[ADVISOR_MODEL_TIER],
148
+ attempts=[{"tier": ADVISOR_MODEL_TIER, "result": "spawned"}],
149
+ selected_tier=ADVISOR_MODEL_TIER,
150
+ host_profile="Codex",
151
+ evidence=["not", "an", "object"],
152
+ )
153
+ with pytest.raises(ModelTierRunError, match=EVIDENCE_MUST_BE_OBJECT_MESSAGE):
154
+ validate_model_tier_run(run)
155
+
156
+ log_path = tmp_path / "model-tier-run.json"
157
+ log_path.write_text(
158
+ json.dumps(
159
+ {
160
+ "own_tier": "Opus",
161
+ "candidate_tiers": [ADVISOR_MODEL_TIER],
162
+ "attempts": [{"tier": ADVISOR_MODEL_TIER, "result": "spawned"}],
163
+ "selected_tier": ADVISOR_MODEL_TIER,
164
+ "host_profile": "Codex",
165
+ "evidence": "not an object",
166
+ }
167
+ ),
168
+ encoding="utf-8",
169
+ )
170
+ with pytest.raises(TypeError, match=EVIDENCE_MUST_BE_OBJECT_MESSAGE):
171
+ load_model_tier_run_from_json_path(from_path=log_path)
172
+
173
+
139
174
  def test_clean_single_spawn_at_top_of_slice_passes() -> None:
140
175
  run = ModelTierRun(
141
176
  own_tier="Opus",
@@ -10,6 +10,10 @@ MARKDOWN_FILE_EXTENSION: str = ".md"
10
10
 
11
11
  INLINE_CODE_SPAN_PATTERN: re.Pattern[str] = re.compile(r"`[^`]*`")
12
12
 
13
+ MARKDOWN_LINK_TARGET_PATTERN: re.Pattern[str] = re.compile(r"\]\([^)]*\)")
14
+
15
+ SENTENCE_BOUNDARY_PATTERN: re.Pattern[str] = re.compile(r"(?<=[.!?])\s+")
16
+
13
17
  SNAKE_CASE_IDENTIFIER_PATTERN: re.Pattern[str] = re.compile(
14
18
  r"\b[a-z][a-z0-9]*(?:_[a-z0-9]+)+\b"
15
19
  )
@@ -62,10 +62,12 @@ from pr_loop_shared_constants.terminology_sweep_constants import (
62
62
  JAVASCRIPT_LINE_COMMENT_MARKER,
63
63
  JSDOC_CONTINUATION_MARKER,
64
64
  MARKDOWN_FILE_EXTENSION,
65
+ MARKDOWN_LINK_TARGET_PATTERN,
65
66
  MINIMUM_IDENTIFIER_TOKEN_COUNT,
66
67
  PROSE_WINDOW_WORD_SEPARATOR,
67
68
  PROSE_WORD_PATTERN,
68
69
  PYTHON_COMMENT_MARKER,
70
+ SENTENCE_BOUNDARY_PATTERN,
69
71
  SNAKE_CASE_IDENTIFIER_PATTERN,
70
72
  STRING_LITERAL_CONTENT_PATTERN,
71
73
  TERMINOLOGY_FINDING_TEMPLATE,
@@ -74,7 +76,6 @@ from pr_loop_shared_constants.terminology_sweep_constants import (
74
76
  TEST_FILE_PREFIX,
75
77
  TEST_FILE_SUFFIX,
76
78
  )
77
-
78
79
  from subprocess_window_access import hidden_window_creation_flags
79
80
 
80
81
  IdentifierTuple = tuple[str, ...]
@@ -161,27 +162,76 @@ def _collect_introduced_identifiers(
161
162
  ) -> frozenset[IdentifierTuple]:
162
163
  """Return the identifier tuples introduced on added code lines.
163
164
 
165
+ A name bound only inside a test module is a fixture's own local variable,
166
+ not a term the rest of the repository is expected to agree with, so a
167
+ test file's identifiers are left out.
168
+
164
169
  Args:
165
170
  all_added_lines: Added-line triples from :func:`_parse_added_lines`.
166
171
 
167
172
  Returns:
168
- Every multi-word identifier tuple appearing on an added code line.
173
+ Every multi-word identifier tuple appearing on an added,
174
+ non-test-module code line.
169
175
  """
170
176
  all_identifier_tuples: set[IdentifierTuple] = set()
171
177
  for each_file_path, _, each_text in all_added_lines:
172
- if _file_extension(each_file_path) in ALL_SWEEP_CODE_FILE_EXTENSIONS:
178
+ if _is_swept_non_test_code_file(each_file_path):
173
179
  all_identifier_tuples.update(_identifier_tuples_in_text(each_text))
174
180
  return frozenset(all_identifier_tuples)
175
181
 
176
182
 
183
+ def _fragments_split_at_breaks(all_fragments: list[str]) -> list[str]:
184
+ """Split each fragment at every sentence, link, and code-span break in turn."""
185
+ for each_break_pattern in (
186
+ INLINE_CODE_SPAN_PATTERN,
187
+ MARKDOWN_LINK_TARGET_PATTERN,
188
+ SENTENCE_BOUNDARY_PATTERN,
189
+ ):
190
+ all_fragments = [
191
+ each_piece
192
+ for each_fragment in all_fragments
193
+ for each_piece in each_break_pattern.split(each_fragment)
194
+ ]
195
+ return all_fragments
196
+
197
+
198
+ def _split_into_prose_fragments(raw_fragment: str) -> list[str]:
199
+ """Split a raw prose fragment at every sentence, link, and code-span break.
200
+
201
+ ::
202
+
203
+ raw: "…gates the pull request. [`link`](docs/README.md) explains it."
204
+ split -> ["…gates the pull request", "explains it."]
205
+ flag: a window built from the joined text -- "pull request docs"
206
+ ok: "pull request" and "docs" now sit in different fragments
207
+
208
+ A sentence-ending period, a Markdown link's target, and a backticked code
209
+ span each end whatever the reader was reading and start something new.
210
+ Splitting there keeps a word window scoped to one sentence, clear of a
211
+ stripped span's unrelated text on its far side.
212
+
213
+ Args:
214
+ raw_fragment: One prose fragment collected from an added line.
215
+
216
+ Returns:
217
+ The fragment's pieces with every break removed, in order, dropping
218
+ any piece left blank.
219
+ """
220
+ all_fragments = _fragments_split_at_breaks([raw_fragment])
221
+ return [
222
+ each_fragment.strip() for each_fragment in all_fragments if each_fragment.strip()
223
+ ]
224
+
225
+
177
226
  def _prose_fragments(file_path: str, line_text: str) -> list[str]:
178
227
  """Return the prose fragments of an added line worth scanning for terms.
179
228
 
180
- A Markdown line is prose with its inline-code spans removed, since a
181
- backticked span names code verbatim. A code line contributes its comment
182
- tail, its JSDoc continuation text, and the contents of its string
183
- literals. A test module contributes its comment tail and JSDoc text only.
184
- Its string literals hold fixture data, not prose.
229
+ A Markdown line is prose split at its sentence, link, and code-span
230
+ breaks, since a backticked span names code verbatim and a link target is
231
+ a path, not a word. A code line contributes its comment tail, its JSDoc
232
+ continuation text, and the contents of its string literals, each split
233
+ the same way. A test module contributes its comment tail and JSDoc text
234
+ only. Its string literals hold fixture data, not prose.
185
235
 
186
236
  Args:
187
237
  file_path: The path the added line belongs to.
@@ -192,17 +242,21 @@ def _prose_fragments(file_path: str, line_text: str) -> list[str]:
192
242
  """
193
243
  extension = _file_extension(file_path)
194
244
  if extension == MARKDOWN_FILE_EXTENSION:
195
- return [INLINE_CODE_SPAN_PATTERN.sub(" ", line_text)]
245
+ return _split_into_prose_fragments(line_text)
196
246
  if extension not in ALL_SWEEP_CODE_FILE_EXTENSIONS:
197
247
  return []
198
- all_fragments: list[str] = []
248
+ all_raw_fragments: list[str] = []
199
249
  stripped_line = line_text.strip()
200
250
  if stripped_line.startswith(JSDOC_CONTINUATION_MARKER):
201
- all_fragments.append(stripped_line)
202
- all_fragments.extend(_comment_fragments(line_text))
251
+ all_raw_fragments.append(stripped_line)
252
+ all_raw_fragments.extend(_comment_fragments(line_text))
203
253
  if not _is_test_file(file_path):
204
- all_fragments.extend(_string_literal_fragments(line_text))
205
- return all_fragments
254
+ all_raw_fragments.extend(_string_literal_fragments(line_text))
255
+ return [
256
+ each_split_fragment
257
+ for each_raw_fragment in all_raw_fragments
258
+ for each_split_fragment in _split_into_prose_fragments(each_raw_fragment)
259
+ ]
206
260
 
207
261
 
208
262
  def _is_test_file(file_path: str) -> bool:
@@ -245,6 +299,13 @@ def _is_test_file(file_path: str) -> bool:
245
299
  )
246
300
 
247
301
 
302
+ def _is_swept_non_test_code_file(file_path: str) -> bool:
303
+ """Return whether a diff path names a swept code file outside a test module."""
304
+ return _file_extension(
305
+ file_path
306
+ ) in ALL_SWEEP_CODE_FILE_EXTENSIONS and not _is_test_file(file_path)
307
+
308
+
248
309
  def _comment_fragments(line_text: str) -> list[str]:
249
310
  """Return the comment tail of a code line, or an empty list when absent."""
250
311
  all_fragments: list[str] = []
@@ -428,15 +489,30 @@ def _near_miss_identifier(
428
489
  all_identifier_tuples: frozenset[IdentifierTuple],
429
490
  identifiers_by_first_token: dict[str, list[IdentifierTuple]],
430
491
  all_identifier_tokens: frozenset[str],
492
+ all_known_identifier_tuples: frozenset[IdentifierTuple],
431
493
  ) -> IdentifierTuple | None:
432
494
  """Return an identifier the candidate near-misses, or None when it does not.
433
495
 
496
+ ::
497
+
498
+ known identifier: cde_lint (scripts/cde_lint.py, the tool's own name)
499
+ introduced: cde_lint_path 3-token, adds "path" to that name
500
+ candidate: "cde lint standard" shares "cde lint", then "standard"
501
+ candidate[:-1]: ("cde", "lint") == the known identifier cde_lint
502
+ ok: candidate[:-1] names cde_lint verbatim -- agreement, not a near-miss
503
+ flag: "premium request budget" against premium_request_interactions
504
+ -- "premium request" names no identifier of its own
505
+
434
506
  Args:
435
507
  candidate_tuple: The prose term's lowercase token tuple.
436
508
  all_identifier_tuples: Every introduced identifier tuple.
437
509
  identifiers_by_first_token: Identifier tuples grouped by leading token.
438
510
  all_identifier_tokens: Every token of every identifier on added code
439
511
  lines, used to spare prose built from real code vocabulary.
512
+ all_known_identifier_tuples: Every identifier tuple the sweep knows
513
+ about, introduced or pre-existing, plus each added file's stem,
514
+ used to spare a candidate whose leading tokens name one of them
515
+ verbatim.
440
516
 
441
517
  Returns:
442
518
  The first identifier the candidate renames in only its final token. Such a
@@ -449,9 +525,12 @@ def _near_miss_identifier(
449
525
  common English compound tail word (``read-only``, ``data-driven``); it
450
526
  contains an English stopword (``to a``, ``each image``); it differs
451
527
  from the identifier only by a singular/plural form of one or more
452
- tokens (``test files`` against ``test_file``); or every diverging word
528
+ tokens (``test files`` against ``test_file``); every diverging word
453
529
  is itself a token of some introduced identifier (``target width box``
454
- when ``box_height`` is also in the diff).
530
+ when ``box_height`` is also in the diff); or its leading tokens, with
531
+ the final diverging word set aside, spell out a known identifier of
532
+ their own (``cde_lint standard`` against ``cde_lint_path``, when
533
+ ``cde_lint`` already names something in the tree).
455
534
  """
456
535
  if not candidate_tuple or candidate_tuple in all_identifier_tuples:
457
536
  return None
@@ -468,6 +547,8 @@ def _near_miss_identifier(
468
547
  continue
469
548
  if not _diverges_only_in_final_token(candidate_tuple, each_identifier):
470
549
  continue
550
+ if candidate_tuple[:-1] in all_known_identifier_tuples:
551
+ continue
471
552
  diverging_words = [
472
553
  each_word
473
554
  for each_word, each_token in zip(candidate_tuple, each_identifier)
@@ -489,6 +570,7 @@ def _findings_for_line(
489
570
  all_identifier_tuples: frozenset[IdentifierTuple],
490
571
  identifiers_by_first_token: dict[str, list[IdentifierTuple]],
491
572
  all_identifier_tokens: frozenset[str],
573
+ all_known_identifier_tuples: frozenset[IdentifierTuple],
492
574
  ) -> list[str]:
493
575
  """Return the near-miss findings for one added line.
494
576
 
@@ -500,6 +582,8 @@ def _findings_for_line(
500
582
  identifiers_by_first_token: Identifier tuples grouped by leading token.
501
583
  all_identifier_tokens: Every token of every identifier on added code
502
584
  lines, used to spare prose built from real code vocabulary.
585
+ all_known_identifier_tuples: Every identifier tuple the sweep knows
586
+ about, passed through to :func:`_near_miss_identifier`.
503
587
 
504
588
  Returns:
505
589
  One finding string per distinct near-miss term on the line.
@@ -515,6 +599,7 @@ def _findings_for_line(
515
599
  all_identifier_tuples,
516
600
  identifiers_by_first_token,
517
601
  all_identifier_tokens,
602
+ all_known_identifier_tuples,
518
603
  )
519
604
  if matched_identifier is None or each_tuple in reported_tuples:
520
605
  continue
@@ -530,6 +615,48 @@ def _findings_for_line(
530
615
  return all_findings
531
616
 
532
617
 
618
+ def _stem_identifier_tuple_for_code_file(each_file_path: str) -> IdentifierTuple | None:
619
+ """Return a non-test code file's stem as a multi-word identifier tuple.
620
+
621
+ ``None`` when the path is not a swept code file, is a test file, or its
622
+ stem carries fewer than the minimum identifier token count.
623
+ """
624
+ if not _is_swept_non_test_code_file(each_file_path):
625
+ return None
626
+ stem_tuple = _identifier_token_tuple(Path(each_file_path).stem)
627
+ if len(stem_tuple) < MINIMUM_IDENTIFIER_TOKEN_COUNT:
628
+ return None
629
+ return stem_tuple
630
+
631
+
632
+ def _file_stem_identifier_tuples(
633
+ all_added_lines: list[tuple[str, int, str]],
634
+ ) -> frozenset[IdentifierTuple]:
635
+ """Return the identifier tuple of each added, non-test code file's stem.
636
+
637
+ ::
638
+
639
+ touched file: scripts/cde_lint_partition.py
640
+ stem tuple: ("cde", "lint", "partition")
641
+
642
+ A file that exists in the tree names something whether or not that name
643
+ is ever spelled out as a code identifier, so a file's stem joins the
644
+ known-identifier vocabulary the same way a spelled-out name does.
645
+
646
+ Args:
647
+ all_added_lines: Added-line triples from :func:`_parse_added_lines`.
648
+
649
+ Returns:
650
+ One tuple per distinct, multi-word stem of an added, non-test code
651
+ file.
652
+ """
653
+ return frozenset(
654
+ stem_tuple
655
+ for each_file_path, _, _ in all_added_lines
656
+ if (stem_tuple := _stem_identifier_tuple_for_code_file(each_file_path)) is not None
657
+ )
658
+
659
+
533
660
  def _find_terminology_near_misses(
534
661
  diff_text: str, all_preexisting_identifier_tuples: frozenset[IdentifierTuple]) -> list[str]:
535
662
  all_added_lines = _parse_added_lines(diff_text)
@@ -537,6 +664,9 @@ def _find_terminology_near_misses(
537
664
  all_identifier_tokens = frozenset(
538
665
  each_token for each_tuple in all_identifier_tuples for each_token in each_tuple
539
666
  )
667
+ all_known_identifier_tuples = all_identifier_tuples | _file_stem_identifier_tuples(
668
+ all_added_lines
669
+ )
540
670
  introduced_tuples = frozenset(
541
671
  each_tuple
542
672
  for each_tuple in all_identifier_tuples
@@ -557,6 +687,7 @@ def _find_terminology_near_misses(
557
687
  introduced_tuples,
558
688
  introduced_by_first_token,
559
689
  all_identifier_tokens,
690
+ all_known_identifier_tuples,
560
691
  )
561
692
  )
562
693
  return all_findings
@@ -605,15 +736,21 @@ def repository_environment() -> dict[str, str]:
605
736
  def _identifier_names_on_added_code_lines(diff_text: str) -> frozenset[str]:
606
737
  """Return every multi-word identifier name on the diff's added code lines.
607
738
 
739
+ A name bound only inside a test module is left out, matching
740
+ :func:`_collect_introduced_identifiers`, since it is never treated as
741
+ introduced terminology and its presence in the base tree is not worth a
742
+ lookup.
743
+
608
744
  Args:
609
745
  diff_text: The unified-diff text to scan.
610
746
 
611
747
  Returns:
612
- The distinct snake_case and camelCase names of two or more tokens.
748
+ The distinct snake_case and camelCase names of two or more tokens
749
+ on a non-test-module code line.
613
750
  """
614
751
  all_names: set[str] = set()
615
752
  for each_file_path, _, each_text in _parse_added_lines(diff_text):
616
- if _file_extension(each_file_path) not in ALL_SWEEP_CODE_FILE_EXTENSIONS:
753
+ if not _is_swept_non_test_code_file(each_file_path):
617
754
  continue
618
755
  all_found_names = SNAKE_CASE_IDENTIFIER_PATTERN.findall(each_text)
619
756
  all_found_names += CAMEL_CASE_IDENTIFIER_PATTERN.findall(each_text)
@@ -626,6 +626,123 @@ def test_does_not_flag_two_token_identifier_first_word_coincidence() -> None:
626
626
  assert sweep_diff(diff) == []
627
627
 
628
628
 
629
+ def test_does_not_flag_window_crossing_a_sentence_boundary() -> None:
630
+ diff = (
631
+ "diff --git a/api/quota.py b/api/quota.py\n"
632
+ "--- a/api/quota.py\n"
633
+ "+++ a/api/quota.py\n"
634
+ "@@ -0,0 +1,1 @@\n"
635
+ "+pull_request_owner = 5\n"
636
+ "diff --git a/README.md b/README.md\n"
637
+ "--- a/README.md\n"
638
+ "+++ b/README.md\n"
639
+ "@@ -0,0 +1,1 @@\n"
640
+ "+Merge the pull request. Trigger warnings surface elsewhere.\n"
641
+ )
642
+ assert sweep_diff(diff) == []
643
+
644
+
645
+ def test_does_not_flag_window_crossing_a_markdown_link_target() -> None:
646
+ diff = (
647
+ "diff --git a/api/quota.py b/api/quota.py\n"
648
+ "--- a/api/quota.py\n"
649
+ "+++ a/api/quota.py\n"
650
+ "@@ -0,0 +1,1 @@\n"
651
+ "+pull_request_owner = 5\n"
652
+ "diff --git a/README.md b/README.md\n"
653
+ "--- a/README.md\n"
654
+ "+++ b/README.md\n"
655
+ "@@ -0,0 +1,1 @@\n"
656
+ "+See the pull request [`link`](docs/README.md) explains it.\n"
657
+ )
658
+ assert sweep_diff(diff) == []
659
+
660
+
661
+ def test_does_not_flag_identifier_bound_only_in_a_test_module() -> None:
662
+ diff = (
663
+ "diff --git a/tests/test_config.py b/tests/test_config.py\n"
664
+ "--- a/tests/test_config.py\n"
665
+ "+++ b/tests/test_config.py\n"
666
+ "@@ -0,0 +1,1 @@\n"
667
+ "+jon_pc_json = load_fixture()\n"
668
+ "diff --git a/README.md b/README.md\n"
669
+ "--- a/README.md\n"
670
+ "+++ b/README.md\n"
671
+ "@@ -0,0 +1,1 @@\n"
672
+ "+The jon pc export runs first.\n"
673
+ )
674
+ assert sweep_diff(diff) == []
675
+
676
+
677
+ def test_still_flags_identifier_bound_in_a_production_module() -> None:
678
+ diff = (
679
+ "diff --git a/api/config.py b/api/config.py\n"
680
+ "--- a/api/config.py\n"
681
+ "+++ b/api/config.py\n"
682
+ "@@ -0,0 +1,1 @@\n"
683
+ "+jon_pc_json = load_config()\n"
684
+ "diff --git a/README.md b/README.md\n"
685
+ "--- a/README.md\n"
686
+ "+++ b/README.md\n"
687
+ "@@ -0,0 +1,1 @@\n"
688
+ "+The jon pc export runs first.\n"
689
+ )
690
+ findings = sweep_diff(diff)
691
+ assert len(findings) == 1
692
+ assert "jon pc export" in findings[0]
693
+
694
+
695
+ def test_does_not_flag_window_whose_leading_tokens_are_a_known_identifier() -> None:
696
+ diff = (
697
+ "diff --git a/api/store.py b/api/store.py\n"
698
+ "--- a/api/store.py\n"
699
+ "+++ b/api/store.py\n"
700
+ "@@ -0,0 +1,2 @@\n"
701
+ "+cde_lint_path = Path(value)\n"
702
+ '+HELP_TEXT = "cde_lint standard output"\n'
703
+ )
704
+ assert sweep_diff(diff) == []
705
+
706
+
707
+ def test_still_flags_window_whose_leading_tokens_name_nothing_known() -> None:
708
+ diff = (
709
+ "diff --git a/api/quota.py b/api/quota.py\n"
710
+ "--- a/api/quota.py\n"
711
+ "+++ b/api/quota.py\n"
712
+ "@@ -0,0 +1,1 @@\n"
713
+ "+premium_request_interactions = 5\n"
714
+ "diff --git a/docs/README.md b/docs/README.md\n"
715
+ "--- a/docs/README.md\n"
716
+ "+++ b/docs/README.md\n"
717
+ "@@ -0,0 +1,1 @@\n"
718
+ "+The premium request budget gates the run.\n"
719
+ )
720
+ findings = sweep_diff(diff)
721
+ assert len(findings) == 1
722
+ assert "premium request budget" in findings[0]
723
+
724
+
725
+ def test_does_not_flag_window_whose_leading_tokens_are_an_added_files_stem() -> None:
726
+ diff = (
727
+ "diff --git a/scripts/cde_lint.py b/scripts/cde_lint.py\n"
728
+ "--- a/scripts/cde_lint.py\n"
729
+ "+++ b/scripts/cde_lint.py\n"
730
+ "@@ -0,0 +1,1 @@\n"
731
+ "+VERSION = 1\n"
732
+ "diff --git a/scripts/cde_lint_partition.py b/scripts/cde_lint_partition.py\n"
733
+ "--- a/scripts/cde_lint_partition.py\n"
734
+ "+++ b/scripts/cde_lint_partition.py\n"
735
+ "@@ -0,0 +1,1 @@\n"
736
+ "+cde_lint_owner = 5\n"
737
+ "diff --git a/docs/README.md b/docs/README.md\n"
738
+ "--- a/docs/README.md\n"
739
+ "+++ b/docs/README.md\n"
740
+ "@@ -0,0 +1,1 @@\n"
741
+ "+Run cde_lint standard checks first.\n"
742
+ )
743
+ assert sweep_diff(diff) == []
744
+
745
+
629
746
  def test_flags_near_miss_when_second_leading_token_is_plural_variant() -> None:
630
747
  diff = (
631
748
  "diff --git a/api/store.py b/api/store.py\n"
@@ -642,3 +759,16 @@ def test_flags_near_miss_when_second_leading_token_is_plural_variant() -> None:
642
759
  findings = sweep_diff(diff)
643
760
  assert len(findings) == 1
644
761
  assert "retry policies limit" in findings[0]
762
+
763
+
764
+ def test_a_swept_code_file_outside_the_test_modules_reads_as_swept() -> None:
765
+ assert sweep_module._is_swept_non_test_code_file("api/store.py") is True
766
+
767
+
768
+ def test_a_test_module_does_not_read_as_a_swept_code_file() -> None:
769
+ assert sweep_module._is_swept_non_test_code_file("api/test_store.py") is False
770
+ assert sweep_module._is_swept_non_test_code_file("api/tests/store.py") is False
771
+
772
+
773
+ def test_a_markdown_document_does_not_read_as_a_swept_code_file() -> None:
774
+ assert sweep_module._is_swept_non_test_code_file("docs/README.md") is False
@@ -2928,6 +2928,31 @@ test('retired skills leave install groups and stay in the cleanup registry', ()
2928
2928
  }
2929
2929
  });
2930
2930
 
2931
+ test('the README agents section names the shipped agent, not archived personal agents', () => {
2932
+ const readme = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8');
2933
+ const agentsSection = readme.slice(
2934
+ readme.indexOf('### Agents'),
2935
+ readme.indexOf('### Commands'),
2936
+ );
2937
+ assert.match(agentsSection, /`poteto-agent`/);
2938
+ for (const archivedAgentName of [
2939
+ 'clean-coder',
2940
+ 'code-quality-agent',
2941
+ 'git-commit-crafter',
2942
+ 'issue-tracker',
2943
+ 'plan-packet-validator',
2944
+ 'pr-description-writer',
2945
+ 'session-advisor',
2946
+ 'skill-writer-agent',
2947
+ ]) {
2948
+ assert.equal(
2949
+ agentsSection.includes(`\`${archivedAgentName}\``),
2950
+ false,
2951
+ `${archivedAgentName} is archived and should not appear as a shipped agent`,
2952
+ );
2953
+ }
2954
+ });
2955
+
2931
2956
  test('the README names current session tools', () => {
2932
2957
  const readme = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8');
2933
2958
  assert.doesNotMatch(readme, /--only journal|session-log|session-tidy/);