Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ sha2 = "0.11.0"
thiserror = "2.0.18"

[patch.crates-io]
makefile-lossless = { git = "https://github.com/leynos/makefile-lossless.git", rev = "8dd35801b75b332c2ac2f995ae398ef8238559fa" }
makefile-lossless = { git = "https://github.com/leynos/makefile-lossless.git", rev = "2ae7134beb04416851ab18c8a5d5893348fbe26c" }

[dev-dependencies]
assert_cmd = "2.2.2"
Expand Down
139 changes: 139 additions & 0 deletions docs/adrs/0002-bare-export-directive-representation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# ADR-0002: Represent bare `export` directives as valueless variable facts

## Status

Accepted

## Date

2026-08-13

## Context and Problem Statement

GNU Make's `export` keyword serves two roles. It can modify a definition, as in
`export FOO := bar`, or stand alone as a directive naming variables assigned
elsewhere, as in `export FOO BAR`. The directive form carries no assignment
operator and no value.

`makeutil` originally treated an absent assignment operator as a broken syntax
tree and returned a missing-field error, which surfaced as a fatal
`parse-internal` message and exit code 2. Because facts are collected before
diagnostics, one such line destroyed the report for the whole file: every rule,
variable, and include was lost. Downstream consumers therefore recorded an
operational error for an entire repository rather than a set of facts with one
gap. The directive form is common in real Makefiles, so this was a blocking
defect rather than an edge case.

Schema version 1 sets `"additionalProperties": false` at every level, and
consumers pin `makeutil` by commit SHA and validate reports against the
published schema. Any new key is therefore a breaking change requiring a schema
version 2 and a coordinated re-pin by every consumer.

Consumers also fail closed on any `parse.status` other than `complete`, so a
representation that forces `recovered` leaves them just as blocked as an abort
did.

## Decision

A bare `export NAME` directive is reported as an entry in the existing
`variables` array, using the operator enum's existing empty-string variant,
with an empty `raw_value`, `exported` true, and `define_block` false. A
directive naming several variables yields one entry per name, each carrying the
span of the whole directive.

The discriminating predicate for consumers is
`operator == "" && define_block == false`, which identifies an export directive
rather than an assignment. The empty operator is shared with `define` blocks,
which is why `define_block` is part of the predicate.

Neither `schema_version` nor `schemas/makeutil.parse.v1.schema.json` changes,
because the empty operator was already in the enum for `define` blocks and no
new key or enum member is introduced.

A line the parser can name nothing on — a bare `export` with no names, which
means "export every variable", `export define NAME`, or a line whose only name
is one the parser treats as a keyword — yields no entry rather than an invented
one, together with a diagnostic of `makeutil`'s own. The diagnostic is emitted
rather than relying on the parser to emit one, because the parser does not
always do so: `override export override` is dropped upstream without any error.
Without it such a line would leave a report claiming `complete` with the
construct silently missing, which the honesty rule forbids.

The names on a directive line are read from the definition node's identifier
tokens, anchored on the name the parser itself reports rather than by skipping
identifiers whose text matches a directive keyword. A variable may legitimately
be called `unexport`, and keyword-text filtering would silently discard it.

`unexport` remains unrepresented and is documented as a known gap.

## Alternatives considered

### Add a new top-level `exports` array

Rejected. This is the most honest representation, but `additionalProperties` is
`false` on the root object, so every consumer validating a report against the
version 1 schema would reject reports containing it. That is a breaking change
requiring a schema version 2 and a coordinated re-pin by every consumer —
disproportionate to a crash fix, and it would leave consumers blocked until
they moved.

### Record the directive only as a recoverable diagnostic

Rejected. It stops the abort but forces `status: "recovered"`, and consumers
fail closed on any status other than `complete`. Every Makefile containing a
bare export would remain effectively unparsable downstream, which fails the
purpose of the change.

### Add a new operator enum variant for the directive form

Rejected. The operator enum is a closed set in the version 1 schema, so a new
member is as breaking as a new key.

## Consequences

### Positive

- No form of `export` or `unexport` aborts the parse, so one directive line can
no longer destroy the report for a whole file.
- A Makefile that assigns variables and then exports them by name reports
`complete` with exit code 0, unblocking consumers immediately.
- No schema file changes and no consumer re-pin is required beyond moving to a
commit that carries the fix.

### Negative

- Conflation: a consumer treating every entry in `variables` as an assignment
now sees extra entries for export directives, and a name may appear twice —
once for its assignment and once for the directive that exports it. This is
mitigated by the documented predicate in the users' guide and pinned by a
test.
- The representation cannot express "export every variable", so that form is
reported as an absence plus a diagnostic rather than as a fact.
- Supporting the multi-name form required a change to the parser fork and a
revision bump of the `[patch.crates-io]` pin, so this decision is not
confined to `makeutil` after all. The published `parser_version` is
unaffected.
- A variable whose name is `export`, `override` or `define` is unrepresentable,
because the parser's name accessor skips those texts. The adapter preserves
every nameable fact and emits a recoverable diagnostic for the omitted name,
so a report never claims `complete` while silently omitting that form.

### Neutral

- A future schema version 2 could introduce a dedicated `exports` array and an
explicit un-export representation. This decision does not preclude it; it
defers it until a schema break is warranted on its own merits.

## Acceptance criteria

1. A Makefile that assigns variables and then exports one of them by name
reports `complete` with exit code 0, and every rule and assignment in the
file is present.
2. No form of `export` or `unexport` produces a `parse-internal` message or
exit code 2.
3. Bare export directives are distinguishable from assignments by
`operator == "" && define_block == false`.
4. `schemas/makeutil.parse.v1.schema.json` is unchanged and every new fixture's
report validates against it.
5. An operator-less definition that is neither a `define` nor an `export` still
fails loudly, so genuinely broken trees are not masked.
4 changes: 4 additions & 0 deletions docs/contents.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,14 @@ set.
architecture, security boundaries, and verification strategy.
- [ADR-0001: Parse one GNU Makefile into versioned JSON facts](adrs/0001-single-file-gnu-make-parse.md)
records the first-slice boundary, accepted on 2026-07-13.
- [ADR-0002: Represent bare `export` directives as valueless variable facts](adrs/0002-bare-export-directive-representation.md)
records the schema-preserving representation, accepted on 2026-08-13.
- [Execution plans](execplans/) describe approved, milestone-oriented delivery
work:
- [Implement ADR-0001](execplans/adr-0001-single-file-gnu-make-parse.md)
plans the single-file GNU Make parser and its verification.
- [Parse bare export directives](execplans/bare-export-directives.md) plans
the removal of the fatal abort on `export` and `unexport` directives.

## Rust reference material

Expand Down
87 changes: 80 additions & 7 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,13 @@ rewriting, and bindings remain later decisions.
The implementation uses
[`makefile-lossless`](https://github.com/jelmer/makefile-lossless), initially
pinned to `=0.3.40`. A temporary `[patch.crates-io]` override selects commit
`8dd35801b75b332c2ac2f995ae398ef8238559fa` from a project-maintained fork
because release 0.3.40 does not lex the documented GNU Make `!=` assignment
operator. Remove the override when an upstream release containing the fix is
adopted; do not replace the immutable commit with a branch name.
`2ae7134beb04416851ab18c8a5d5893348fbe26c` from a project-maintained fork,
which carries two fixes absent from release 0.3.40: lexing the documented GNU
Make `!=` assignment operator, and retaining every name of a multi-name
`export A B C` directive within the definition node. Remove the override when
an upstream release containing both is adopted; do not replace the immutable
commit with a branch name. The published version string stays `0.3.40`, so
`parser_version` is unaffected by a revision bump.

The crate supplies:

Expand Down Expand Up @@ -314,9 +317,79 @@ for the first bounded rules.
```

The schema-v1 operator set is closed: `""`, `"="`, `":="`, `"::="`, `":::="`,
`"+="`, `"?="`, and `"!="`. The empty string means a `define` block without an
assignment token. The operator remains source-faithful; the first slice does
not calculate the effective value or precedence.
`"+="`, `"?="`, and `"!="`. The empty string means a definition without an
assignment token: either a `define` block or a bare `export` directive. The
operator remains source-faithful; the first slice does not calculate the
effective value or precedence.

#### 6.6.1. Bare `export` directives

A bare `export NAME` names a variable assigned elsewhere and carries no
operator and no value. It is represented as an entry in `variables` with the
empty operator, an empty `raw_value`, `exported` true, and `define_block`
false. The discriminating predicate for consumers is
`operator == "" && define_block == false`.

A directive naming several variables yields one entry per name, sharing the
span of the whole directive. This needed a parser change: the previously pinned
revision trapped the second name in an error node and pushed the rest out of
the definition node entirely, so `makeutil` could not recover them. A form the
parser cannot name at all — a bare `export`, `export define NAME`, or a name
whose text the parser treats as one of its own keywords — yields no entry
rather than an invented one, together with a diagnostic that keeps the report
`recovered` rather than falsely `complete`. That diagnostic is `makeutil`'s own
rather than the parser's, because the parser does not always emit one:
`override export override` is dropped upstream silently.

The names on a directive line are read from the definition node's identifier
tokens, anchored on the name the parser itself reports. Keyword text cannot be
used to skip the directive prefix, because a variable may legitimately be called
`unexport`. Names the parser itself treats as keywords — `export`, `override`
and `define` — remain unrepresentable, because it reports no name for them. For
a line such as `export export FOO`, the adapter retains the nameable `FOO` fact
and reports `recovered` with a diagnostic for the omitted name. That keeps the
report honest without redefining what the parser considers a name.

The alternative — a new top-level `exports` array — is more honest but the
schema sets `"additionalProperties": false` at every level, so it would be a
breaking change requiring schema version 2 and a coordinated re-pin by every
consumer. Recording the directive only as a diagnostic was also rejected,
because consumers fail closed on any status other than `complete` and every
Makefile with a bare export would remain effectively unparsable. The accepted
cost is conflation: a consumer treating every entry in `variables` as an
assignment sees extra entries for export directives. See
[ADR-0002](adrs/0002-bare-export-directive-representation.md) for the full
decision and
[the bare export execution plan](execplans/bare-export-directives.md) for the
delivery record.

An `unexport` directive remains unrepresented: it parses as a rule whose first
target is `unexport` and forces a `recovered` status. Expressing an explicit
un-export needs a field that schema version 1 does not have, so support is
deferred to a future schema version.

#### 6.6.2. Note for consumers pinning `makeutil`

Downstream repositories pin `makeutil` by commit SHA, so this change reaches
them only when they move their pin. Consumers moving to a commit on or after
the merge of the `bare-export-directives` work should know that:

- A bare `export NAME` directive no longer aborts the parse. Before the change
a single such line produced no JSON at all and exit code 2, discarding every
fact in the file; it now appears in `variables` with an empty `operator`.
- Reports may therefore contain more `variables` entries than before, and a
name may appear twice — once for its assignment and once for the directive
that exports it. Identify bare-export facts with
`operator == "" && define_block == false`; do not use a non-empty `operator`
filter, because `define` blocks also serialize with an empty operator.
- `unexport` remains unsupported and still reports a misleading rule with a
`recovered` status.
- A multi-name `export A B C` reports `complete` with one entry per name. This
required a parser revision bump, so the `[patch.crates-io]` commit differs
from earlier builds; the published `parser_version` is unchanged at `0.3.40`.
- `schema_version` is unchanged at `1`, and
`schemas/makeutil.parse.v1.schema.json` is byte-identical, so no schema
re-validation work is required.

### 6.7. Include facts

Expand Down
32 changes: 25 additions & 7 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,25 @@ not pass upstream strings beyond the adapter.
`AssignmentOperator` is the shared, closed domain and parser-port
representation for schema-v1 variable operators. The parser adapter is its only
producer; `SyntaxObservation` and report types are its permitted consumers. Its
`Define` variant serializes as an empty string and means a `define` block
without an assignment token. Extend the enum only through a schema-versioned
contract decision, and do not pass upstream operator strings beyond the adapter.
`Define` variant serializes as an empty string and means a definition without
an assignment token: either a `define` block or a bare `export` directive, told
apart by the `define_block` flag. Extend the enum only through a
schema-versioned contract decision, and do not pass upstream operator strings
beyond the adapter.

The private `makefile_export` helpers own translation of operator-less export
definitions into variable observations. Before extraction, a repository sweep
found no equivalent directive-expansion helper: `variable_observation` was the
sole variable translator and produced one observation. In production,
`variable_observation` is the only permitted caller of `assignment_operator` and
`export_directive_observations`; `export_directive_observations` alone may call
`directive_names`. Focused unit tests may exercise each helper directly.
Compose the helpers only while translating one upstream `VariableDefinition`:
use `assignment_operator` for ordinary variable facts, and use
`export_directive_observations` only for an operator-less export so it can emit
zero or more directive facts with the shared directive span. They are
adapter-private mechanics, not domain ports, general directive parsers, or
reusable CST walkers.

The makefile adapter privately scans leading recipe modifiers. This scanner
exists because the upstream API has no always-execute accessor and its silent
Expand Down Expand Up @@ -90,10 +106,12 @@ cannot forge another physical line; it is not a general path normalizer or JSON
encoder.

The exact 0.3.40 parser requirement is temporarily patched to immutable fork
commit `8dd35801b75b332c2ac2f995ae398ef8238559fa`, which adds `!=` lexer
support. Keep the commit pin reproducible. When upgrading to an upstream
release that contains the fix, remove the `[patch.crates-io]` entry and rerun
the complete assignment-operator contract matrix before updating the lockfile.
commit `2ae7134beb04416851ab18c8a5d5893348fbe26c`, which adds `!=` lexer
support and retains every name of a multi-name `export A B C` directive inside
the definition node. Keep the commit pin reproducible. When upgrading to an
upstream release that contains both fixes, remove the `[patch.crates-io]` entry
and rerun the complete assignment-operator contract matrix and the
export-directive suite before updating the lockfile.

Tests keep raw Makefile text under `tests/fixtures/makefiles/`. Unit and
property tests exercise the domain, `rstest-bdd` scenarios exercise observable
Expand Down
Loading
Loading