Skip to content

Commit a0ab177

Browse files
authored
Merge pull request #168 from codellm-devkit/feat/issue-165-deployment-env
feat: deployment-env config keys — Dockerfile ENV/ARG, compose and k8s env
2 parents ebc67dc + 097dcc2 commit a0ab177

9 files changed

Lines changed: 656 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3535
Unresolved reads (non-literal keys, undefined keys) emitted in
3636
`application.config_reads_unresolved` with reasons; `PY_READS_CONFIG_UNRESOLVED`
3737
Neo4j projection (#162).
38+
- Deployment-env config-key namespaces: Dockerfile `ENV`/`ARG` directives
39+
(multi-key, legacy space form, backslash continuations, quoted values) and
40+
compose/k8s `environment`/`env` shapes are now extracted too. `ENV` and
41+
compose/k8s `environment`/`env` entries mint namespace `env`, so
42+
`os.environ`/`os.getenv` reads resolve against deployment-declared
43+
variables; Dockerfile `ARG` mints its own `dockerfile` namespace
44+
(build-time only, not env-detector-bindable). Compose/k8s recognition
45+
dual-mints alongside the plain `yaml` dotted-path keys, by design (#165).
3846

3947
## [1.2.0] - 2026-08-26
4048

codeanalyzer/artifacts/config_keys.py

Lines changed: 296 additions & 41 deletions
Large diffs are not rendered by default.

codeanalyzer/schema/py_schema.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,7 @@ class PyConfigKey(BaseModel):
492492

493493
id: str = "" # <artifact-id>@key/<dotted.key>
494494
key: str # dotted path; numeric segments for arrays, e.g. "services.web.ports.0"
495-
namespace: str # env|yaml|json|toml|ini|properties
495+
namespace: str # env|yaml|json|toml|ini|properties|dockerfile
496496
value: Optional[str] = None # populated only when options.artifact_text is on
497497
span: Optional[Span] = None # into the artifact's source; best-effort for yaml/json/toml
498498
references: List[str] = [] # raw recognized tokens, order of appearance, deduplicated

docs/design/specs/2026-08-28-config-key-family-design.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,18 @@ reading a key) is the recorded follow-up.
2525
references are always extracted — `--no-artifact-text` drops values and
2626
source together, so the secret off-switch actually switches everything off.
2727
3. **V1 formats**: `env` (`.env`, `.env.*`, `.flaskenv`), `yaml`, `json`,
28-
`toml`, `ini`, `properties`. Extraction is format-driven over existing
29-
artifacts (pyproject.toml gets keys too; overlap with dependency records is
30-
harmless). `references[]` v1 recognizes three syntaxes, recorded as raw
31-
tokens: `${VAR}`/`$VAR`, `%(name)s`, `${{ ... }}`.
28+
`toml`, `ini`, `properties`, `dockerfile`. Extraction is format-driven over
29+
existing artifacts (pyproject.toml gets keys too; overlap with dependency
30+
records is harmless). `references[]` v1 recognizes three syntaxes, recorded
31+
as raw tokens: `${VAR}`/`$VAR`, `%(name)s`, `${{ ... }}`. Deployment-env
32+
namespaces (issue #165, a later extension of this same machinery): a
33+
`dockerfile`-format artifact's `ENV` directives mint namespace `env` (so
34+
`os.environ`/`os.getenv` reads bind to them) while its `ARG` directives
35+
mint namespace `dockerfile` (build-time only, not env-detector-bindable);
36+
a `yaml`-format artifact
37+
additionally dual-mints namespace `env` keys for recognized compose
38+
(`services.*.environment`) and k8s (`...env[].name`/`.value`) shapes,
39+
alongside the plain dotted-path `yaml` mint of the same leaves.
3240
4. **Placement: nested.** `PyArtifact.config_keys: List[PyConfigKey]`
3341
containment mirrors `DEFINES_CONFIG`; L1 data, identical at every level.
3442
5. **Overlay posture.** Parse failure never drops the artifact node; it sets
@@ -43,7 +51,7 @@ reading a key) is the recorded follow-up.
4351
| --- | --- | --- |
4452
| `id` | str | `<artifact-id>@key/<dotted.key>` |
4553
| `key` | str | dotted path; numeric segments for arrays (`services.web.ports.0`) |
46-
| `namespace` | str | `env` \| `yaml` \| `json` \| `toml` \| `ini` \| `properties` |
54+
| `namespace` | str | `env` \| `yaml` \| `json` \| `toml` \| `ini` \| `properties` \| `dockerfile` |
4755
| `value` | Optional[str] | only when text capture on |
4856
| `span` | Span | into the artifact's source |
4957
| `references` | List[str] | raw recognized tokens |
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
11
FROM python:3.12-slim
2+
3+
ARG BUILD_REV
4+
ENV APP_MODE=production
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
services:
22
web:
33
build: .
4+
environment:
5+
COMPOSE_ONLY_KEY: x

test/fixtures/whole_applications/manifests_app/pkg/config_reader.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Config-use e2e fixture for all five detection shapes (#162 Task 5)."""
1+
"""Config-use e2e fixture for all six detection shapes (#162 Task 5, #165)."""
22
import os
33

44

@@ -38,3 +38,9 @@ def get_config_multi_def(use_debug):
3838
# Should appear in config_reads_unresolved with reason: "undefined-key"
3939
def get_missing_config():
4040
return os.getenv("NOT_DEFINED_ANYWHERE")
41+
42+
43+
# Shape 6: Deployment-env, reads APP_MODE from the Dockerfile ENV directive
44+
# (#165). Direct literal, resolves at -a 2 like shape 1.
45+
def get_app_mode():
46+
return os.getenv("APP_MODE")

test/test_artifacts_end_to_end.py

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -205,20 +205,26 @@ def test_config_keys_extraction_level(tmp_path):
205205
)).analyze().application
206206

207207
# Config keys should be identical at both levels
208-
for art_name in [".env", "config/settings.yml", "app.properties"]:
208+
for art_name in [
209+
".env", "config/settings.yml", "app.properties",
210+
"Dockerfile", "docker-compose.yml", # deployment-env namespaces (#165)
211+
]:
209212
l1_keys = sorted([k.key for k in app_l1.artifacts[art_name].config_keys])
210213
l4_keys = sorted([k.key for k in app_l4.artifacts[art_name].config_keys])
211214
assert l1_keys == l4_keys
212215

213216

214217
def test_config_keys_extraction_full(tmp_path):
215-
"""Verify extraction='full' on the three config files."""
218+
"""Verify extraction='full' on the config files, including Dockerfile
219+
(newly namespace-eligible as of #165 -- it was "none" before, since
220+
`is_config_eligible` used to skip it outright)."""
216221
app = _app(tmp_path, "extraction_test")
217222

218-
# All three new config files should have extraction='full'
219223
assert app.artifacts[".env"].extraction == "full"
220224
assert app.artifacts["config/settings.yml"].extraction == "full"
221225
assert app.artifacts["app.properties"].extraction == "full"
226+
assert app.artifacts["Dockerfile"].extraction == "full"
227+
assert app.artifacts["docker-compose.yml"].extraction == "full"
222228

223229

224230
def _id_suffix(id_: str) -> str:
@@ -229,7 +235,8 @@ def _id_suffix(id_: str) -> str:
229235

230236

231237
def test_config_uses_full(tmp_path):
232-
"""Verify config-use edges at -a 4: all five fixture shapes (#162 Task 5).
238+
"""Verify config-use edges at -a 4: all six fixture shapes (#162 Task 5,
239+
#165 shape 6).
233240
234241
Exact-set assertions (the plan's "exact", not a `>=` lower bound): every
235242
`config_uses` edge as `(src-suffix, dst-key, prov)` and every
@@ -238,7 +245,8 @@ def test_config_uses_full(tmp_path):
238245
against the dataflow-tier fixes landed alongside this test -- none of
239246
those fixes' shapes (aliasing, conditional shadowing, module-scope
240247
callers) occur in this fixture, so the counts are unchanged from the
241-
pre-fix reviewer probe: 3 resolved, 2 unresolved at -a 4).
248+
pre-fix reviewer probe except for #165's own addition: 4 resolved, 2
249+
unresolved at -a 4).
242250
"""
243251
app = Codeanalyzer(AnalysisOptions(
244252
input=FIXTURE, analysis_level=4, no_venv=True, cache_dir=tmp_path / "config_uses",
@@ -250,11 +258,14 @@ def test_config_uses_full(tmp_path):
250258
# (site is the read INSIDE _read_config, not get_secret_token's call)
251259
# 4. Multi-def unresolved: get_config_multi_def() -> unresolved (two defs)
252260
# 5. Undefined-key: get_missing_config() -> unresolved (key not in .env)
261+
# 6. Deployment-env literal: get_app_mode() -> os.getenv("APP_MODE"),
262+
# binding to the Dockerfile `ENV APP_MODE=production` key (#165).
253263
uses = {(_id_suffix(e.src), _id_suffix(e.dst), tuple(e.prov)) for e in app.config_uses}
254264
assert uses == {
255265
("get_database_url()@7:11", "DATABASE_URL", ("literal",)),
256266
("get_api_key()@14:11", "API_KEY", ("dataflow",)),
257267
("_read_config(name)@20:11", "SECRET_API_TOKEN", ("dataflow",)),
268+
("get_app_mode()@46:11", "APP_MODE", ("literal",)),
258269
}
259270

260271
unresolved = {(_id_suffix(r.site), r.reason, r.key) for r in app.config_reads_unresolved}
@@ -264,6 +275,40 @@ def test_config_uses_full(tmp_path):
264275
}
265276

266277

278+
def test_config_uses_app_mode_resolves_at_l2(tmp_path):
279+
"""The deployment-env DoD, verified directly (#165): `get_app_mode()`'s
280+
`os.getenv("APP_MODE")` binds to the Dockerfile `ENV APP_MODE=production`
281+
key at `-a 2` already -- a direct literal, same tier as `DATABASE_URL`,
282+
with no dataflow tier needed."""
283+
app = Codeanalyzer(AnalysisOptions(
284+
input=FIXTURE, analysis_level=2, no_venv=True, cache_dir=tmp_path / "app_mode_l2",
285+
)).analyze().application
286+
uses = {(_id_suffix(e.src), _id_suffix(e.dst), tuple(e.prov)) for e in app.config_uses}
287+
assert ("get_app_mode()@46:11", "APP_MODE", ("literal",)) in uses
288+
289+
290+
def test_config_keys_deployment_env(tmp_path):
291+
"""Verify Dockerfile ENV/ARG and compose environment-map config keys,
292+
and their dual-mint into namespace "env" alongside the plain namespace
293+
"yaml" dotted path (#165)."""
294+
app = _app(tmp_path, "deployment_env_test")
295+
296+
df_keys = {(k.key, k.namespace, k.value) for k in app.artifacts["Dockerfile"].config_keys}
297+
assert df_keys == {
298+
("APP_MODE", "env", "production"),
299+
("BUILD_REV", "dockerfile", None),
300+
}
301+
302+
compose_keys = {(k.key, k.namespace, k.value) for k in app.artifacts["docker-compose.yml"].config_keys}
303+
assert compose_keys == {
304+
("services.web.build", "yaml", "."),
305+
("services.web.environment.COMPOSE_ONLY_KEY", "yaml", "x"),
306+
("COMPOSE_ONLY_KEY", "env", "x"), # dual-mint alongside the dotted yaml key
307+
}
308+
by = {k.key: k for k in app.artifacts["docker-compose.yml"].config_keys}
309+
assert by["COMPOSE_ONLY_KEY"].id != by["services.web.environment.COMPOSE_ONLY_KEY"].id
310+
311+
267312
def test_config_uses_tier_visibility(tmp_path):
268313
"""Verify config-use edges only appear at their appropriate tier (#162):
269314
monotonic growth AND that each tier's own shape is genuinely gated, not

0 commit comments

Comments
 (0)