claude-dev-env 7.3.0 → 7.4.1

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 (35) hide show
  1. package/.agents/skills/session-continuity/continuity.mjs +1 -3
  2. package/.agents/skills/session-continuity/continuity.test.mjs +12 -4
  3. package/AGENTS.md +12 -2
  4. package/bin/install.agents-home.test.mjs +1 -0
  5. package/bin/install.codex-rules.test.mjs +2 -0
  6. package/bin/install.cursor-rules.test.mjs +1 -0
  7. package/bin/install.mjs +57 -10
  8. package/bin/install.prune.test.mjs +1 -0
  9. package/bin/install.pstack-plugin.test.mjs +1 -0
  10. package/bin/install.settings-defaults.test.mjs +1 -0
  11. package/bin/install.test.mjs +118 -4
  12. package/bin/install.transaction.test.mjs +6 -0
  13. package/bin/install.uninstall-transaction.test.mjs +2 -0
  14. package/bin/pstack.mjs +24 -4
  15. package/bin/pstack.test.mjs +20 -10
  16. package/hooks/blocking/env_var_table_code_drift_blocker.py +11 -99
  17. package/hooks/blocking/env_var_table_rows.py +130 -0
  18. package/hooks/blocking/pii_scanner.py +19 -29
  19. package/hooks/blocking/test_env_var_table_code_drift_blocker.py +81 -3
  20. package/hooks/blocking/test_env_var_table_rows.py +100 -0
  21. package/hooks/blocking/test_pii_scanner.py +34 -24
  22. package/hooks/hooks_constants/env_var_table_code_drift_constants.py +12 -4
  23. package/hooks/hooks_constants/hardcoded_user_path_constants.py +12 -10
  24. package/hooks/hooks_constants/pii_prevention_constants.py +10 -25
  25. package/package.json +1 -1
  26. package/scripts/pstack-adapters/common.md +1 -1
  27. package/scripts/repository_checks/config/constants.py +7 -17
  28. package/scripts/repository_checks/config/email_exemptions.py +10 -0
  29. package/scripts/repository_checks/email_exemptions.py +96 -0
  30. package/scripts/repository_checks/env_var_documentation.py +1 -9
  31. package/scripts/repository_checks/tracked_secrets.py +10 -4
  32. package/scripts/tests/test_email_exemptions.py +121 -0
  33. package/scripts/tests/test_env_var_documentation.py +26 -0
  34. package/scripts/tests/test_rules.py +25 -0
  35. package/scripts/tests/test_tracked_secrets.py +13 -0
@@ -25,22 +25,23 @@ _hooks_dir = str(Path(__file__).resolve().parent.parent)
25
25
  if _hooks_dir not in sys.path:
26
26
  sys.path.insert(0, _hooks_dir)
27
27
 
28
- from hooks_constants.env_var_table_code_drift_constants import ( # noqa: E402
29
- ALL_CODE_FILE_EXTENSIONS,
28
+ from env_var_table_rows import (
29
+ _code_file_reference_in_cell,
30
+ _env_var_name_in_cell,
31
+ _is_separator_row,
32
+ _row_cells,
33
+ iter_env_var_table_rows,
34
+ )
35
+ from hooks_constants.env_var_table_code_drift_constants import (
30
36
  ALL_NOISE_DIRECTORY_NAMES,
31
- BACKTICK_TOKEN_PATTERN,
32
- CODE_FENCE_PATTERN,
33
37
  DRIFT_ADDITIONAL_CONTEXT,
34
38
  DRIFT_MESSAGE_TEMPLATE,
35
39
  DRIFT_SYSTEM_MESSAGE,
36
- ENV_VAR_NAME_PATTERN,
37
40
  GIT_DIRECTORY_NAME,
38
41
  MARKDOWN_FILE_EXTENSION,
39
42
  MAX_DRIFT_ISSUES,
40
43
  MAX_SUBTREE_FILES_SCANNED,
41
44
  MINIMUM_ENV_VAR_ROW_CELL_COUNT,
42
- SEPARATOR_CELL_PATTERN,
43
- TABLE_ROW_PATTERN,
44
45
  )
45
46
  from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
46
47
  from hooks_constants.multi_edit_reconstruction import ( # noqa: E402
@@ -65,87 +66,6 @@ def is_markdown_file(file_path: str) -> bool:
65
66
  return extension.lower() == MARKDOWN_FILE_EXTENSION
66
67
 
67
68
 
68
- def _row_cells(table_line: str) -> list[str]:
69
- """Return the trimmed cells of one markdown table row.
70
-
71
- Args:
72
- table_line: A single line that begins with a pipe character.
73
-
74
- Returns:
75
- The text of each pipe-delimited cell, stripped, with the empty leading
76
- and trailing segments a bounding pipe produces removed.
77
- """
78
- stripped_line = table_line.strip()
79
- inner = stripped_line.strip("|")
80
- return [each_cell.strip() for each_cell in inner.split("|")]
81
-
82
-
83
- def _first_backtick_token(cell_text: str) -> str | None:
84
- """Return the first backtick-wrapped token in a cell, when it has one.
85
-
86
- Args:
87
- cell_text: The trimmed text of a table cell.
88
-
89
- Returns:
90
- The text inside the first pair of backticks, or None when the cell
91
- carries no backtick-wrapped token.
92
- """
93
- token_match = BACKTICK_TOKEN_PATTERN.search(cell_text)
94
- if token_match is None:
95
- return None
96
- inner_text = token_match.group(1).strip()
97
- return inner_text or None
98
-
99
-
100
- def _env_var_name_in_cell(cell_text: str) -> str | None:
101
- """Return the environment-variable name a cell names, when it names one.
102
-
103
- Args:
104
- cell_text: The trimmed text of a table cell.
105
-
106
- Returns:
107
- The UPPER_SNAKE variable name inside the first backticks, or None when
108
- the cell names no variable-shaped token.
109
- """
110
- token = _first_backtick_token(cell_text)
111
- if token is None:
112
- return None
113
- if ENV_VAR_NAME_PATTERN.match(token) is None:
114
- return None
115
- return token
116
-
117
-
118
- def _code_file_reference_in_cell(cell_text: str) -> str | None:
119
- """Return the code-file path a cell names, when it names one.
120
-
121
- Args:
122
- cell_text: The trimmed text of a table cell.
123
-
124
- Returns:
125
- The relative code-file path inside the first backticks, or None when the
126
- token carries no recognized code-file extension.
127
- """
128
- token = _first_backtick_token(cell_text)
129
- if token is None:
130
- return None
131
- _, extension = os.path.splitext(token)
132
- if extension.lower() not in ALL_CODE_FILE_EXTENSIONS:
133
- return None
134
- return token
135
-
136
-
137
- def _is_separator_row(all_cells: list[str]) -> bool:
138
- """Return whether every cell is a markdown table header-separator cell.
139
-
140
- Args:
141
- all_cells: The trimmed cells of one table row.
142
-
143
- Returns:
144
- True when each cell holds only dashes, colons, and whitespace.
145
- """
146
- return all(SEPARATOR_CELL_PATTERN.match(each_cell) is not None for each_cell in all_cells)
147
-
148
-
149
69
  def _resolve_scan_root(markdown_directory: Path) -> Path:
150
70
  """Return the repository directory whose subtree bounds the code-file search.
151
71
 
@@ -248,8 +168,8 @@ def _code_file_reads_variable(code_file: Path, variable_name: str) -> bool | Non
248
168
  def find_drift_rows(content: str, markdown_directory: Path) -> list[str]:
249
169
  """Return each env-var table row whose code file does not read the variable.
250
170
 
251
- Walks the markdown content line by line, skipping lines inside a fenced code
252
- block. A table row counts when its first cell names an UPPER_SNAKE variable and
171
+ Reads environment-variable tables and headerless edit fragments outside code
172
+ fences. A row counts when its first cell names an UPPER_SNAKE variable and
253
173
  a later cell names a code file with a recognized extension. The row drifts when
254
174
  that code file resolves under the scan root yet its source never references the
255
175
  variable name; a row whose code file does not resolve, or cannot be read, is
@@ -266,15 +186,7 @@ def find_drift_rows(content: str, markdown_directory: Path) -> list[str]:
266
186
  scan_root = _resolve_scan_root(markdown_directory)
267
187
  drift_rows: list[str] = []
268
188
  already_reported: set[str] = set()
269
- is_inside_code_fence = False
270
- for each_line in content.splitlines():
271
- if CODE_FENCE_PATTERN.match(each_line) is not None:
272
- is_inside_code_fence = not is_inside_code_fence
273
- continue
274
- if is_inside_code_fence:
275
- continue
276
- if TABLE_ROW_PATTERN.match(each_line) is None:
277
- continue
189
+ for each_line in iter_env_var_table_rows(content):
278
190
  each_finding = _drift_finding_for_row(each_line, scan_root)
279
191
  if each_finding is None or each_finding in already_reported:
280
192
  continue
@@ -0,0 +1,130 @@
1
+ """Recognize Markdown environment-variable tables and parse their cells."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Iterator
7
+
8
+ from hooks_constants.env_var_table_code_drift_constants import (
9
+ ALL_CODE_FILE_EXTENSIONS,
10
+ ALL_GENERIC_ENV_VAR_HEADERS,
11
+ BACKTICK_TOKEN_PATTERN,
12
+ CODE_FENCE_PATTERN,
13
+ ENV_VAR_HEADER_PATTERN,
14
+ ENV_VAR_HEADING_PATTERN,
15
+ ENV_VAR_NAME_PATTERN,
16
+ SEPARATOR_CELL_PATTERN,
17
+ TABLE_ROW_PATTERN,
18
+ )
19
+
20
+
21
+ def _row_cells(table_line: str) -> list[str]:
22
+ """Return the trimmed cells of one markdown table row.
23
+
24
+ Args:
25
+ table_line: A single line that begins with a pipe character.
26
+
27
+ Returns:
28
+ The text of each pipe-delimited cell, stripped, with the empty leading
29
+ and trailing segments a bounding pipe produces removed.
30
+ """
31
+ stripped_line = table_line.strip()
32
+ inner = stripped_line.strip("|")
33
+ return [each_cell.strip() for each_cell in inner.split("|")]
34
+
35
+
36
+ def _first_backtick_token(cell_text: str) -> str | None:
37
+ """Return the first backtick-wrapped token in a cell, when it has one.
38
+
39
+ Args:
40
+ cell_text: The trimmed text of a table cell.
41
+
42
+ Returns:
43
+ The text inside the first pair of backticks, or None when the cell
44
+ carries no backtick-wrapped token.
45
+ """
46
+ token_match = BACKTICK_TOKEN_PATTERN.search(cell_text)
47
+ if token_match is None:
48
+ return None
49
+ inner_text = token_match.group(1).strip()
50
+ return inner_text or None
51
+
52
+
53
+ def _env_var_name_in_cell(cell_text: str) -> str | None:
54
+ """Return the environment-variable name a cell names, when it names one.
55
+
56
+ Args:
57
+ cell_text: The trimmed text of a table cell.
58
+
59
+ Returns:
60
+ The UPPER_SNAKE variable name inside the first backticks, or None when
61
+ the cell names no variable-shaped token.
62
+ """
63
+ token = _first_backtick_token(cell_text)
64
+ if token is None:
65
+ return None
66
+ if ENV_VAR_NAME_PATTERN.match(token) is None:
67
+ return None
68
+ return token
69
+
70
+
71
+ def _code_file_reference_in_cell(cell_text: str) -> str | None:
72
+ """Return the code-file path a cell names, when it names one.
73
+
74
+ Args:
75
+ cell_text: The trimmed text of a table cell.
76
+
77
+ Returns:
78
+ The relative code-file path inside the first backticks, or None when the
79
+ token carries no recognized code-file extension.
80
+ """
81
+ token = _first_backtick_token(cell_text)
82
+ if token is None:
83
+ return None
84
+ _, extension = os.path.splitext(token)
85
+ if extension.lower() not in ALL_CODE_FILE_EXTENSIONS:
86
+ return None
87
+ return token
88
+
89
+
90
+ def _is_separator_row(all_cells: list[str]) -> bool:
91
+ """Return whether every cell is a markdown table header-separator cell.
92
+
93
+ Args:
94
+ all_cells: The trimmed cells of one table row.
95
+
96
+ Returns:
97
+ True when each cell holds only dashes, colons, and whitespace.
98
+ """
99
+ return all(SEPARATOR_CELL_PATTERN.match(each_cell) is not None for each_cell in all_cells)
100
+
101
+
102
+ def iter_env_var_table_rows(content: str) -> Iterator[str]:
103
+ """Yield environment-table rows and headerless edit fragments outside code fences."""
104
+ is_inside_code_fence = False
105
+ is_environment_table: bool | None = None
106
+ is_environment_section = False
107
+ for each_line in content.splitlines():
108
+ if CODE_FENCE_PATTERN.match(each_line) is not None:
109
+ is_inside_code_fence = not is_inside_code_fence
110
+ is_environment_table = None
111
+ continue
112
+ if is_inside_code_fence:
113
+ continue
114
+ if each_line.lstrip().startswith("#"):
115
+ is_environment_section = ENV_VAR_HEADING_PATTERN.search(each_line) is not None
116
+ if TABLE_ROW_PATTERN.match(each_line) is None:
117
+ is_environment_table = None
118
+ continue
119
+ all_cells = _row_cells(each_line)
120
+ if not all_cells or _is_separator_row(all_cells):
121
+ continue
122
+ if is_environment_table is None:
123
+ header = all_cells[0].strip("`* ")
124
+ is_environment_table = (
125
+ ENV_VAR_HEADER_PATTERN.fullmatch(header) is not None
126
+ or _env_var_name_in_cell(all_cells[0]) is not None
127
+ or (is_environment_section and header.casefold() in ALL_GENERIC_ENV_VAR_HEADERS)
128
+ )
129
+ if is_environment_table:
130
+ yield each_line
@@ -18,10 +18,9 @@ if _hooks_directory not in sys.path:
18
18
  sys.path.insert(0, _hooks_directory)
19
19
 
20
20
  from hooks_constants.local_identity import nas_host # noqa: E402
21
- from hooks_constants.pii_prevention_constants import ( # noqa: E402
21
+ from hooks_constants.pii_prevention_constants import (
22
22
  ALL_ALLOWLISTED_PRIVATE_IP_ADDRESSES,
23
23
  ALL_EXACT_LEGAL_NOTICE_BASENAMES,
24
- ALL_HOME_DIRECTORY_PATH_MARKERS,
25
24
  ALL_PLACEHOLDER_HOME_USERNAMES,
26
25
  ALL_REDACTED_PREVIEW_CATEGORIES,
27
26
  ALL_RFC1918_NETWORK_CIDRS,
@@ -37,6 +36,8 @@ from hooks_constants.pii_prevention_constants import ( # noqa: E402
37
36
  CONFTEST_BASENAME,
38
37
  EMAIL_PATTERN,
39
38
  HOME_PATH_PATTERN,
39
+ HOME_USERNAME_MARKER_PATTERN,
40
+ INTERPOLATED_HOME_USERNAME_PATTERN,
40
41
  IPV4_PATTERN,
41
42
  IPV4_VERSION_NUMBER,
42
43
  MAXIMUM_FINDINGS_PER_SCAN,
@@ -90,9 +91,7 @@ def is_path_exempt_from_pii_scan(file_path: str) -> bool:
90
91
  return False
91
92
  normalized_path = file_path.replace("\\", "/").lower()
92
93
  path_for_suffix_match = (
93
- normalized_path
94
- if normalized_path.startswith("/")
95
- else f"/{normalized_path}"
94
+ normalized_path if normalized_path.startswith("/") else f"/{normalized_path}"
96
95
  )
97
96
  basename = os.path.basename(file_path)
98
97
  basename_lower = basename.lower()
@@ -106,9 +105,7 @@ def is_path_exempt_from_pii_scan(file_path: str) -> bool:
106
105
  or basename_lower.endswith(TEST_MODULE_BASENAME_SUFFIX)
107
106
  ):
108
107
  return True
109
- if TESTS_PATH_SEGMENT in normalized_path or normalized_path.startswith(
110
- TESTS_PATH_PREFIX
111
- ):
108
+ if TESTS_PATH_SEGMENT in normalized_path or normalized_path.startswith(TESTS_PATH_PREFIX):
112
109
  return True
113
110
  if basename_lower.endswith(ALL_SOURCE_TEST_FILE_SUFFIXES) and (
114
111
  SPEC_BASENAME_MARKER in basename_lower or TEST_BASENAME_MARKER in basename_lower
@@ -130,11 +127,7 @@ def _redact_sensitive_preview(matched_text: str) -> str:
130
127
  return REDACTED_SHORT_PREVIEW
131
128
  prefix_length = REDACTED_PREVIEW_PREFIX_LENGTH
132
129
  suffix_length = REDACTED_PREVIEW_SUFFIX_LENGTH
133
- return (
134
- matched_text[:prefix_length]
135
- + REDACTED_PREVIEW_ELLIPSIS
136
- + matched_text[-suffix_length:]
137
- )
130
+ return matched_text[:prefix_length] + REDACTED_PREVIEW_ELLIPSIS + matched_text[-suffix_length:]
138
131
 
139
132
 
140
133
  def _build_preview(matched_text: str, category: str) -> str:
@@ -161,8 +154,7 @@ def _is_safe_email_domain(domain_name: str) -> bool:
161
154
  if domain_name in ALL_SAFE_EMAIL_DOMAINS:
162
155
  return True
163
156
  return any(
164
- domain_name.endswith("." + each_safe_domain)
165
- for each_safe_domain in ALL_SAFE_EMAIL_DOMAINS
157
+ domain_name.endswith("." + each_safe_domain) for each_safe_domain in ALL_SAFE_EMAIL_DOMAINS
166
158
  )
167
159
 
168
160
 
@@ -180,8 +172,8 @@ def _home_username_offset(matched_path: str) -> tuple[int, str] | None:
180
172
 
181
173
  C:\\Users\\Users\\file.txt -> (9, "Users") the second Users, not the first
182
174
 
183
- Replacing backslashes with forward slashes preserves length, so the marker
184
- offset computed on the normalized path indexes *matched_path* directly.
175
+ The marker match consumes repeated separators, so the username starts after
176
+ both ordinary and escaped Windows path separators.
185
177
 
186
178
  Args:
187
179
  matched_path: The matched home-path substring.
@@ -189,18 +181,14 @@ def _home_username_offset(matched_path: str) -> tuple[int, str] | None:
189
181
  Returns:
190
182
  The username's start index and value, or None when no marker is found.
191
183
  """
192
- normalized_path = matched_path.replace("\\", "/")
193
- lowered_path = normalized_path.lower()
194
- for each_marker in ALL_HOME_DIRECTORY_PATH_MARKERS:
195
- marker_index = lowered_path.find(each_marker)
196
- if marker_index < 0:
197
- continue
198
- username_start = marker_index + len(each_marker)
199
- username = normalized_path[username_start:].split("/", 1)[0]
200
- if not username:
201
- return None
202
- return username_start, username
203
- return None
184
+ marker = HOME_USERNAME_MARKER_PATTERN.search(matched_path)
185
+ if marker is None:
186
+ return None
187
+ username_start = marker.end()
188
+ username = matched_path[username_start:].replace("\\", "/").split("/", 1)[0]
189
+ if not username:
190
+ return None
191
+ return username_start, username
204
192
 
205
193
 
206
194
  def _username_from_home_path(matched_path: str) -> str | None:
@@ -226,6 +214,8 @@ def _redact_home_path_username(matched_text: str) -> str:
226
214
 
227
215
 
228
216
  def _is_placeholder_home_username(username: str) -> bool:
217
+ if INTERPOLATED_HOME_USERNAME_PATTERN.fullmatch(username) is not None:
218
+ return True
229
219
  if ANGLE_BRACKET_PLACEHOLDER_PATTERN.match(username) is not None:
230
220
  return True
231
221
  lowered_username = username.lower()
@@ -125,9 +125,10 @@ def test_production_entrypoint_denies_drifted_table(tmp_path: Path) -> None:
125
125
  hook_specific_output = parsed_payload["hookSpecificOutput"]
126
126
  assert completed.returncode == 0, completed.stderr
127
127
  assert hook_specific_output["permissionDecision"] == "deny"
128
- assert "GOOGLE_APPLICATION_CREDENTIALS -> auth/google_auth.py" in hook_specific_output[
129
- "permissionDecisionReason"
130
- ]
128
+ assert (
129
+ "GOOGLE_APPLICATION_CREDENTIALS -> auth/google_auth.py"
130
+ in hook_specific_output["permissionDecisionReason"]
131
+ )
131
132
  assert_hook_deny_log_contains(tmp_path, "env_var_table_code_drift_blocker.py")
132
133
 
133
134
 
@@ -159,3 +160,80 @@ def test_ignores_row_whose_second_cell_is_not_a_code_file(tmp_path: Path) -> Non
159
160
  "| `SOME_FLAG` | `enables the thing` | off |\n"
160
161
  )
161
162
  assert find_drift_rows(content, doc_path.parent) == []
163
+
164
+
165
+ def test_constant_tables_do_not_claim_environment_consumers(tmp_path: Path) -> None:
166
+ _anchor_repo_root(tmp_path)
167
+ _write(tmp_path / "workflow.py", "def run():\n return\n")
168
+ content = (
169
+ "| Symbol | Imported by |\n"
170
+ "| --- | --- |\n"
171
+ "| `PROCESSING_VISIBLE` | `workflow.py` |\n\n"
172
+ "| Component | Location | Purpose |\n"
173
+ "| --- | --- | --- |\n"
174
+ "| `ACCOUNT_LINK_PRIMARY` | `workflow.py` | Selector |\n\n"
175
+ "| Constant | Value | Source |\n"
176
+ "| --- | --- | --- |\n"
177
+ "| `UPLOAD_DELAY_SECONDS` | 5 | `workflow.py` |\n"
178
+ )
179
+ assert find_drift_rows(content, tmp_path) == []
180
+
181
+
182
+ def test_environment_table_after_constants_still_reports_drift(tmp_path: Path) -> None:
183
+ _anchor_repo_root(tmp_path)
184
+ _write(tmp_path / "workflow.py", "def run():\n return\n")
185
+ content = (
186
+ "| Constant | Source |\n"
187
+ "| --- | --- |\n"
188
+ "| `UPLOAD_DELAY_SECONDS` | `workflow.py` |\n\n"
189
+ "| Environment variable | Consumer |\n"
190
+ "| --- | --- |\n"
191
+ "| `UPLOAD_TOKEN` | `workflow.py` |\n"
192
+ )
193
+ assert find_drift_rows(content, tmp_path) == ["UPLOAD_TOKEN -> workflow.py"]
194
+
195
+
196
+ def test_environment_section_supports_name_header(tmp_path: Path) -> None:
197
+ _anchor_repo_root(tmp_path)
198
+ _write(tmp_path / "workflow.py", "def run():\n return\n")
199
+ content = (
200
+ "## Environment variables\n\n"
201
+ "| Name | Consumer |\n"
202
+ "| --- | --- |\n"
203
+ "| `UPLOAD_TOKEN` | `workflow.py` |\n"
204
+ )
205
+ assert find_drift_rows(content, tmp_path) == ["UPLOAD_TOKEN -> workflow.py"]
206
+
207
+
208
+ def test_headerless_edit_fragment_still_reports_drift(tmp_path: Path) -> None:
209
+ _anchor_repo_root(tmp_path)
210
+ _write(tmp_path / "workflow.py", "def run():\n return\n")
211
+ content = "| `UPLOAD_TOKEN` | `workflow.py` |\n"
212
+ assert find_drift_rows(content, tmp_path) == ["UPLOAD_TOKEN -> workflow.py"]
213
+
214
+
215
+ def test_descriptive_environment_header_reports_drift(tmp_path: Path) -> None:
216
+ _anchor_repo_root(tmp_path)
217
+ _write(tmp_path / "workflow.py", "def run():\n return\n")
218
+ content = (
219
+ "| Environment Variable Name | Consumer |\n"
220
+ "| --- | --- |\n"
221
+ "| `UPLOAD_TOKEN` | `workflow.py` |\n"
222
+ )
223
+ assert find_drift_rows(content, tmp_path) == ["UPLOAD_TOKEN -> workflow.py"]
224
+
225
+
226
+ def test_explicit_source_header_overrides_environment_section(tmp_path: Path) -> None:
227
+ _anchor_repo_root(tmp_path)
228
+ _write(tmp_path / "workflow.py", "def run():\n return\n")
229
+ content = (
230
+ "## Environment variables\n\n"
231
+ "| Name | Consumer |\n"
232
+ "| --- | --- |\n"
233
+ "| `UPLOAD_TOKEN` | `workflow.py` |\n\n"
234
+ "The source also defines a delay constant.\n\n"
235
+ "| Constant | Source |\n"
236
+ "| --- | --- |\n"
237
+ "| `UPLOAD_DELAY_SECONDS` | `workflow.py` |\n"
238
+ )
239
+ assert find_drift_rows(content, tmp_path) == ["UPLOAD_TOKEN -> workflow.py"]
@@ -0,0 +1,100 @@
1
+ """Tests for environment-variable table row recognition.
2
+
3
+ Each test drives ``iter_env_var_table_rows`` against markdown text and asserts
4
+ the exact rows it yields. The behavior under test is table context: which
5
+ markdown tables read as environment-variable summaries, and which read as
6
+ source, constant, or component tables that name no environment consumer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from env_var_table_rows import iter_env_var_table_rows
12
+
13
+
14
+ def test_environment_variable_header_yields_data_rows() -> None:
15
+ content = (
16
+ "| Environment variable | Consumer |\n| --- | --- |\n| `UPLOAD_TOKEN` | `workflow.py` |\n"
17
+ )
18
+ assert list(iter_env_var_table_rows(content)) == [
19
+ "| Environment variable | Consumer |",
20
+ "| `UPLOAD_TOKEN` | `workflow.py` |",
21
+ ]
22
+
23
+
24
+ def test_constant_table_yields_nothing() -> None:
25
+ content = (
26
+ "| Constant | Value | Source |\n"
27
+ "| --- | --- | --- |\n"
28
+ "| `UPLOAD_DELAY_SECONDS` | 5 | `workflow.py` |\n"
29
+ )
30
+ assert list(iter_env_var_table_rows(content)) == []
31
+
32
+
33
+ def test_component_table_yields_nothing() -> None:
34
+ content = (
35
+ "| Component | Location | Purpose |\n"
36
+ "| --- | --- | --- |\n"
37
+ "| `ACCOUNT_LINK_PRIMARY` | `workflow.py` | Selector |\n"
38
+ )
39
+ assert list(iter_env_var_table_rows(content)) == []
40
+
41
+
42
+ def test_generic_name_header_under_environment_heading_yields_rows() -> None:
43
+ content = (
44
+ "## Environment variables\n\n"
45
+ "| Name | Consumer |\n"
46
+ "| --- | --- |\n"
47
+ "| `UPLOAD_TOKEN` | `workflow.py` |\n"
48
+ )
49
+ assert list(iter_env_var_table_rows(content)) == [
50
+ "| Name | Consumer |",
51
+ "| `UPLOAD_TOKEN` | `workflow.py` |",
52
+ ]
53
+
54
+
55
+ def test_generic_name_header_without_environment_heading_yields_nothing() -> None:
56
+ content = (
57
+ "## Selectors\n\n"
58
+ "| Name | Consumer |\n"
59
+ "| --- | --- |\n"
60
+ "| `ACCOUNT_LINK_PRIMARY` | `workflow.py` |\n"
61
+ )
62
+ assert list(iter_env_var_table_rows(content)) == []
63
+
64
+
65
+ def test_headerless_fragment_naming_a_variable_yields_the_row() -> None:
66
+ content = "| `UPLOAD_TOKEN` | `workflow.py` |\n"
67
+ assert list(iter_env_var_table_rows(content)) == ["| `UPLOAD_TOKEN` | `workflow.py` |"]
68
+
69
+
70
+ def test_rows_inside_a_code_fence_yield_nothing() -> None:
71
+ content = (
72
+ "```markdown\n"
73
+ "| Environment variable | Consumer |\n"
74
+ "| --- | --- |\n"
75
+ "| `UPLOAD_TOKEN` | `workflow.py` |\n"
76
+ "```\n"
77
+ )
78
+ assert list(iter_env_var_table_rows(content)) == []
79
+
80
+
81
+ def test_constant_table_after_an_environment_table_yields_nothing_extra() -> None:
82
+ content = (
83
+ "| Environment variable | Consumer |\n"
84
+ "| --- | --- |\n"
85
+ "| `UPLOAD_TOKEN` | `workflow.py` |\n\n"
86
+ "| Constant | Source |\n"
87
+ "| --- | --- |\n"
88
+ "| `UPLOAD_DELAY_SECONDS` | `workflow.py` |\n"
89
+ )
90
+ assert list(iter_env_var_table_rows(content)) == [
91
+ "| Environment variable | Consumer |",
92
+ "| `UPLOAD_TOKEN` | `workflow.py` |",
93
+ ]
94
+
95
+
96
+ def test_separator_row_is_never_yielded() -> None:
97
+ content = (
98
+ "| Environment variable | Consumer |\n| :--- | ---: |\n| `UPLOAD_TOKEN` | `workflow.py` |\n"
99
+ )
100
+ assert "| :--- | ---: |" not in list(iter_env_var_table_rows(content))