asmdiff 0.1.2__tar.gz → 0.1.4__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.
@@ -0,0 +1,123 @@
1
+ # asmdiff field-test report
2
+
3
+ **Version tested:** working tree at 0.1.3 (post response-file/object-label/walk-up/verbose fixes), run via `uvx --from <repo>` so the packaged entry point, not the checkout, is what executed.
4
+ **Environment:** S3-Amysynth - a real ESP32-S3 / ESP-IDF v6 firmware (FreeRTOS, TinyUSB, vendored AMY synth engine), `build/compile_commands.json` with 1024 entries, xtensa-esp-elf-gcc 15.2.0 (picolibc via specs in a `@response` file), plus host gcc/clang and g++ for contrast.
5
+
6
+ ## Scenario matrix
7
+
8
+ | # | Scenario | Result |
9
+ |---|----------|--------|
10
+ | A1 | Small harness, `old_*`/`new_*` auto-pairing, default gcc+clang fallback | works - both pairs found, fold-vs-libcall visible immediately |
11
+ | A2 | Same harness through a config target: glob `cc`, `compile_commands = true`, harness not in db | works - glob resolution announced, soft-miss note printed, xtensa FPU asm side-by-side |
12
+ | A3 | Two-target matrix (`--target s3 --target s3-size`) | works - O2 vs Os counts differ as expected |
13
+ | B1 | Whole-file summary of a real DSP file (`oscillators.c`, 58 functions) via db flags | works - and immediately useful: softfloat `__muldf3`/`__extendsfdf2` calls stand out in oscillator init paths |
14
+ | B2 | `--pair render_lut:render_lut_fb` inside a real file | works |
15
+ | C1 | `--across render_lut` between two config profiles | works - zero-overhead loop spans (`.L*_LEND`) detected on both sides |
16
+ | C2 | `--across` real file vs modified copy outside the tree | fails cleanly: soft-miss note + trimmed "amy.h: No such file" error, `--verbose` hint |
17
+ | C2b | `--across` real file vs copy **next to** the original | **compiles but silently misleads** - see Trap 1 |
18
+ | C3 | Two real files (both in db), per-file summary blocks | works - each file gets its own recorded flags |
19
+ | E1-E3 | Error paths: bogus target, string `flags`, explicit db + absent source | all produce specific, actionable messages (bogus target lists available targets) |
20
+ | E4 | C++ file via `g++` | compiles, functions extracted - but see Trap 3 |
21
+ | F1 | Giant function (`play_delta`, ~1000 insns) across O2/Os | works; see note on span semantics |
22
+ | F2 | Bare `--` extra flags (README's own example) | works |
23
+ | F3 | Source with no functions | `(no functions found)`, no crash |
24
+ | F4 | `--pair` with two files | correct argparse guard pointing at `--across` |
25
+
26
+ ## Traps and gaps (ranked)
27
+
28
+ ### 1. Same-directory copy: identical source, different assembly, one stderr note
29
+ The natural before/after move - copy `oscillators.c` to `osc_tweak.c` in the same
30
+ directory and `--across` them - *succeeds*, because quote-includes resolve next to
31
+ the source. But the copy has no db entry, so it compiles without the recorded
32
+ `-D`/`-I` set: in this project that means no `ESP_PLATFORM`, a different
33
+ `AMY_SAMPLE_RATE`, the portable code paths. The two columns show different
34
+ register allocation and frame sizes **for byte-identical code**. The only
35
+ warning is the one-line soft-miss note, whose implication ("the flag sets now
36
+ differ") is easy to miss. A user would read real codegen differences into a
37
+ header-config artifact.
38
+
39
+ **Recommendation:** when the two sides of an `--across`/two-file run received
40
+ *different* borrowed-flag sets (one hit, one soft miss), say so loudly - e.g.
41
+ tag the column header `[no db entry]` and print a warning that the comparison
42
+ crosses header configurations.
43
+
44
+ ### 2. Before/after on a project source has no supported path
45
+ The tool's pitch ("what does the compiler emit before and after my rewrite")
46
+ cannot currently be executed for a source that needs db flags, except by
47
+ editing in place and diffing two separate runs by eye, or by maintaining two
48
+ built checkouts with two databases. C2/C2b are both dead ends by design.
49
+
50
+ **Recommendation (feature):** an opt-in fallback such as "borrow flags from
51
+ the same-basename entry" (with a warning), or an explicit
52
+ `--compile-commands-entry PATH` to name which db entry a given source should
53
+ borrow from. Either would make the copy workflow first-class and would also
54
+ neutralize Trap 1.
55
+
56
+ ### 3. C++ defeats auto-pairing silently
57
+ `_Z9old_scalef` does not start with `old_`, so a C++ harness falls back to
58
+ whole-file summary with no hint about why. Workable via `extern "C"` or
59
+ explicit `--pair` with mangled names, but nothing tells the user that.
60
+ Acceptable for a C-focused tool; a doc note (or a "mangled names detected,
61
+ auto-pairing skipped" hint) would close it.
62
+
63
+ ### 4. Loop spans are coarse on large control flow
64
+ `play_delta` (1001 insns) reports spans like `.L513:676` - two-thirds of the
65
+ function - because any backward branch defines a span, and big switch/dispatch
66
+ functions branch backwards for control flow that is not a loop. The README's
67
+ "the listing and your source know, not the tool" caveat covers this honestly,
68
+ but on 1000-instruction functions the span column stops being informative.
69
+
70
+ ### Minor observations
71
+ - The glob-resolution notice (`cc pattern matched 2 toolchains ...`) prints on
72
+ every run; correct behavior (never silent), slightly noisy in scripted use.
73
+ - Two-file summary blocks are rendered as independent tables, so their columns
74
+ do not align with each other - cosmetic.
75
+ - `-ffast-math` after `--` reached the compiler as documented (no behavior
76
+ change for the test case, which was already folded).
77
+
78
+ ## What clearly works now (regression-verified this session)
79
+ - ESP-IDF v6 out of the box: `@response-file` expansion delivers
80
+ `-specs=picolibc.specs`; `main.c` and every AMY source compiled with only
81
+ `-O2 -mlongcalls` supplied by the user.
82
+ - Function extraction is clean: 72/72 real functions on `amy.c`, zero data
83
+ labels, counts match `.type @function` ground truth.
84
+ - Walk-up discovery: project root, `main/`, and `components/amy/src/` all find
85
+ `build/compile_commands.json`; the `.git` boundary prevents escaping the repo.
86
+ - Failure UX: 4-line trimmed error with a `--verbose` escape hatch instead of a
87
+ 13 KB flag dump.
88
+ - Config system end-to-end: CWD discovery, `default`, multi-target matrices,
89
+ glob `cc`, env expansion, precise config error messages.
90
+
91
+ ## Postscript: guards added after this report
92
+
93
+ Traps 1-3 were addressed in the working tree the same day, and re-verified
94
+ against the scenarios above:
95
+
96
+ - **Trap 1:** a two-file comparison with borrowed flags on only one side now
97
+ tags the column `[no db entry]` and warns that differences may be header
98
+ configuration, not codegen. The C2b rerun makes the case vividly: identical
99
+ source showed 51 vs 14 instructions (partial outlining under the portable
100
+ header config) — now labeled instead of silent.
101
+ - **Trap 2:** new `--flags-like PATH` lets a db-less source borrow a named
102
+ entry's flags. Rerunning C2 and C2b with it produced identical 51/51
103
+ columns for identical source, in-tree and out-of-tree. The absent-source
104
+ error and the soft-miss note both suggest the flag when a same-basename
105
+ entry exists.
106
+ - **Trap 3:** falling back to summary when only mangled `_Z*old_*` symbols
107
+ exist now prints a note naming `extern "C"` and `--pair` as the outs.
108
+
109
+ Trap 4 (span coarseness on giant control flow) is documented behavior and
110
+ was left as-is.
111
+
112
+ ## Evaluation
113
+ For its stated job - harness-based codegen comparison and whole-file/function
114
+ inspection of real embedded sources - the tool now works credibly in a real
115
+ ESP-IDF v6 project, with fast (<2 s) turnarounds and output that surfaced a
116
+ genuine finding within minutes (software-double promotion in AMY filter/
117
+ oscillator setup paths via `__extendsfdf2`/`__muldf3` in the calls column).
118
+ Error paths are uniformly specific and short. The remaining weaknesses cluster
119
+ around one theme: comparing *modified copies* of db-backed sources, where the
120
+ current behavior ranges from a clean failure (C2) to a quietly misleading
121
+ success (C2b). That workflow is close enough to the tool's core promise that
122
+ Trap 1's warning and Trap 2's fallback are the highest-value next changes;
123
+ everything else found is polish.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: asmdiff
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Compare per-function assembly between paired C implementations
5
5
  Project-URL: Homepage, https://github.com/rt-rtos/asmdiff
6
6
  Project-URL: Repository, https://github.com/rt-rtos/asmdiff
@@ -94,7 +94,8 @@ $ asmdiff.py asmdiff_example.c
94
94
  ```
95
95
  asmdiff.py SOURCE.c [SOURCE2.c] [--pair OLD:NEW]... [--across FUNC]...
96
96
  [--cc 'CC FLAGS']... [--target NAME]... [--config PATH]
97
- [--compile-commands [PATH]] [-- EXTRA_FLAGS...]
97
+ [--compile-commands [PATH]] [--flags-like PATH] [-v]
98
+ [-- EXTRA_FLAGS...]
98
99
  ```
99
100
 
100
101
  | Option | Meaning |
@@ -105,7 +106,9 @@ asmdiff.py SOURCE.c [SOURCE2.c] [--pair OLD:NEW]... [--across FUNC]...
105
106
  | `--cc 'CC FLAGS'` | One compiler invocation, command and flags in a single quoted string. Repeatable to build a matrix. |
106
107
  | `--target NAME` | A named target from the config file, resolved to a `--cc` entry. Repeatable; appended to the matrix after `--cc` entries. |
107
108
  | `--config PATH` | Config file to use. Default search: `asmdiff.toml` next to `SOURCE.c`, then in the current directory, then `~/.config/`. First hit wins. |
108
- | `--compile-commands [PATH]` | Borrow each source's include/define flags from a `compile_commands.json`; with no `PATH`, search `./`, `./build`, `../`, `../build`. See [below](#borrowing-includes-from-compile_commandsjson). |
109
+ | `--compile-commands [PATH]` | Borrow each source's include/define flags from a `compile_commands.json`; with no `PATH`, walk up from the CWD checking each directory and its `build/` until the repository root. See [below](#borrowing-includes-from-compile_commandsjson). |
110
+ | `--flags-like PATH` | A source with no `compile_commands` entry borrows the flags recorded for `PATH` — the way to compare a modified copy of a project source under its original's header environment. |
111
+ | `-v`, `--verbose` | On compile failure, print the full compiler command and complete error output. Default shows only the compiler, the source, and the first error lines. |
109
112
  | `-- FLAGS...` | Everything after a bare `--` is appended to *every* compiler invocation. |
110
113
 
111
114
  With no `--cc` and no `--target`, the config file's top-level
@@ -207,7 +210,8 @@ compile_commands = "$HOME/myproject/build/compile_commands.json"
207
210
  Now `asmdiff.py $HOME/myproject/components/dsp/biquad.c --target esp32s3-idf`
208
211
  finds that file's entry in the database and adds the include/define flags
209
212
  it recorded — `-I`, `-isystem`, `-iquote`, `-idirafter`, `-include`,
210
- `-imacros`, `-D`, `-U` — to this target's command. Everything else the
213
+ `-imacros`, `-D`, `-U`, plus the header-environment driver flags `-specs`
214
+ and `--sysroot` — to this target's command. Everything else the
211
215
  database records (its own compiler, `-O`/`-std`/`-W` flags, `-c`, `-o`, the
212
216
  source) is ignored: **you** own the compiler and optimisation flags via
213
217
  `cc`/`flags`; only the header environment is borrowed. That split is the
@@ -219,6 +223,12 @@ Details that make it robust:
219
223
  - **Paths are made absolute** against each entry's `directory`, so a
220
224
  database full of build-relative `-I../include` flags still resolves when
221
225
  asmdiff runs from anywhere.
226
+ - **`@file` response files are expanded.** Build systems park flags in
227
+ them — ESP-IDF v6 hides `-specs=picolibc.specs` in
228
+ `build/toolchain/cflags`, and without it every libc header breaks —
229
+ so the flag scan reads them (nested ones too) instead of skipping the
230
+ token. A bare specs name is left for the compiler's own search
231
+ directories; a specs path resolves like any other recorded path.
222
232
  - **Per source file.** The lookup keys on the source you pass (matched by
223
233
  resolved absolute path), so two files in an `--across`/summary run each
224
234
  get their own recorded flags.
@@ -246,12 +256,15 @@ asmdiff.py biquad.c --compile-commands # same, for any matrix
246
256
  asmdiff.py biquad.c --compile-commands path/to/compile_commands.json
247
257
  ```
248
258
 
249
- Both search `./`, `./build`, `../`, `../build` — first hit wins — which
250
- covers running from a project root or from one directory below it (a
251
- component dir), with the database where CMake/idf.py leaves it. Finding
252
- nothing is an error: discovery is opt-in, so if you asked for it, silence
253
- would be a lie. It is never on by default — a target without
254
- `compile_commands` and no `--compile-commands` flag borrows nothing.
259
+ Both walk up from the current directory, checking each level for
260
+ `compile_commands.json` and then `build/compile_commands.json` (where
261
+ CMake and idf.py leave it) — first hit wins. The walk stops at the
262
+ repository root (the first directory with a `.git`), so running from any
263
+ depth of component directory finds the project database, but an unrelated
264
+ one further up the filesystem is never picked up. Finding nothing is an
265
+ error: discovery is opt-in, so if you asked for it, silence would be a
266
+ lie. It is never on by default — a target without `compile_commands` and
267
+ no `--compile-commands` flag borrows nothing.
255
268
 
256
269
  The precedence is what you'd hope: a target that names its own
257
270
  `compile_commands` path always keeps it; `--compile-commands` (with or
@@ -264,6 +277,29 @@ instead of being an error. That keeps standalone harness files working
264
277
  when a `build/` directory happens to sit nearby; an explicitly named
265
278
  database still treats an absent source as the error it is.
266
279
 
280
+ If a two-file comparison ends up with borrowed flags on only *one* side,
281
+ the mismatched column is tagged `[no db entry]` and a warning is printed:
282
+ the two sides then differ in header configuration — defines, include
283
+ paths — not just source, and byte-identical code can compile to visibly
284
+ different assembly. Don't read codegen meaning into such a diff.
285
+
286
+ ### Comparing a modified copy of a project source
287
+
288
+ The before/after workflow — copy `oscillators.c` to `osc_tweak.c`, change
289
+ one thing, `--across` them — is exactly the situation above: the copy has
290
+ no database entry. `--flags-like` names the entry the copy should borrow:
291
+
292
+ ```bash
293
+ asmdiff.py src/oscillators.c src/osc_tweak.c --across render_lut \
294
+ --target s3 --flags-like src/oscillators.c
295
+ ```
296
+
297
+ Both sides now compile under the same recorded header environment, so the
298
+ diff is your edit and nothing else. This also covers a git-worktree
299
+ baseline (`../baseline/src/oscillators.c`), which is the same file under a
300
+ path the database has never heard of. The absent-source error and the
301
+ soft-miss note both point at this flag when a same-name entry exists.
302
+
267
303
  ## Whole-file summary
268
304
 
269
305
  With no `--pair`, no `--across`, and no `old_*`/`new_*` functions to
@@ -292,6 +328,12 @@ weight, did a call appear that shouldn't have? It sums parsed function
292
328
  bodies only (no literal pools, data, or alignment), so it is not a size
293
329
  measurement, and per-function rows are where the real information is.
294
330
 
331
+ Only labels the assembler types as functions are listed — global data
332
+ (string constants, state structs, lookup tables) gets column-0 labels too
333
+ but is not code. A `calls` list longer than 8 symbols is truncated to
334
+ `..., +N more`; real firmware dispatch functions call dozens of distinct
335
+ symbols and would otherwise make rows thousands of characters wide.
336
+
295
337
  ## Comparing the same function across two builds (`--across`)
296
338
 
297
339
  `--pair` needs both variants to coexist in one compilation. Real changes
@@ -76,7 +76,8 @@ $ asmdiff.py asmdiff_example.c
76
76
  ```
77
77
  asmdiff.py SOURCE.c [SOURCE2.c] [--pair OLD:NEW]... [--across FUNC]...
78
78
  [--cc 'CC FLAGS']... [--target NAME]... [--config PATH]
79
- [--compile-commands [PATH]] [-- EXTRA_FLAGS...]
79
+ [--compile-commands [PATH]] [--flags-like PATH] [-v]
80
+ [-- EXTRA_FLAGS...]
80
81
  ```
81
82
 
82
83
  | Option | Meaning |
@@ -87,7 +88,9 @@ asmdiff.py SOURCE.c [SOURCE2.c] [--pair OLD:NEW]... [--across FUNC]...
87
88
  | `--cc 'CC FLAGS'` | One compiler invocation, command and flags in a single quoted string. Repeatable to build a matrix. |
88
89
  | `--target NAME` | A named target from the config file, resolved to a `--cc` entry. Repeatable; appended to the matrix after `--cc` entries. |
89
90
  | `--config PATH` | Config file to use. Default search: `asmdiff.toml` next to `SOURCE.c`, then in the current directory, then `~/.config/`. First hit wins. |
90
- | `--compile-commands [PATH]` | Borrow each source's include/define flags from a `compile_commands.json`; with no `PATH`, search `./`, `./build`, `../`, `../build`. See [below](#borrowing-includes-from-compile_commandsjson). |
91
+ | `--compile-commands [PATH]` | Borrow each source's include/define flags from a `compile_commands.json`; with no `PATH`, walk up from the CWD checking each directory and its `build/` until the repository root. See [below](#borrowing-includes-from-compile_commandsjson). |
92
+ | `--flags-like PATH` | A source with no `compile_commands` entry borrows the flags recorded for `PATH` — the way to compare a modified copy of a project source under its original's header environment. |
93
+ | `-v`, `--verbose` | On compile failure, print the full compiler command and complete error output. Default shows only the compiler, the source, and the first error lines. |
91
94
  | `-- FLAGS...` | Everything after a bare `--` is appended to *every* compiler invocation. |
92
95
 
93
96
  With no `--cc` and no `--target`, the config file's top-level
@@ -189,7 +192,8 @@ compile_commands = "$HOME/myproject/build/compile_commands.json"
189
192
  Now `asmdiff.py $HOME/myproject/components/dsp/biquad.c --target esp32s3-idf`
190
193
  finds that file's entry in the database and adds the include/define flags
191
194
  it recorded — `-I`, `-isystem`, `-iquote`, `-idirafter`, `-include`,
192
- `-imacros`, `-D`, `-U` — to this target's command. Everything else the
195
+ `-imacros`, `-D`, `-U`, plus the header-environment driver flags `-specs`
196
+ and `--sysroot` — to this target's command. Everything else the
193
197
  database records (its own compiler, `-O`/`-std`/`-W` flags, `-c`, `-o`, the
194
198
  source) is ignored: **you** own the compiler and optimisation flags via
195
199
  `cc`/`flags`; only the header environment is borrowed. That split is the
@@ -201,6 +205,12 @@ Details that make it robust:
201
205
  - **Paths are made absolute** against each entry's `directory`, so a
202
206
  database full of build-relative `-I../include` flags still resolves when
203
207
  asmdiff runs from anywhere.
208
+ - **`@file` response files are expanded.** Build systems park flags in
209
+ them — ESP-IDF v6 hides `-specs=picolibc.specs` in
210
+ `build/toolchain/cflags`, and without it every libc header breaks —
211
+ so the flag scan reads them (nested ones too) instead of skipping the
212
+ token. A bare specs name is left for the compiler's own search
213
+ directories; a specs path resolves like any other recorded path.
204
214
  - **Per source file.** The lookup keys on the source you pass (matched by
205
215
  resolved absolute path), so two files in an `--across`/summary run each
206
216
  get their own recorded flags.
@@ -228,12 +238,15 @@ asmdiff.py biquad.c --compile-commands # same, for any matrix
228
238
  asmdiff.py biquad.c --compile-commands path/to/compile_commands.json
229
239
  ```
230
240
 
231
- Both search `./`, `./build`, `../`, `../build` — first hit wins — which
232
- covers running from a project root or from one directory below it (a
233
- component dir), with the database where CMake/idf.py leaves it. Finding
234
- nothing is an error: discovery is opt-in, so if you asked for it, silence
235
- would be a lie. It is never on by default — a target without
236
- `compile_commands` and no `--compile-commands` flag borrows nothing.
241
+ Both walk up from the current directory, checking each level for
242
+ `compile_commands.json` and then `build/compile_commands.json` (where
243
+ CMake and idf.py leave it) — first hit wins. The walk stops at the
244
+ repository root (the first directory with a `.git`), so running from any
245
+ depth of component directory finds the project database, but an unrelated
246
+ one further up the filesystem is never picked up. Finding nothing is an
247
+ error: discovery is opt-in, so if you asked for it, silence would be a
248
+ lie. It is never on by default — a target without `compile_commands` and
249
+ no `--compile-commands` flag borrows nothing.
237
250
 
238
251
  The precedence is what you'd hope: a target that names its own
239
252
  `compile_commands` path always keeps it; `--compile-commands` (with or
@@ -246,6 +259,29 @@ instead of being an error. That keeps standalone harness files working
246
259
  when a `build/` directory happens to sit nearby; an explicitly named
247
260
  database still treats an absent source as the error it is.
248
261
 
262
+ If a two-file comparison ends up with borrowed flags on only *one* side,
263
+ the mismatched column is tagged `[no db entry]` and a warning is printed:
264
+ the two sides then differ in header configuration — defines, include
265
+ paths — not just source, and byte-identical code can compile to visibly
266
+ different assembly. Don't read codegen meaning into such a diff.
267
+
268
+ ### Comparing a modified copy of a project source
269
+
270
+ The before/after workflow — copy `oscillators.c` to `osc_tweak.c`, change
271
+ one thing, `--across` them — is exactly the situation above: the copy has
272
+ no database entry. `--flags-like` names the entry the copy should borrow:
273
+
274
+ ```bash
275
+ asmdiff.py src/oscillators.c src/osc_tweak.c --across render_lut \
276
+ --target s3 --flags-like src/oscillators.c
277
+ ```
278
+
279
+ Both sides now compile under the same recorded header environment, so the
280
+ diff is your edit and nothing else. This also covers a git-worktree
281
+ baseline (`../baseline/src/oscillators.c`), which is the same file under a
282
+ path the database has never heard of. The absent-source error and the
283
+ soft-miss note both point at this flag when a same-name entry exists.
284
+
249
285
  ## Whole-file summary
250
286
 
251
287
  With no `--pair`, no `--across`, and no `old_*`/`new_*` functions to
@@ -274,6 +310,12 @@ weight, did a call appear that shouldn't have? It sums parsed function
274
310
  bodies only (no literal pools, data, or alignment), so it is not a size
275
311
  measurement, and per-function rows are where the real information is.
276
312
 
313
+ Only labels the assembler types as functions are listed — global data
314
+ (string constants, state structs, lookup tables) gets column-0 labels too
315
+ but is not code. A `calls` list longer than 8 symbols is truncated to
316
+ `..., +N more`; real firmware dispatch functions call dozens of distinct
317
+ symbols and would otherwise make rows thousands of characters wide.
318
+
277
319
  ## Comparing the same function across two builds (`--across`)
278
320
 
279
321
  `--pair` needs both variants to coexist in one compilation. Real changes
@@ -42,8 +42,9 @@ flags = ["-O2", "-mlongcalls", "-DNDEBUG"]
42
42
  # never transcribe include paths by hand. The compiler and -O/-m flags
43
43
  # still come from cc/flags; only the header environment is borrowed.
44
44
  # A path expands ~ and $VARS; `compile_commands = true` instead searches
45
- # ./, ./build, ../, ../build at run time (a source missing from a database
46
- # found that way is compiled without borrowed flags, with a note).
45
+ # at run time: each directory from the CWD up to the repository root,
46
+ # and its build/ subdirectory (a source missing from a database found
47
+ # that way is compiled without borrowed flags, with a note).
47
48
  [esp32s3-idf]
48
49
  cc = "$HOME/.espressif/tools/xtensa-esp-elf/esp-*/xtensa-esp-elf/bin/xtensa-esp32s3-elf-gcc"
49
50
  flags = ["-O2", "-mlongcalls"]
@@ -13,6 +13,7 @@ Usage:
13
13
  [--across FUNC]... [--cc 'CC FLAGS']...
14
14
  [--target NAME]... [--config PATH]
15
15
  [--compile-commands [PATH]]
16
+ [--flags-like PATH] [-v]
16
17
  [-- EXTRA_FLAGS...]
17
18
 
18
19
  Three modes:
@@ -35,9 +36,12 @@ the include/define flags recorded there for the source being compiled are
35
36
  added to that target's command, so a real project source resolves its
36
37
  headers the way its own build system does (e.g. an ESP-IDF component).
37
38
  compile_commands = true in a target — or a bare --compile-commands on the
38
- command line — instead searches ./, ./build, ../, ../build for the
39
- database; a source absent from a database found that way is compiled
40
- without borrowed flags (with a note) rather than being an error.
39
+ command line — instead finds the database by checking each directory from
40
+ the CWD up to the repository root, and its build/ subdirectory; a source
41
+ absent from a database found that way is compiled without borrowed flags
42
+ (with a note) rather than being an error. --flags-like PATH lets such a
43
+ source borrow the flags recorded for PATH, so a modified copy compares
44
+ against its original under one header environment.
41
45
  """
42
46
  import argparse
43
47
  import glob
@@ -73,8 +77,19 @@ CONFIG_NAME = "asmdiff.toml"
73
77
  _CC_DB_PATH_FLAGS = ("-I", "-iquote", "-isystem", "-idirafter",
74
78
  "-include", "-imacros", "-isysroot")
75
79
  _CC_DB_PLAIN_FLAGS = ("-D", "-U")
80
+ # Driver-level flags that also shape the header environment: a specs file
81
+ # can swap the entire libc header set (ESP-IDF v6 selects picolibc via
82
+ # -specs=picolibc.specs), and --sysroot moves every system include. Both
83
+ # accept =-glued and split spellings; both are re-emitted =-glued.
84
+ _CC_DB_EQ_FLAGS = ("-specs", "--specs", "--sysroot")
76
85
  _DB_CACHE = {}
77
86
  _MISS_NOTED = set()
87
+ _BORROW_NOTED = set()
88
+ # --flags-like PATH: a source with no database entry borrows the flags
89
+ # recorded for PATH. This is how a modified copy of a project source gets
90
+ # the same header environment as its original, so an --across between them
91
+ # compares codegen instead of header configurations.
92
+ FLAGS_LIKE = None
78
93
 
79
94
 
80
95
  class Target(str):
@@ -105,6 +120,43 @@ def _abs_against(directory, value):
105
120
  return str(p)
106
121
 
107
122
 
123
+ def _expand_response_files(tokens, directory, depth=0):
124
+ """Inline GCC @file response files: each @FILE token is replaced by the
125
+ shlex-split contents of FILE, resolved against the entry's ``directory``
126
+ (the CWD the driver ran from). Build systems park header-environment
127
+ flags there — ESP-IDF v6 hides -specs=picolibc.specs in one — so
128
+ skipping them silently loses flags this feature exists to borrow.
129
+ An unreadable file is warned about and dropped; nesting is bounded as
130
+ a cycle guard.
131
+ """
132
+ out = []
133
+ for tok in tokens:
134
+ if not tok.startswith("@") or len(tok) == 1:
135
+ out.append(tok)
136
+ continue
137
+ path = _abs_against(directory, tok[1:])
138
+ try:
139
+ content = Path(path).read_text()
140
+ except OSError:
141
+ print(f"warning: response file {path} not readable; "
142
+ "flags inside it are not borrowed", file=sys.stderr)
143
+ continue
144
+ inner = shlex.split(content)
145
+ if depth < 8:
146
+ inner = _expand_response_files(inner, directory, depth + 1)
147
+ out += inner
148
+ return out
149
+
150
+
151
+ def _specs_value(directory, value):
152
+ """A specs argument with a path separator is a file path (resolved like
153
+ any other); a bare name (picolibc.specs) is looked up in the compiler's
154
+ own search directories and must pass through untouched."""
155
+ if "/" in value or os.sep in value:
156
+ return _abs_against(directory, value)
157
+ return value
158
+
159
+
108
160
  def include_flags(tokens, directory):
109
161
  """Pick header-search, forced-include, and define flags out of one
110
162
  recorded compile command; make relative paths absolute against
@@ -124,6 +176,19 @@ def include_flags(tokens, directory):
124
176
  flags += [f, _abs_against(directory, tok[len(f):])]
125
177
  matched = True
126
178
  break
179
+ if not matched:
180
+ for f in _CC_DB_EQ_FLAGS:
181
+ value = None
182
+ if tok == f and i + 1 < n: # -specs file
183
+ value, extra = tokens[i + 1], 1
184
+ elif tok.startswith(f + "="): # -specs=file
185
+ value = tok[len(f) + 1:]
186
+ if value is not None:
187
+ resolve = (_specs_value if f.endswith("specs")
188
+ else _abs_against)
189
+ flags.append(f + "=" + resolve(directory, value))
190
+ matched = True
191
+ break
127
192
  if not matched:
128
193
  for f in _CC_DB_PLAIN_FLAGS:
129
194
  if tok == f and i + 1 < n: # -D FOO
@@ -138,18 +203,26 @@ def include_flags(tokens, directory):
138
203
 
139
204
 
140
205
  def find_compile_commands():
141
- """Locate a compile_commands.json near the current directory.
142
-
143
- Searched in order: ./, ./build, ../, ../build — first hit wins. This
144
- covers running asmdiff from a project root or from one directory below
145
- it (a component dir), with the database where CMake/idf.py leaves it.
146
- Returns None when nothing is found.
206
+ """Locate a compile_commands.json by walking up from the current
207
+ directory.
208
+
209
+ Each directory from the CWD upward is checked for the database itself,
210
+ then for build/compile_commands.json (where CMake and idf.py leave it),
211
+ so the search works from a project root or from any depth of component
212
+ directory. The walk stops at the first directory containing .git (the
213
+ repository root — checked after that directory's own candidates) or
214
+ after a bounded number of levels, so an unrelated database further up
215
+ the filesystem is never picked up. Returns None when nothing is found.
147
216
  """
148
- cwd = Path.cwd()
149
- for d in (cwd, cwd / "build", cwd.parent, cwd.parent / "build"):
150
- candidate = d / "compile_commands.json"
151
- if candidate.is_file():
152
- return str(candidate)
217
+ d = Path.cwd()
218
+ for _ in range(10):
219
+ for candidate in (d / "compile_commands.json",
220
+ d / "build" / "compile_commands.json"):
221
+ if candidate.is_file():
222
+ return str(candidate)
223
+ if (d / ".git").exists() or d.parent == d:
224
+ return None
225
+ d = d.parent
153
226
  return None
154
227
 
155
228
 
@@ -159,8 +232,9 @@ def _discovered_db(who):
159
232
  not a silent no-op."""
160
233
  found = find_compile_commands()
161
234
  if found is None:
162
- sys.exit(f"error: {who}: no compile_commands.json found in "
163
- "./, ./build, ../, or ../build")
235
+ sys.exit(f"error: {who}: no compile_commands.json found in the "
236
+ "current directory, its build/, or any parent up to the "
237
+ "repository root")
164
238
  return found
165
239
 
166
240
 
@@ -192,9 +266,47 @@ def compile_commands_flags(db_path, source, missing_ok=False):
192
266
  and the message flags a same-name entry recorded under a different path.
193
267
  With ``missing_ok`` (auto-discovered databases) an absent source instead
194
268
  compiles without borrowed flags, after a one-time note per (db, source).
269
+ In either mode, --flags-like PATH satisfies an absent source with the
270
+ flags recorded for PATH — the modified-copy workflow.
195
271
  """
196
272
  entries = load_compile_commands(db_path)
197
273
  want = Path(source).resolve()
274
+ flags, name_seen = _lookup_entry(entries, want)
275
+ if flags is not None:
276
+ return flags
277
+ if FLAGS_LIKE:
278
+ like = Path(FLAGS_LIKE).resolve()
279
+ like_flags, _ = _lookup_entry(entries, like)
280
+ if like_flags is None:
281
+ sys.exit(f"error: --flags-like {FLAGS_LIKE} "
282
+ f"not found in {db_path}")
283
+ key = (db_path, str(want))
284
+ if key not in _BORROW_NOTED:
285
+ _BORROW_NOTED.add(key)
286
+ print(f"note: {source} not in {db_path}; borrowing the flags "
287
+ f"recorded for {FLAGS_LIKE}", file=sys.stderr)
288
+ return like_flags
289
+ suggest = ("; --flags-like PATH can borrow another entry's flags"
290
+ if name_seen else "")
291
+ if missing_ok:
292
+ key = (db_path, str(want))
293
+ if key not in _MISS_NOTED:
294
+ _MISS_NOTED.add(key)
295
+ print(f"note: {source} not in {db_path}; compiling without "
296
+ f"its include/define flags{suggest}", file=sys.stderr)
297
+ return []
298
+ hint = (f"; an entry named {want.name} exists under a different path — "
299
+ "pass the source as it appears in the database, or borrow its "
300
+ "flags with --flags-like") if name_seen else ""
301
+ sys.exit(f"error: {source} not found in {db_path}{hint}")
302
+
303
+
304
+ def _lookup_entry(entries, want):
305
+ """Flags for the entry whose file resolves to ``want``, or None.
306
+
307
+ The second result reports whether some entry shares ``want``'s
308
+ basename — the raw material for the miss hints above.
309
+ """
198
310
  name_seen = False
199
311
  for entry in entries:
200
312
  f = entry.get("file")
@@ -212,27 +324,26 @@ def compile_commands_flags(db_path, source, missing_ok=False):
212
324
  args = entry.get("arguments")
213
325
  tokens = (list(args) if isinstance(args, list)
214
326
  else shlex.split(entry.get("command", "")))
215
- return include_flags(tokens, directory)
327
+ tokens = _expand_response_files(tokens, directory)
328
+ return include_flags(tokens, directory), name_seen
216
329
  if Path(f).name == want.name:
217
330
  name_seen = True
218
- if missing_ok:
219
- key = (db_path, str(want))
220
- if key not in _MISS_NOTED:
221
- _MISS_NOTED.add(key)
222
- print(f"note: {source} not in {db_path}; "
223
- "compiling without its include/define flags",
224
- file=sys.stderr)
225
- return []
226
- hint = (f"; an entry named {want.name} exists under a different path — "
227
- "pass the source as it appears in the database") if name_seen else ""
228
- sys.exit(f"error: {source} not found in {db_path}{hint}")
331
+ return None, name_seen
229
332
 
230
333
  # A label at column 0 that is not a local (.L*) label starts a function.
231
334
  FUNC_LABEL = re.compile(r"^([A-Za-z_][\w$.]*):")
232
- # Assembler directives that carry no information worth reading.
335
+ # .type NAME, @object|@function — gcc/clang emit this before the label on
336
+ # ELF. Objects (string constants like __func__, global state, LUTs) get
337
+ # column-0 labels too and must not be reported as functions. The type
338
+ # marker is @ on x86/Xtensa/RISC-V, % on ARM, # on some targets.
339
+ TYPE_DIRECTIVE = re.compile(r'^\s*\.type\s+([^,\s]+)\s*,\s*[@%#]?(\w+)')
340
+ # Assembler directives that carry no information worth reading. .local/
341
+ # .comm/.lcomm declare zero-initialised data, .literal/.literal_position
342
+ # are Xtensa literal-pool bookkeeping — none of them is an instruction.
233
343
  NOISE = re.compile(
234
344
  r"^\s*\.(cfi_|p2align|align\b|loc\b|file\b|text\b|globl\b|global\b|"
235
- r"type\b|section\b|ident\b|weak\b|hidden\b|addrsig|build_version)"
345
+ r"type\b|section\b|ident\b|weak\b|hidden\b|addrsig|build_version|"
346
+ r"local\b|comm\b|lcomm\b|literal_position|literal\b)"
236
347
  )
237
348
  # Compiler-generated bracketing labels that add nothing (.LFB0:, .Lfunc_end0:).
238
349
  NOISE_LABEL = re.compile(r"^\.(LFB|LFE|Lfunc_begin|Lfunc_end)\d*:")
@@ -252,18 +363,28 @@ def extract_functions(asm_text):
252
363
 
253
364
  A function body runs from its column-0 label to the matching .size
254
365
  directive (gcc and clang both emit one on ELF) or the next function
255
- label. Comment lines, CFI/section/alignment directives, compiler
256
- bracketing labels, and inline data (switch jump tables, constants) are
257
- dropped; instructions and meaningful local labels (loop targets) are
258
- kept, whitespace-stripped.
366
+ label. Labels typed ``@object`` (string constants, global state,
367
+ lookup tables) are data, not functions, and are skipped entirely;
368
+ an untyped label still counts as a function so hand-written asm
369
+ keeps working. Comment lines, CFI/section/alignment directives,
370
+ compiler bracketing labels, and inline data (switch jump tables,
371
+ constants) are dropped; instructions and meaningful local labels
372
+ (loop targets) are kept, whitespace-stripped.
259
373
  """
260
374
  funcs = {}
261
375
  current = None
376
+ data_labels = set()
262
377
  for raw in asm_text.splitlines():
378
+ t = TYPE_DIRECTIVE.match(raw)
379
+ if t and t.group(2) != "function":
380
+ data_labels.add(t.group(1))
263
381
  m = FUNC_LABEL.match(raw)
264
382
  if m:
265
- current = m.group(1)
266
- funcs[current] = []
383
+ if m.group(1) in data_labels:
384
+ current = None
385
+ else:
386
+ current = m.group(1)
387
+ funcs[current] = []
267
388
  continue
268
389
  if current is None:
269
390
  continue
@@ -453,7 +574,7 @@ def target_command(config, name, config_path):
453
574
  if not isinstance(db, str):
454
575
  sys.exit(f"error: target [{name}]: compile_commands must be a "
455
576
  "path to a compile_commands.json, or true to search "
456
- "./, ./build, ../, ../build")
577
+ "upward from the current directory")
457
578
  db = os.path.expandvars(os.path.expanduser(db))
458
579
  return Target(shlex.join([resolve_cc(cc, name), *flags]), db, discovered)
459
580
 
@@ -491,6 +612,31 @@ def build_matrix(cc_args, target_args, config, config_path, db_arg=None):
491
612
  return entries
492
613
 
493
614
 
615
+ # --verbose: print full compiler command lines and untrimmed stderr.
616
+ VERBOSE = False
617
+ # Without --verbose, a failed compile shows this many stderr lines — enough
618
+ # for the include chain plus the first error, which is the actionable part.
619
+ MAX_STDERR_LINES = 20
620
+
621
+
622
+ def _compile_failure(cmd, stderr):
623
+ """Exit for a failed compile.
624
+
625
+ A borrowed-flags command runs to hundreds of tokens and a broken
626
+ header environment produces pages of stderr; dumping both buries the
627
+ actual error. Default: compiler + source + the first stderr lines.
628
+ --verbose restores the complete command and output.
629
+ """
630
+ if VERBOSE:
631
+ sys.exit(f"error: compile failed: {shlex.join(cmd)}\n{stderr}")
632
+ lines = stderr.splitlines()
633
+ shown = "\n".join(lines[:MAX_STDERR_LINES])
634
+ dropped = len(lines) - MAX_STDERR_LINES
635
+ more = f"\n... {dropped} more stderr lines" if dropped > 0 else ""
636
+ sys.exit(f"error: {cmd[0]} failed on {cmd[-1]}\n{shown}{more}\n"
637
+ "(re-run with --verbose for the full command and output)")
638
+
639
+
494
640
  def asm_output_name(cc_cmd, harness):
495
641
  """Filesystem-safe .s name for one (compiler, source) compilation.
496
642
 
@@ -527,10 +673,45 @@ def compile_to_asm(cc_cmd, extra_flags, harness, out_dir):
527
673
  + ["-S", "-o", str(out_s), str(harness)])
528
674
  proc = subprocess.run(cmd, capture_output=True, text=True)
529
675
  if proc.returncode != 0:
530
- sys.exit(f"error: compile failed: {' '.join(cmd)}\n{proc.stderr}")
676
+ _compile_failure(cmd, proc.stderr)
531
677
  return out_s.read_text()
532
678
 
533
679
 
680
+ def mark_db_misses(cc_cmd, sources, tags):
681
+ """Column tags for a two-file comparison, marking a side that compiled
682
+ without borrowed flags while the other side had them.
683
+
684
+ That situation — one source in the database, its copy not — means the
685
+ columns differ in header configuration (defines, include paths), not
686
+ just in source, so byte-identical code can show different assembly.
687
+ The marked tag and a warning keep that from reading as a codegen
688
+ finding. Both sides missing is a consistent environment: no warning.
689
+ """
690
+ db = getattr(cc_cmd, "compile_commands", None)
691
+ if not db:
692
+ return list(tags)
693
+ missed = [(db, str(Path(s).resolve())) in _MISS_NOTED for s in sources]
694
+ if not any(missed) or all(missed):
695
+ return list(tags)
696
+ print("warning: only one side got flags from the database — assembly "
697
+ "differences may reflect header configuration, not source "
698
+ "changes; --flags-like PATH gives the copy its original's flags",
699
+ file=sys.stderr)
700
+ return [t + " [no db entry]" if m else t for t, m in zip(tags, missed)]
701
+
702
+
703
+ # C++ mangles old_scale(float) to _Z9old_scalef: the old_/new_ prefixes
704
+ # survive inside the mangled name but no longer lead it, so auto-pairing
705
+ # finds nothing and would fall back to the summary without explanation.
706
+ MANGLED_PAIR = re.compile(r"_Z\d+(?:old|new)_")
707
+
708
+
709
+ def mangled_pair_hint(names):
710
+ """True when the extracted names look like C++-mangled old_*/new_*
711
+ functions — the harness probably just needs extern \"C\"."""
712
+ return any(MANGLED_PAIR.match(n) for n in names)
713
+
714
+
534
715
  def side_by_side(left, right, ltitle, rtitle, width=44):
535
716
  """Two-column view of a pair's asm lines."""
536
717
  rows = [f"{ltitle:<{width}} | {rtitle}",
@@ -554,14 +735,28 @@ def format_spans(spans):
554
735
  return " ".join(f"{label}:{n}" for label, n in spans) or "-"
555
736
 
556
737
 
738
+ # Real firmware dispatch functions call dozens of distinct symbols; an
739
+ # uncapped list makes summary rows thousands of characters wide.
740
+ MAX_CALLS_SHOWN = 8
741
+
742
+
743
+ def format_calls(calls):
744
+ """Comma-joined callee list, capped at MAX_CALLS_SHOWN for readability."""
745
+ if not calls:
746
+ return "-"
747
+ if len(calls) > MAX_CALLS_SHOWN:
748
+ return (", ".join(calls[:MAX_CALLS_SHOWN])
749
+ + f", +{len(calls) - MAX_CALLS_SHOWN} more")
750
+ return ", ".join(calls)
751
+
752
+
557
753
  def summary_table(pairs, funcs):
558
754
  """Instruction counts, outbound calls, and loop spans per pair member."""
559
755
  rows = [("function", "role", "insns", "calls", "loop spans")]
560
756
  for old, new in pairs:
561
757
  for name, role in ((old, "baseline"), (new, "candidate")):
562
758
  insns, calls = analyze(funcs[name])
563
- rows.append((name, role, str(insns),
564
- ", ".join(calls) or "-",
759
+ rows.append((name, role, str(insns), format_calls(calls),
565
760
  format_spans(loop_spans(funcs[name]))))
566
761
  return render_table(rows)
567
762
 
@@ -582,10 +777,10 @@ def file_summary_table(funcs):
582
777
  for sym in calls:
583
778
  if sym not in all_calls:
584
779
  all_calls.append(sym)
585
- rows.append((name, str(insns), ", ".join(calls) or "-",
780
+ rows.append((name, str(insns), format_calls(calls),
586
781
  format_spans(loop_spans(lines))))
587
782
  rows.append((f"TOTAL ({len(funcs)} functions)", str(total_insns),
588
- ", ".join(all_calls) or "-", "-"))
783
+ format_calls(all_calls), "-"))
589
784
  return render_table(rows)
590
785
 
591
786
 
@@ -639,9 +834,10 @@ def run_across(sources, matrix, fn_names, extra_flags, tmp):
639
834
  if len(sides) < 2:
640
835
  continue
641
836
  ran_any = True
837
+ tags = mark_db_misses(cc_cmd, sources,
838
+ file_tags(sources[0], sources[1]))
642
839
  print(f"\n== {cc_cmd} ==\n")
643
- report_across(fn_names, sides[0], sides[1],
644
- *file_tags(sources[0], sources[1]))
840
+ report_across(fn_names, sides[0], sides[1], *tags)
645
841
  if not ran_any:
646
842
  sys.exit("error: no usable compiler in the matrix")
647
843
  return 0
@@ -679,8 +875,10 @@ def run_summary(sources, matrix, extra_flags, tmp):
679
875
  if len(sections) < len(sources):
680
876
  continue
681
877
  ran_any = True
878
+ shown = (mark_db_misses(cc_cmd, sources, tags)
879
+ if len(sources) == 2 else tags)
682
880
  print(f"\n== {cc_cmd} ==")
683
- for tag, funcs in zip(tags, sections):
881
+ for tag, funcs in zip(shown, sections):
684
882
  if len(sections) > 1:
685
883
  print(f"\n-- {tag} --")
686
884
  print()
@@ -707,6 +905,10 @@ def run_pairs(source, matrix, pair_specs, extra_flags, tmp):
707
905
  pairs = ([tuple(p.split(":", 1)) for p in pair_specs]
708
906
  or auto_pairs(funcs))
709
907
  if not pairs:
908
+ if mangled_pair_hint(funcs):
909
+ print('note: C++ mangling defeats old_*/new_* auto-pairing; '
910
+ 'declare the pairs extern "C" or use --pair with the '
911
+ 'mangled names', file=sys.stderr)
710
912
  print(f"\n== {cc_cmd} ==\n")
711
913
  print(file_summary_table(funcs) if funcs
712
914
  else "(no functions found)")
@@ -763,13 +965,27 @@ def main(argv=None):
763
965
  default=None, metavar="PATH",
764
966
  help="borrow each source's include/define flags from "
765
967
  "a compile_commands.json; with no PATH, search "
766
- "./, ./build, ../, ../build. A target whose "
968
+ "each directory (and its build/) from the CWD "
969
+ "up to the repository root. A target whose "
767
970
  "config names its own compile_commands keeps it")
768
971
  parser.add_argument("--config", metavar="PATH",
769
972
  help=f"config file; default search: {CONFIG_NAME} "
770
973
  "next to SOURCE.c, in the current directory, "
771
974
  "then in ~/.config/")
975
+ parser.add_argument("--flags-like", metavar="PATH",
976
+ help="when a source has no compile_commands entry, "
977
+ "borrow the include/define flags recorded for "
978
+ "PATH — lets a modified copy of a project "
979
+ "source compile (and compare) under its "
980
+ "original's header environment")
981
+ parser.add_argument("-v", "--verbose", action="store_true",
982
+ help="on compile failure, print the full compiler "
983
+ "command and complete error output instead of "
984
+ "the first lines")
772
985
  args = parser.parse_args(argv)
986
+ global VERBOSE, FLAGS_LIKE
987
+ VERBOSE = args.verbose
988
+ FLAGS_LIKE = args.flags_like
773
989
 
774
990
  if len(args.sources) > 2:
775
991
  parser.error("at most two source files may be given")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "asmdiff"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "Compare per-function assembly between paired C implementations"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -388,6 +388,77 @@ class TestIncludeFlags(unittest.TestCase):
388
388
  ["-isystem", "/s"])
389
389
 
390
390
 
391
+ class TestSpecsAndSysroot(unittest.TestCase):
392
+ """Driver flags that change the header environment (-specs, --sysroot)."""
393
+
394
+ def test_specs_glued_bare_name_not_resolved(self):
395
+ # A bare specs name (no path separator) is found in the compiler's
396
+ # own search dirs; gluing a directory onto it would break it.
397
+ self.assertEqual(
398
+ asmdiff.include_flags(["-specs=picolibc.specs"], "/build"),
399
+ ["-specs=picolibc.specs"])
400
+
401
+ def test_specs_with_path_resolved_against_directory(self):
402
+ self.assertEqual(
403
+ asmdiff.include_flags(["-specs=./custom/my.specs"], "/build"),
404
+ ["-specs=/build/custom/my.specs"])
405
+
406
+ def test_specs_split_and_double_dash(self):
407
+ self.assertEqual(
408
+ asmdiff.include_flags(["-specs", "nano.specs",
409
+ "--specs=nosys.specs"], "/b"),
410
+ ["-specs=nano.specs", "--specs=nosys.specs"])
411
+
412
+ def test_sysroot_glued_and_split(self):
413
+ self.assertEqual(
414
+ asmdiff.include_flags(["--sysroot=sr", "--sysroot", "/abs"],
415
+ "/b"),
416
+ ["--sysroot=/b/sr", "--sysroot=/abs"])
417
+
418
+
419
+ class TestResponseFiles(unittest.TestCase):
420
+ """GCC @file response files inside compile_commands entries."""
421
+
422
+ def test_flags_inside_response_file_are_borrowed(self):
423
+ with tempfile.TemporaryDirectory() as tmp:
424
+ rsp = Path(tmp) / "toolchain" / "cflags"
425
+ rsp.parent.mkdir()
426
+ rsp.write_text("-mlongcalls\n-specs=picolibc.specs\n-Irspinc\n")
427
+ src = Path(tmp) / "a.c"
428
+ src.touch()
429
+ db = Path(tmp) / "compile_commands.json"
430
+ db.write_text(json.dumps([{
431
+ "directory": tmp, "file": str(src),
432
+ "command": f"cc -Iinc @{rsp} -c a.c"}]))
433
+ asmdiff._DB_CACHE.clear()
434
+ self.assertEqual(
435
+ asmdiff.compile_commands_flags(str(db), str(src)),
436
+ ["-I", f"{tmp}/inc", "-specs=picolibc.specs",
437
+ "-I", f"{tmp}/rspinc"])
438
+
439
+ def test_relative_response_file_resolves_against_directory(self):
440
+ with tempfile.TemporaryDirectory() as tmp:
441
+ (Path(tmp) / "cflags").write_text("-DFROMRSP")
442
+ self.assertEqual(
443
+ asmdiff._expand_response_files(["@cflags"], tmp),
444
+ ["-DFROMRSP"])
445
+
446
+ def test_nested_response_files(self):
447
+ with tempfile.TemporaryDirectory() as tmp:
448
+ (Path(tmp) / "outer").write_text("@inner -DOUTER")
449
+ (Path(tmp) / "inner").write_text("-DINNER")
450
+ self.assertEqual(
451
+ asmdiff._expand_response_files(["@outer"], tmp),
452
+ ["-DINNER", "-DOUTER"])
453
+
454
+ def test_missing_response_file_warns_and_continues(self):
455
+ err = io.StringIO()
456
+ with contextlib.redirect_stderr(err):
457
+ out = asmdiff._expand_response_files(["-DKEEP", "@/nope/x"], "/b")
458
+ self.assertEqual(out, ["-DKEEP"])
459
+ self.assertIn("/nope/x", err.getvalue())
460
+
461
+
391
462
  class TestCompileCommandsFlags(unittest.TestCase):
392
463
  def _db(self, tmp, entries):
393
464
  path = Path(tmp) / "compile_commands.json"
@@ -477,15 +548,22 @@ def _inside(directory):
477
548
 
478
549
 
479
550
  class TestFindCompileCommands(unittest.TestCase):
480
- """Auto-discovery of compile_commands.json near the current directory."""
551
+ """Auto-discovery of compile_commands.json by walking up from the CWD."""
481
552
 
482
553
  def _touch_db(self, directory):
483
554
  directory.mkdir(parents=True, exist_ok=True)
484
555
  (directory / "compile_commands.json").write_text("[]")
485
556
 
486
- def test_search_order_cwd_build_parent_parentbuild(self):
557
+ def _repo(self, tmp):
558
+ """A fake repo root: .git bounds the walk so tests never escape
559
+ the tempdir and pick up a stray database further up."""
560
+ root = Path(tmp)
561
+ (root / ".git").mkdir()
562
+ return root
563
+
564
+ def test_nearer_hits_win(self):
487
565
  with tempfile.TemporaryDirectory() as tmp:
488
- root = Path(tmp)
566
+ root = self._repo(tmp)
489
567
  cwd = root / "sub"
490
568
  cwd.mkdir()
491
569
  for where, expect in [(root / "build", root / "build"),
@@ -497,14 +575,236 @@ class TestFindCompileCommands(unittest.TestCase):
497
575
  self.assertEqual(asmdiff.find_compile_commands(),
498
576
  str(expect / "compile_commands.json"))
499
577
 
578
+ def test_walks_up_from_nested_component_dir(self):
579
+ # components/amy/src is three levels below the project root where
580
+ # idf.py leaves build/compile_commands.json.
581
+ with tempfile.TemporaryDirectory() as tmp:
582
+ root = self._repo(tmp)
583
+ self._touch_db(root / "build")
584
+ cwd = root / "components" / "amy" / "src"
585
+ cwd.mkdir(parents=True)
586
+ with _inside(cwd):
587
+ self.assertEqual(asmdiff.find_compile_commands(),
588
+ str(root / "build" / "compile_commands.json"))
589
+
590
+ def test_stops_at_repository_root(self):
591
+ with tempfile.TemporaryDirectory() as tmp:
592
+ self._touch_db(Path(tmp)) # db ABOVE the repo root
593
+ repo = Path(tmp) / "repo"
594
+ repo.mkdir()
595
+ (repo / ".git").write_text("gitdir: elsewhere") # worktree form
596
+ cwd = repo / "src"
597
+ cwd.mkdir()
598
+ with _inside(cwd):
599
+ self.assertIsNone(asmdiff.find_compile_commands())
600
+
500
601
  def test_nothing_found_returns_none(self):
501
602
  with tempfile.TemporaryDirectory() as tmp:
502
- cwd = Path(tmp) / "a" / "b"
603
+ root = self._repo(tmp)
604
+ cwd = root / "a" / "b"
503
605
  cwd.mkdir(parents=True)
504
606
  with _inside(cwd):
505
607
  self.assertIsNone(asmdiff.find_compile_commands())
506
608
 
507
609
 
610
+ class TestObjectLabels(unittest.TestCase):
611
+ """Data labels must not be reported as functions."""
612
+
613
+ ASM = """\
614
+ \t.type\tscale, @function
615
+ scale:
616
+ \tentry\tsp, 32
617
+ \tretw.n
618
+ \t.size\tscale, .-scale
619
+ \t.type\t__func__$1, @object
620
+ \t.size\t__func__$1, 9
621
+ __func__$1:
622
+ \t.string\t"app_main"
623
+ \t.local\ts_queue
624
+ \t.comm\ts_queue,4,4
625
+ \t.lcomm\ts_tmp,8
626
+ """
627
+
628
+ def test_object_labels_skipped(self):
629
+ funcs = asmdiff.extract_functions(self.ASM)
630
+ self.assertEqual(list(funcs), ["scale"])
631
+
632
+ def test_arm_percent_function_type_still_reported(self):
633
+ asm = "\t.type\tf, %function\nf:\n\tbx\tlr\n\t.size\tf, .-f\n"
634
+ self.assertEqual(list(asmdiff.extract_functions(asm)), ["f"])
635
+
636
+ def test_untyped_label_still_treated_as_function(self):
637
+ # Hand-written asm often has no .type at all.
638
+ asm = "myfunc:\n\tret\n"
639
+ self.assertEqual(list(asmdiff.extract_functions(asm)), ["myfunc"])
640
+
641
+ def test_local_comm_literal_lines_not_counted(self):
642
+ asm = ("f:\n\tmov.n\ta2, a3\n\t.literal_position\n"
643
+ "\t.literal .LC1, 4096\n\t.local\tx\n\t.comm\tx,4,4\n"
644
+ "\tretw.n\n")
645
+ funcs = asmdiff.extract_functions(asm)
646
+ insns, _ = asmdiff.analyze(funcs["f"])
647
+ self.assertEqual(insns, 2)
648
+
649
+
650
+ class TestFlagsLike(unittest.TestCase):
651
+ """--flags-like: a source absent from the db borrows a named entry."""
652
+
653
+ def _db(self, tmp):
654
+ path = Path(tmp) / "compile_commands.json"
655
+ path.write_text(json.dumps([{
656
+ "directory": tmp, "file": f"{tmp}/real.c",
657
+ "command": "cc -Iinc -DESP_PLATFORM -c real.c"}]))
658
+ asmdiff._DB_CACHE.clear()
659
+ asmdiff._MISS_NOTED.clear()
660
+ return str(path)
661
+
662
+ def test_missing_source_borrows_named_entry(self):
663
+ with tempfile.TemporaryDirectory() as tmp:
664
+ db = self._db(tmp)
665
+ asmdiff.FLAGS_LIKE = f"{tmp}/real.c"
666
+ try:
667
+ err = io.StringIO()
668
+ with contextlib.redirect_stderr(err):
669
+ flags = asmdiff.compile_commands_flags(
670
+ db, f"{tmp}/copy.c", missing_ok=True)
671
+ finally:
672
+ asmdiff.FLAGS_LIKE = None
673
+ self.assertEqual(flags, ["-I", f"{tmp}/inc", "-DESP_PLATFORM"])
674
+ self.assertIn("real.c", err.getvalue()) # borrow is announced
675
+ # A satisfied borrow is not a miss: no apples-to-oranges state.
676
+ self.assertEqual(asmdiff._MISS_NOTED, set())
677
+
678
+ def test_applies_to_explicit_db_too(self):
679
+ with tempfile.TemporaryDirectory() as tmp:
680
+ db = self._db(tmp)
681
+ asmdiff.FLAGS_LIKE = f"{tmp}/real.c"
682
+ try:
683
+ with contextlib.redirect_stderr(io.StringIO()):
684
+ flags = asmdiff.compile_commands_flags(
685
+ db, f"{tmp}/copy.c", missing_ok=False)
686
+ finally:
687
+ asmdiff.FLAGS_LIKE = None
688
+ self.assertEqual(flags[-1], "-DESP_PLATFORM")
689
+
690
+ def test_bad_flags_like_target_is_an_error(self):
691
+ with tempfile.TemporaryDirectory() as tmp:
692
+ db = self._db(tmp)
693
+ asmdiff.FLAGS_LIKE = f"{tmp}/nowhere.c"
694
+ try:
695
+ with self.assertRaises(SystemExit):
696
+ asmdiff.compile_commands_flags(db, f"{tmp}/copy.c",
697
+ missing_ok=True)
698
+ finally:
699
+ asmdiff.FLAGS_LIKE = None
700
+
701
+ def test_miss_messages_suggest_flags_like(self):
702
+ with tempfile.TemporaryDirectory() as tmp:
703
+ db = self._db(tmp)
704
+ with self.assertRaises(SystemExit) as ctx: # hard error path
705
+ asmdiff.compile_commands_flags(db, f"{tmp}/other/real.c")
706
+ self.assertIn("--flags-like", str(ctx.exception))
707
+ err = io.StringIO() # soft-miss path
708
+ with contextlib.redirect_stderr(err):
709
+ asmdiff.compile_commands_flags(db, f"{tmp}/sub/real.c",
710
+ missing_ok=True)
711
+ self.assertIn("--flags-like", err.getvalue())
712
+
713
+
714
+ class TestMarkDbMisses(unittest.TestCase):
715
+ """Two-file comparisons where only one side got borrowed flags."""
716
+
717
+ def _entry(self, db):
718
+ return asmdiff.Target("gcc -O2", db, db_discovered=True)
719
+
720
+ def test_one_sided_miss_tags_and_warns(self):
721
+ asmdiff._MISS_NOTED.clear()
722
+ asmdiff._MISS_NOTED.add(("/db.json", str(Path("b.c").resolve())))
723
+ err = io.StringIO()
724
+ with contextlib.redirect_stderr(err):
725
+ tags = asmdiff.mark_db_misses(self._entry("/db.json"),
726
+ ["a.c", "b.c"], ("a.c", "b.c"))
727
+ self.assertEqual(tags, ["a.c", "b.c [no db entry]"])
728
+ self.assertIn("header", err.getvalue())
729
+
730
+ def test_both_missed_means_same_env_no_warning(self):
731
+ asmdiff._MISS_NOTED.clear()
732
+ for s in ("a.c", "b.c"):
733
+ asmdiff._MISS_NOTED.add(("/db.json", str(Path(s).resolve())))
734
+ err = io.StringIO()
735
+ with contextlib.redirect_stderr(err):
736
+ tags = asmdiff.mark_db_misses(self._entry("/db.json"),
737
+ ["a.c", "b.c"], ("a.c", "b.c"))
738
+ self.assertEqual(tags, ["a.c", "b.c"])
739
+ self.assertEqual(err.getvalue(), "")
740
+
741
+ def test_no_db_entry_untouched(self):
742
+ tags = asmdiff.mark_db_misses("gcc -O2", ["a.c", "b.c"],
743
+ ("a.c", "b.c"))
744
+ self.assertEqual(tags, ["a.c", "b.c"])
745
+
746
+
747
+ class TestMangledPairHint(unittest.TestCase):
748
+ def test_mangled_old_new_detected(self):
749
+ self.assertTrue(asmdiff.mangled_pair_hint(
750
+ ["_Z9old_scalef", "_Z9new_scalef"]))
751
+
752
+ def test_plain_c_names_no_hint(self):
753
+ self.assertFalse(asmdiff.mangled_pair_hint(["scale", "helper"]))
754
+
755
+ def test_mangled_but_not_old_new_no_hint(self):
756
+ self.assertFalse(asmdiff.mangled_pair_hint(["_Z6renderv"]))
757
+
758
+
759
+ class TestCompileFailureOutput(unittest.TestCase):
760
+ CMD = ["xtensa-gcc", "-O2"] + [f"-I/inc{i}" for i in range(50)] + ["a.c"]
761
+ STDERR = "\n".join(f"err line {i}" for i in range(60))
762
+
763
+ def test_default_trims_flags_and_stderr(self):
764
+ asmdiff.VERBOSE = False
765
+ with self.assertRaises(SystemExit) as ctx:
766
+ asmdiff._compile_failure(self.CMD, self.STDERR)
767
+ msg = str(ctx.exception)
768
+ self.assertIn("xtensa-gcc failed on a.c", msg)
769
+ self.assertNotIn("-I/inc0", msg) # no flag dump
770
+ self.assertIn("err line 0", msg)
771
+ self.assertNotIn("err line 30", msg) # stderr trimmed
772
+ self.assertIn("40 more stderr lines", msg)
773
+ self.assertIn("--verbose", msg)
774
+
775
+ def test_verbose_shows_everything(self):
776
+ asmdiff.VERBOSE = True
777
+ try:
778
+ with self.assertRaises(SystemExit) as ctx:
779
+ asmdiff._compile_failure(self.CMD, self.STDERR)
780
+ finally:
781
+ asmdiff.VERBOSE = False
782
+ msg = str(ctx.exception)
783
+ self.assertIn("-I/inc0", msg)
784
+ self.assertIn("err line 59", msg)
785
+
786
+ def test_short_stderr_not_annotated_with_more(self):
787
+ asmdiff.VERBOSE = False
788
+ with self.assertRaises(SystemExit) as ctx:
789
+ asmdiff._compile_failure(["gcc", "x.c"], "one error\n")
790
+ self.assertNotIn("more stderr", str(ctx.exception))
791
+
792
+
793
+ class TestFormatCalls(unittest.TestCase):
794
+ def test_short_list_unchanged(self):
795
+ self.assertEqual(asmdiff.format_calls(["a", "b"]), "a, b")
796
+
797
+ def test_empty_is_dash(self):
798
+ self.assertEqual(asmdiff.format_calls([]), "-")
799
+
800
+ def test_long_list_capped(self):
801
+ calls = [f"fn{i}" for i in range(12)]
802
+ out = asmdiff.format_calls(calls)
803
+ self.assertTrue(out.endswith("+4 more"))
804
+ self.assertIn("fn7", out)
805
+ self.assertNotIn("fn8,", out)
806
+
807
+
508
808
  class TestDiscoveredCompileCommands(unittest.TestCase):
509
809
  """compile_commands = true in a target / bare --compile-commands."""
510
810
 
File without changes
File without changes
File without changes
File without changes