Skip to content

fix(constructs): attribute a construct to the file that declares it - #1491

Open
sorccu wants to merge 5 commits into
mainfrom
simo/red-982-source-file
Open

sorccu wants to merge 5 commits into
mainfrom
simo/red-982-source-file

Conversation

@sorccu

@sorccu sorccu commented Sep 20, 2026

Copy link
Copy Markdown
Member

RED-982.

A construct was attributed to whichever check file the project parser was loading when the construct's module was first evaluated. The parser sets Session.checkFileAbsolutePath around each Session.loadFile(checkFile), and the Construct constructor copied it. That is right for a construct declared at the top of the check file being loaded, and wrong for everything else: a module a check file imports (a shared alerts.ts, a groups.ts no glob matches) is evaluated during its first importer's load and then served from the module cache, so every construct in it was attributed to that importer, and which importer that was depended on glob order.

Four things read that path and were wrong for such constructs: sourceFile in the deploy payload (shown in the deploy preview and recorded by the backend), __checkFilePath on checks (checkly test <file> filtering, --list and reporter grouping), resolveContentFilePath (relative entrypoint and other file props resolved against the importer's directory), and CheckGroupV1's testMatch glob root.

How

  • constructs/internal/declaring-file.ts reads the call stack in the Construct constructor (Error.prepareStackTrace call sites, restored right after). It skips the construct's own constructor chain (counted from new.target, so a subclass the user derives from a construct is skipped and a wrapper class is not), the CLI's own code (dist/ in the package, src/ from source) and Node internals, and takes the first remaining frame as the declaring file: the file that ran new, or the helper function that did so on a check file's behalf. When that frame sits under node_modules no user code created the construct (the CLI made it for a browserChecks glob entry or a Playwright check, or a test runner did) and the parser's current file stays the answer, so Session.checkFileAbsolutePath remains as the fallback. Both jiti and Node's ESM loader report the original file, as a path or a file:// URL; resolved imports come back at their physical location, the entry file as given.
  • A construct also remembers the check file the parser was loading when it was created. A relative entrypoint (and the other relative file props) resolves next to that check file, as before, and next to the declaring file when it does not exist there, so a construct declared in a shared module can keep its script next to the module while a factory called from a check file keeps resolving paths from the check file. CheckGroupV1 globs testMatch from the loading check file, as before, and from its declaring file when that finds nothing.
  • checkly test <file> matches its file patterns against the declaring file and the check file that was being loaded (filterByCheckFiles in test-filters.ts), so naming a check file still selects everything loaded through it, as before, and naming a shared module now selects the checks declared there. --list and reporter grouping use the declaring file.
  • Session.checkFilePath is gone; Session.checkFilesDirectory (the physical directory the project is parsed from) and Session.relativeCheckFilePath() replace it, and Check.__checkFilePath is derived from the construct's own absolute path. parseProject resolves its directory with fs.realpath first, so the base path, glob results and stack-derived paths agree even when the project is reached through a symlink.
  • checkly debug parse-project passes the git repository root to synthesize() like checkly deploy does, so its payload shows sourceFile (first commit; the sandbox spec asserts on it).

Other changes

  • A CheckGroupV1 declared in checkly.config.ts whose testMatch matched files crashed with a TypeError on the missing base path before reaching the rule that checks cannot be declared in the config file. It now fails with that rule's message.

Behaviour changes

  • sourceFile, the preview's file column and --list headings name the declaring module for constructs in shared modules.
  • checkly test <shared-module> selects the checks declared there. Naming a check file still selects the checks loaded through it (the first importer of a shared module, as before).
  • checkly test --list and the reporters group a check made by a helper under the helper's file.
  • A relative entrypoint on a check declared in a shared module resolves against that module's directory when it does not exist next to the importing check file.
  • Session.checkFilePath no longer exists on the Session class exported from checkly/constructs; it had no documented use.

Accepted

  • A construct created inside a helper function or wrapper class in the user's code is attributed to the helper's file (a subclass is not: the file that runs new is).
  • V8 emits one frame for a run of two or more consecutive subclasses without an explicit constructor; behind such a run a wrapper class's constructor frame is skipped too and the construct is attributed to the wrapper's caller.
  • Docs: no page states how testMatch or an entrypoint is resolved or which file checkly test <file> matches, so there is nothing to update.
  • A construct library installed under node_modules (not linked from workspace source) falls back to the parser's current file.
  • Cost: on a generated project of 2 000 API checks, debug parse-project --stats reports the parse step at about 90 ms after this change against about 65 ms before (roughly 12 µs per construct, subscriptions included; the warm figure over four runs).

Verification

  • declaring-file.spec.ts covers the frame filter (constructor frames, posix, file://, Windows drive letters and URLs, node: frames, node_modules first frame, empty stacks), the hook restoration, and a real capture through jiti from a temp directory: an imported module rather than its importer, a subclass instantiated from the entry file, a factory function, a wrapper class.
  • project-parser-source-file.spec.ts runs the packed CLI's debug parse-project against a fixture with an alert channel, a group, an API check and a browser check in modules the check glob does not match, imported by two check files, plus a factory-made browser check with a relative entrypoint, a helper-made CheckGroupV1 with a testMatch, a shared-module CheckGroupV1 whose testMatch matches only next to the module, and a subclass: every construct reports its declaring module, relative paths resolve next to the loading check file first and the declaring module second, and loading with a config that matches only the second check file gives the same attribution.
  • checkly-config-loader.spec.ts: a config-declared CheckGroupV1 with a matching testMatch fails with the config-file rule's message.
  • e2e test.spec.ts: checkly test --list shared/checks lists the shared check under src/shared/checks.ts; --list factory.check lists the helper-made check; --list b.check includes the shared checks it loads; --list a.check does not.
  • test-filters.spec.ts pins the selection rule offline.
  • Unit specs for Session.relativeCheckFilePath, Check.__checkFilePath (fallback path and config-file case) and the physical parse directory.

🤖 Generated with Claude Code

sorccu and others added 5 commits September 21, 2026 06:09
…ED-982]

`checkly debug parse-project` printed the synthesized payload without the
per-resource `sourceFile` that a deploy sends, because it never passed the
git repository root to `synthesize()`. It now resolves the root the same
way `checkly deploy` does, so the debug output shows which file each
resource is attributed to.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…RED-982]

A construct copied `Session.checkFileAbsolutePath`, the check file the
parser was loading, into `checkFileAbsolutePath`. A module a check file
imports is evaluated during its first importer's load and then cached, so
every construct in a shared alerts or groups module was attributed to that
importer, and glob order decided which one. The deploy payload's
`sourceFile`, `checkly test <file>` filtering, reporter grouping, relative
entrypoints and `CheckGroupV1.testMatch` all read that path.

The `Construct` constructor now reads the declaring file off the call
stack: the frame below the construct's own constructor chain (counted from
`new.target`, so a user subclass is skipped and a wrapper class counts as a
helper) and outside the CLI's own code. When that frame is a tool under
node_modules the parser's current file remains the answer, as before. The
loading check file is kept alongside: relative file paths and `testMatch`
globs resolve next to it first, as they always did, and next to the
declaring file when nothing is there; `checkly test <file>` matches either.

`Session.checkFilePath` is replaced by `Session.checkFilesDirectory` and
`relativeCheckFilePath()`; `Check.__checkFilePath` derives from the
construct's own path. `parseProject` and the config loader work on
physical paths, since loaders report resolved imports that way.

A `CheckGroupV1` declared in the config file with a matching `testMatch`
used to crash on the missing base path; it now fails with the rule that
checks cannot be declared there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ED-982]

A fixture project keeps an alert channel, a group, an API check, a browser
check and a legacy group in modules no check glob matches, imported by two
check files, next to a factory helper, a group helper and a subclass. The
sandbox spec runs the packed CLI's `debug parse-project` on it and asserts
every resource's `sourceFile`, where relative entrypoints and `testMatch`
globs resolve, and that loading only the second check file gives the same
attribution. The e2e `checkly test --list` cases cover selection by the
declaring module, by the loading check file and by neither.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The posix frame fixtures were parsed with the platform's path module, so
on Windows a drive-less file URL threw inside the helper and the frame
was skipped. They now pin posix rules, as the win32 case pins its own.
The expected physical paths came from the native realpath, which on
Windows expands 8.3 short names while the loader and the parser use the
JS realpath; the spec now uses the same one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…Windows [RED-982]

jiti normalises the path it is asked to load to forward slashes, so on
Windows the entry file's frame reads `C:/...` while the expectation is
built with backslashes. Resolved imports keep native separators. The
assertion now tolerates that one difference and nothing else.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant