Repository: dataform-co/dataform
Pull request #1962: Add `--check` option to format command
Author: login: AobaIwaki123
Created: 2025-04-22T13:40:39Z | Merged: 2025-04-26T13:52:30Z

## Overview

This PR implements the Feature Request to add a `--check` flag to the `format` command.

## Changes

- Added a new `--check` flag to the format command
- Modified the format command implementation to support check mode without modifying files
- Enhanced the output formatting to clearly show which files need formatting
- Made the command return exit code 1 if any files need formatting in check mode

## How it works

When used with `--check`, the command:
1. Analyzes each file that would be formatted
2. Compares the current content with what it would be if formatted
3. Reports files needing formatting without modifying them
4. Returns exit code 1 if any files need formatting

## Example usage

```bash
# Check if all files are formatted correctly without modifying them
dataform format --check

# Check specific files
dataform format --check --actions="definitions/my_model.sqlx" 
```

## Testing

I've tested both the regular format mode (which continues to work as before) and the new check mode.

In check mode:
- When all files are formatted correctly: displays success message and returns exit code 0
- When files need formatting: displays the list of files needing formatting and returns exit code 1

This implementation follows similar patterns used in other code formatting tools like Prettier, ESLint, and Black, making Dataform better suited for modern CI/CD pipelines and development workflows.

Closes #1961


Linked issue #1961: [Feature Proposal] Add `--check` flag to format command for CI/CD pipelines
## Overview
Currently, Dataform's `format` command modifies files to match the expected formatting style. However, there's no way to verify formatting without making changes, which is important for CI/CD pipelines and pre-commit hooks.

## Feature Request
Add a `--check` flag to the `format` command that:
1. Checks if files are properly formatted
2. Reports any files that need formatting
3. Returns a non-zero exit code if any files are improperly formatted, without modifying them

## Use Cases
- CI/CD pipelines to verify code formatting
- Pre-commit hooks to ensure properly formatted code
- Team workflows to enforce consistent style

## Example Usage
```bash
# Check if files are formatted correctly without modifying them
dataform format --check

# Check specific files
dataform format --check --actions="definitions/my_model.sqlx" 
```

## Expected Behavior
- When all files are correctly formatted:
  - Outputs success message
  - Returns exit code 0
- When some files need formatting:
  - Lists files that need formatting
  - Returns exit code 1

## Implementation Details
This would require minimal changes:
1. Add a `--check` flag to the format command
2. Compare formatted content with original content without writing changes
3. Return appropriate exit code based on comparison results

## Similar Features in Other Tools
This pattern is common in code formatting tools:
- Prettier: `prettier --check`
- ESLint: `eslint --check`
- Black (Python): `black --check`

This would improve Dataform's integration with modern CI/CD pipelines and development workflows.

Linked issue comments: user: login: Ceridan
created_at: 2025-04-22T16:06:11Z
body: Thank you a lot for creating and issue and PR!

Could you please elaborate on what problem we are trying to solve here? I think here is a difference between `formatting` and `linting`. The existing `format` command is actually formatter and it supposed to be used when we want to fix the file. It should be used in a pre-commit stage, not afterwards.

What actual problem we are trying to solve by adding `--check`? If we run it in some pipeline and see that some sqlx file is not properly formatted, what should we do in this case? This code is already in the repository in the wrong format.
author_association: CONTRIBUTOR
user: login: astorrs
created_at: 2025-04-22T17:18:38Z
body: Many companies don't support pre-commit hooks, so this would still allow validation that the formatter had been run on all files as part of a pull request check and could be set to prevent PR merge until the check was satisfied. 
author_association: NONE
user: login: AobaIwaki123
created_at: 2025-04-22T23:11:19Z
body: @Ceridan Thanks for taking the time to review — I’ve responded inline to each of your questions.

> **Could you please elaborate on what problem we are trying to solve here?**

*Check‑only* mode lets us fail fast in CI/CD without mutating files.  
Teams that **can’t enforce pre‑commit hooks** (monorepos, GUI clients, corporate policies) still need a deterministic way to block unformatted code before merge.

> **I think there is a difference between formatting and linting. The existing `format` command is actually a formatter and it’s supposed to be used when we want to fix the file. It should be used in a pre‑commit stage, not afterwards.**

Absolutely — `--check` is **still a formatter**, just read‑only:  
* same rules as `dataform format`  
* returns exit `0` (clean) or `1` (needs formatting)  
* never introduces new lint rules

Think Prettier `--check` / Black `--check`: identical logic, no mutations.

> **What actual problem are we solving by adding `--check`?**

Three main use‑cases:

1. **Deterministic pipelines** – avoid auto‑commits or re‑runs that clutter history.  
2. **Familiar developer UX** – mirrors Prettier, ESLint, Black, etc.  
3. **Branch‑protection rules** – “Dataform Format Check” can be required before merge.

> **If we run it in some pipeline and see that a *.sqlx* file is not properly formatted, what should we do? The code is already in the repository in the wrong format.**

The fix is identical to a failing ESLint or test suite:

```bash
# locally
dataform format
git add -u
git commit --amend        # or a new fixup commit
git push --force-with-lease
```

No extra commits, no noisy history — the red × turns green once formatted.

---

Hope this clarifies the intent and workflow.  
If there are edge‑cases or alternative scenarios we should cover, please let me know!

author_association: CONTRIBUTOR
user: login: Ceridan
created_at: 2025-04-25T14:40:39Z
body: Thank you for detailed explanation. You point is valid, I think we could add it. I will review your PR and we will discuss the change in comments their.
author_association: CONTRIBUTOR
user: login: Ceridan
created_at: 2025-04-26T14:05:59Z
body: The new binary will be released next week. Thanks a lot to @AobaIwaki123 for the contribution 👏 👏 👏 
author_association: CONTRIBUTOR

Diff:
diff --git a/cli/console.ts b/cli/console.ts
index 64eeda6c5..012ce7f81 100644
--- a/cli/console.ts
+++ b/cli/console.ts
@@ -234,7 +234,6 @@ export function printExecutedAction(
   const jobIdSuffix = jobIds.length > 0 ? ` (jobId: ${jobIds.join(", ")})` : "";
   switch (executedAction.status) {
     case dataform.ActionResult.ExecutionStatus.SUCCESSFUL: {
-
       switch (executionAction.type) {
         case "table": {
           writeStdOut(
@@ -250,7 +249,10 @@ export function printExecutedAction(
           writeStdOut(
             `${successOutput(
               `Assertion ${dryRun ? "dry run success" : "passed"}: `
-            )} ${assertionString(executionAction.target, executionAction.tasks.length === 0)}${jobIdSuffix}`
+            )} ${assertionString(
+              executionAction.target,
+              executionAction.tasks.length === 0
+            )}${jobIdSuffix}`
           );
           return;
         }
@@ -258,7 +260,10 @@ export function printExecutedAction(
           writeStdOut(
             `${successOutput(
               `Operation ${dryRun ? "dry run success" : "completed successfully"}: `
-            )} ${operationString(executionAction.target, executionAction.tasks.length === 0)}${jobIdSuffix}`
+            )} ${operationString(
+              executionAction.target,
+              executionAction.tasks.length === 0
+            )}${jobIdSuffix}`
           );
           return;
         }
@@ -371,15 +376,24 @@ export function printFormatFilesResult(
   formatResults: Array<{
     filename: string;
     err?: Error;
+    needsFormatting?: boolean;
   }>
 ) {
   const sorted = formatResults.sort((a, b) => a.filename.localeCompare(b.filename));
-  const successfulFormatResults = sorted.filter(result => !result.err);
+  const successfulFormatResults = sorted.filter(result => !result.err && !result.needsFormatting);
+  const needsFormattingResults = sorted.filter(result => !result.err && result.needsFormatting);
   const failedFormatResults = sorted.filter(result => !!result.err);
+
   if (successfulFormatResults.length > 0) {
     printSuccess("Successfully formatted:");
     successfulFormatResults.forEach(result => writeStdOut(result.filename, 1));
   }
+
+  if (needsFormattingResults.length > 0) {
+    printError("Files that need formatting:");
+    needsFormattingResults.forEach(result => writeStdErr(result.filename, 1));
+  }
+
   if (failedFormatResults.length > 0) {
     printError("Errors encountered during formatting:");
     failedFormatResults.forEach(result =>
diff --git a/cli/index.ts b/cli/index.ts
index 5806789a0..015e084a1 100644
--- a/cli/index.ts
+++ b/cli/index.ts
@@ -171,6 +171,7 @@ const watchOptionName = "watch";
 
 const dryRunOptionName = "dry-run";
 const runTestsOptionName = "run-tests";
+const checkOptionName = "check";
 
 const actionRetryLimitName = "action-retry-limit";
 
@@ -586,7 +587,17 @@ export function runCli() {
         format: `format [${projectDirMustExistOption.name}]`,
         description: "Format the dataform project's files.",
         positionalOptions: [projectDirMustExistOption],
-        options: [actionsOption],
+        options: [
+          actionsOption,
+          {
+            name: checkOptionName,
+            option: {
+              describe: "Check if files are formatted correctly without modifying them.",
+              type: "boolean",
+              default: false
+            }
+          }
+        ],
         processFn: async argv => {
           let actions = ["{definitions,includes}/**/*.{js,sqlx}"];
           if (actionsOption.name in argv && argv[actionsOption.name].length > 0) {
@@ -597,15 +608,35 @@ export function runCli() {
               glob.sync(action, { cwd: argv[projectDirMustExistOption.name] })
             )
             .flat();
-          const results: Array<{ filename: string; err?: Error }> = await Promise.all(
+
+          const isCheckMode = argv[checkOptionName];
+          const results: Array<{
+            filename: string;
+            err?: Error;
+            needsFormatting?: boolean;
+          }> = await Promise.all(
             filenames.map(async (filename: string) => {
               try {
-                await formatFile(path.resolve(argv[projectDirMustExistOption.name], filename), {
-                  overwriteFile: true
-                });
-                return {
-                  filename
-                };
+                const filePath = path.resolve(argv[projectDirMustExistOption.name], filename);
+                if (isCheckMode) {
+                  // In check mode, we don't modify files, just check if they need formatting
+                  const fileContent = fs.readFileSync(filePath).toString();
+                  const formattedContent = await formatFile(filePath, {
+                    overwriteFile: false
+                  });
+                  return {
+                    filename,
+                    needsFormatting: fileContent !== formattedContent
+                  };
+                } else {
+                  // Normal formatting mode
+                  await formatFile(filePath, {
+                    overwriteFile: true
+                  });
+                  return {
+                    filename
+                  };
+                }
               } catch (e) {
                 return {
                   filename,
@@ -614,7 +645,28 @@ export function runCli() {
               }
             })
           );
+
           printFormatFilesResult(results);
+
+          // Return error code if there are any formatting errors
+          const failedFormatResults = results.filter(result => !!result.err);
+          if (failedFormatResults.length > 0) {
+            printError(`${failedFormatResults.length} file(s) failed to format.`);
+            return 1;
+          }
+
+          // In check mode, return an error code if any files need formatting
+          if (isCheckMode) {
+            const filesNeedingFormatting = results.filter(result => result.needsFormatting);
+            if (filesNeedingFormatting.length > 0) {
+              printError(
+                `${filesNeedingFormatting.length} file(s) would be reformatted. Run the format command without --check to update.`
+              );
+              return 1;
+            }
+            printSuccess("All files are formatted correctly!");
+          }
+
           return 0;
         }
       }
diff --git a/cli/index_test.ts b/cli/index_test.ts
index ce5fab9ce..a50ee7d4f 100644
--- a/cli/index_test.ts
+++ b/cli/index_test.ts
@@ -304,4 +304,92 @@ select 1 as \${dataform.projectConfig.vars.testVar2}
       warehouseState: {}
     });
   });
+
+  test("test for format command", async () => {
+    const projectDir = tmpDirFixture.createNewTmpDir();
+    const npmCacheDir = tmpDirFixture.createNewTmpDir();
+    const workflowSettingsPath = path.join(projectDir, "workflow_settings.yaml");
+    const packageJsonPath = path.join(projectDir, "package.json");
+
+    // Initialize a project using the CLI, don't install packages.
+    await getProcessResult(
+      execFile(nodePath, [cliEntryPointPath, "init", projectDir, "dataform-open-source", "US"])
+    );
+
+    // Install packages manually to get around bazel read-only sandbox issues.
+    const workflowSettings = dataform.WorkflowSettings.create(
+      loadYaml(fs.readFileSync(workflowSettingsPath, "utf8"))
+    );
+    delete workflowSettings.dataformCoreVersion;
+    fs.writeFileSync(workflowSettingsPath, dumpYaml(workflowSettings));
+    fs.writeFileSync(
+      packageJsonPath,
+      `{
+  "dependencies":{
+    "@dataform/core": "${version}"
+  }
+}`
+    );
+    await getProcessResult(
+      execFile(npmPath, [
+        "install",
+        "--prefix",
+        projectDir,
+        "--cache",
+        npmCacheDir,
+        corePackageTarPath
+      ])
+    );
+
+    // Create a correctly formatted file
+    const formattedFilePath = path.join(projectDir, "definitions", "formatted.sqlx");
+    fs.ensureFileSync(formattedFilePath);
+    fs.writeFileSync(
+      formattedFilePath,
+      `
+config {
+  type: "table"
+}
+
+SELECT
+  1 AS test
+`
+    );
+
+    // Create a file that needs formatting (extra spaces, inconsistent indentation)
+    const unformattedFilePath = path.join(projectDir, "definitions", "unformatted.sqlx");
+    fs.ensureFileSync(unformattedFilePath);
+    fs.writeFileSync(
+      unformattedFilePath,
+      `
+config {   type:  "table"   }
+SELECT  1  as   test
+`
+    );
+
+    // Test with --check flag on a project with files needing formatting
+    const beforeFormatCheckResult = await getProcessResult(
+      execFile(nodePath, [cliEntryPointPath, "format", projectDir, "--check"])
+    );
+
+    // Should exit with code 1 when files need formatting
+    expect(beforeFormatCheckResult.exitCode).equals(1);
+    expect(beforeFormatCheckResult.stderr).contains("Files that need formatting");
+    expect(beforeFormatCheckResult.stderr).contains("unformatted.sqlx");
+
+    // Format the files (without check flag)
+    const formatCheckResult = await getProcessResult(
+      execFile(nodePath, [cliEntryPointPath, "format", projectDir])
+    );
+    expect(formatCheckResult.exitCode).equals(0);
+
+    // Test with --check flag after formatting
+    const afterFormatCheckResult = await getProcessResult(
+      execFile(nodePath, [cliEntryPointPath, "format", projectDir, "--check"])
+    );
+
+    // Should exit with code 0 when all files are properly formatted
+    expect(afterFormatCheckResult.exitCode).equals(0);
+    expect(afterFormatCheckResult.stdout).contains("All files are formatted correctly");
+  });
 });


Reviews:
user: login: Ceridan
body: Thank you a lot for the contribution and very detailed PR description. Very appreciate that!

Since we touched this code part, may I ask you to add basic tests to `index_test.ts` for formatting, please?
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T16:54:54Z
user: login: AobaIwaki123
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T22:06:18Z
user: login: AobaIwaki123
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T01:30:08Z
user: login: Ceridan
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T11:28:53Z
user: login: AobaIwaki123
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T11:31:41Z
user: login: Ceridan
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T11:37:06Z
user: login: AobaIwaki123
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T12:34:02Z
user: login: AobaIwaki123
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T12:49:21Z
user: login: Ceridan
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T12:54:08Z
user: login: AobaIwaki123
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T12:57:28Z
user: login: AobaIwaki123
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T12:59:57Z
user: login: AobaIwaki123
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T13:01:52Z
user: login: Ceridan
body: The changes looks good, thank you a lot for your time and willingness to improve the change. I left two minor comments.

I ran the whole test suite, the change looks fine.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T13:15:41Z
user: login: Ceridan
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T13:19:18Z
user: login: Ceridan
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T13:52:12Z

Review comments:
diff_hunk: @@ -614,7 +645,21 @@ export function runCli() {
               }
             })
           );
+
           printFormatFilesResult(results);
+
+          // In check mode, return an error code if any files need formatting
+          if (isCheckMode) {
path: cli/index.ts
user: login: Ceridan
body: I think we also need to return error code in case we have failures during formatting.
created_at: 2025-04-25T16:48:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -614,7 +645,21 @@ export function runCli() {
               }
             })
           );
+
           printFormatFilesResult(results);
+
+          // In check mode, return an error code if any files need formatting
+          if (isCheckMode) {
path: cli/index.ts
user: login: AobaIwaki123
body: Is this what you expected?

```ts
          // Return error code if there are any formatting errors
          const failedFormatResults = results.filter(result => !!result.err);
          if (failedFormatResults.length > 0) {
            printError(`${failedFormatResults.length} file(s) failed to format:\n`
              + failedFormatResults.map(result => `${result.filename}: ${result.err.message}`).join("\n")
            );
            return 1;
          }
```
created_at: 2025-04-25T22:06:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -614,7 +645,21 @@ export function runCli() {
               }
             })
           );
+
           printFormatFilesResult(results);
+
+          // In check mode, return an error code if any files need formatting
+          if (isCheckMode) {
path: cli/index.ts
user: login: AobaIwaki123
body: I created test in `cli/index_test.ts`
And run test locally, but I got error
Can I run test locally? Or should I push to test?

```sh
% bazel test //cli/... 
INFO: Analyzed 10 targets (0 packages loaded, 5 targets configured).
INFO: Found 9 targets and 1 test target...
ERROR: /home/aoba/tmp/dataform/test_credentials/BUILD:8:14: Action test_credentials/bigquery.json failed: (Exit 1): gcloud failed: error executing command external/gcloud_sdk/bin/gcloud kms decrypt '--ciphertext-file=test_credentials/bigquery.json.enc' '--plaintext-file=bazel-out/k8-py2-fastbuild/bin/test_credentials/bigquery.json' ... (remaining 4 arguments skipped)
ERROR: (gcloud.kms.decrypt) PERMISSION_DENIED: Permission 'cloudkms.cryptoKeyVersions.useToDecrypt' denied on resource 'projects/dataform-open-source/locations/global/keyRings/dataform-builder-keyring/cryptoKeys/dataform-builder-key' (or it may not exist).
INFO: Elapsed time: 3.026s, Critical Path: 2.71s
INFO: 3 processes: 3 internal.
FAILED: Build did NOT complete successfully
//cli:index_test                                                FAILED TO BUILD

FAILED: Build did NOT complete successfully
```

`cli/index_test.ts`

```ts
suite("@dataform/cli", ({ afterEach }) => {
...
  test("golden path with package.json", async () => {
...
    // Create a correctly formatted file
    const formattedFilePath = path.join(projectDir, "definitions", "formatted.sqlx");
    fs.ensureFileSync(formattedFilePath);
    fs.writeFileSync(
      formattedFilePath,
      `
config {
  type: "table"
}

SELECT
  1 AS test
`
    );

    // Create a file that needs formatting (extra spaces, inconsistent indentation)
    const unformattedFilePath = path.join(projectDir, "definitions", "unformatted.sqlx");
    fs.ensureFileSync(unformattedFilePath);
    fs.writeFileSync(
      unformattedFilePath,
      `
config {   type:  "table"   }
SELECT  1  as   test
`
    );

    // Test with --check flag on a project with files needing formatting
    const checkResult = await getProcessResult(
      execFile(nodePath, [cliEntryPointPath, "format", projectDir, "--check"])
    );

    // Should exit with code 1 when files need formatting
    expect(checkResult.exitCode).equals(1);
    expect(checkResult.stderr).contains("Files that need formatting");
    expect(checkResult.stderr).contains("unformatted.sqlx");

    // Format the files (without check flag)
    await getProcessResult(execFile(nodePath, [cliEntryPointPath, "format", projectDir]));

    // Test with --check flag after formatting
    const afterFormatCheckResult = await getProcessResult(
      execFile(nodePath, [cliEntryPointPath, "format", projectDir, "--check"])
    );

    // Should exit with code 0 when all files are properly formatted
    expect(afterFormatCheckResult.exitCode).equals(0);
    expect(afterFormatCheckResult.stdout).contains("All files are formatted correctly");
  });
});
```
created_at: 2025-04-26T01:30:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -614,7 +645,21 @@ export function runCli() {
               }
             })
           );
+
           printFormatFilesResult(results);
+
+          // In check mode, return an error code if any files need formatting
+          if (isCheckMode) {
path: cli/index.ts
user: login: Ceridan
body: > Is this what you expected?

Yes. But I think that we do not need the part which gets the list of failures because it will be printed when we call `printFormatFilesResult` method. 

created_at: 2025-04-26T11:28:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -614,7 +645,21 @@ export function runCli() {
               }
             })
           );
+
           printFormatFilesResult(results);
+
+          // In check mode, return an error code if any files need formatting
+          if (isCheckMode) {
path: cli/index.ts
user: login: AobaIwaki123
body: ok
I'll return exit code only

```ts
          // Return error code if there are any formatting errors
          const failedFormatResults = results.filter(result => !!result.err);
            return 1;
          }
```
created_at: 2025-04-26T11:31:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -614,7 +645,21 @@ export function runCli() {
               }
             })
           );
+
           printFormatFilesResult(results);
+
+          // In check mode, return an error code if any files need formatting
+          if (isCheckMode) {
path: cli/index.ts
user: login: Ceridan
body: > Can I run test locally? Or should I push to test?

Hm... this error you getting when running tests is interesting, unit tests shouldn't depend on `test_credentials`. Thank you for showing the error, I will try to fix it.

Could you try these options, just to double check:
1. `./scripts/run_tests` - run all the unit tests.
2. `bazel test //cli:index_test` - run the single test file you are interested in.

If it wouldn't work, just add your tests to PR.
created_at: 2025-04-26T11:37:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -614,7 +645,21 @@ export function runCli() {
               }
             })
           );
+
           printFormatFilesResult(results);
+
+          // In check mode, return an error code if any files need formatting
+          if (isCheckMode) {
path: cli/index.ts
user: login: AobaIwaki123
body: To remove environmental issue, I pulled repo again and ran `bazel test //cli:index_test`.
But still I got same error
`./scripts/run_tests` says same kinds error too

```sh
$ bazel test //cli:index_test
INFO: Analyzed target //cli:index_test (0 packages loaded, 0 targets configured).
INFO: Found 1 test target...
ERROR: /home/aoba/tmp-2/dataform/test_credentials/BUILD:8:14: Action test_credentials/bigquery.json failed: (Exit 1): gcloud failed: error executing command external/gcloud_sdk/bin/gcloud kms decrypt '--ciphertext-file=test_credentials/bigquery.json.enc' '--plaintext-file=bazel-out/k8-py2-fastbuild/bin/test_credentials/bigquery.json' ... (remaining 4 arguments skipped)
ERROR: (gcloud.kms.decrypt) PERMISSION_DENIED: Permission 'cloudkms.cryptoKeyVersions.useToDecrypt' denied on resource 'projects/dataform-open-source/locations/global/keyRings/dataform-builder-keyring/cryptoKeys/dataform-builder-key' (or it may not exist).
Target //cli:index_test failed to build
Use --verbose_failures to see the command lines of failed build steps.
INFO: Elapsed time: 2.890s, Critical Path: 2.56s
INFO: 7 processes: 5 internal, 2 worker.
FAILED: Build did NOT complete successfully
//cli:index_test                                                FAILED TO BUILD

FAILED: Build did NOT complete successfully
```

## To reproduce

```sh
$ git clone ...
# bazel is already installed globally
$ bazel run @nodejs//:yarn install
$ bazel build cli
$ ./scripts/run help
# Check built completed successfully
$ bazel test //cli:index_test
# ERROR: /home/aoba/tmp-2/dataform/test_credentials...
```
created_at: 2025-04-26T12:34:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -614,7 +645,21 @@ export function runCli() {
               }
             })
           );
+
           printFormatFilesResult(results);
+
+          // In check mode, return an error code if any files need formatting
+          if (isCheckMode) {
path: cli/index.ts
user: login: AobaIwaki123
body: This part might be a reason
I put my test in same section `golden path with package.json`

```ts
suite("@dataform/cli", ({ afterEach }) => {
...
  test("golden path with package.json", async () => {
...
    // Dry run the project.
    const runResult = await getProcessResult(
      execFile(nodePath, [
        cliEntryPointPath,
        "run",
        projectDir,
        "--credentials",
        path.resolve(process.env.RUNFILES, "df/test_credentials/bigquery.json"),
        "--dry-run",
        "--json",
        "--vars=testVar1=testValue1,testVar2=testValue2",
        "--default-location=europe",
        "--tags=someTag,someOtherTag"
      ])
    );
```
created_at: 2025-04-26T12:49:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -614,7 +645,21 @@ export function runCli() {
               }
             })
           );
+
           printFormatFilesResult(results);
+
+          // In check mode, return an error code if any files need formatting
+          if (isCheckMode) {
path: cli/index.ts
user: login: Ceridan
body: A quick workaround:

Temporary comment out line 51 in `//cli/BUILD` file (it has `//test_credentials:bigquery.json"`). It will allow you to run the tests, but the test you mentioned will fail, which is expected. It unblocks your change for now. Later, I will split the test or consider another permanent solution.
created_at: 2025-04-26T12:54:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -614,7 +645,21 @@ export function runCli() {
               }
             })
           );
+
           printFormatFilesResult(results);
+
+          // In check mode, return an error code if any files need formatting
+          if (isCheckMode) {
path: cli/index.ts
user: login: AobaIwaki123
body: Ok
Now I was trying same stuff
But in my environment I needed to comment out `const runResult = await getProcessResult( ...` too
[For Your Information]
created_at: 2025-04-26T12:57:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -614,7 +645,21 @@ export function runCli() {
               }
             })
           );
+
           printFormatFilesResult(results);
+
+          // In check mode, return an error code if any files need formatting
+          if (isCheckMode) {
path: cli/index.ts
user: login: AobaIwaki123
body: I checked my test is working correctly!
created_at: 2025-04-26T12:59:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -614,7 +645,21 @@ export function runCli() {
               }
             })
           );
+
           printFormatFilesResult(results);
+
+          // In check mode, return an error code if any files need formatting
+          if (isCheckMode) {
path: cli/index.ts
user: login: AobaIwaki123
body: I pushed my changes please review it
created_at: 2025-04-26T13:01:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -614,7 +645,27 @@ export function runCli() {
               }
             })
           );
+
           printFormatFilesResult(results);
+
+          // Return error code if there are any formatting errors
+          const failedFormatResults = results.filter(result => !!result.err);
+          if (failedFormatResults.length > 0) {
+            return 1;
path: cli/index.ts
user: login: Ceridan
body: Could we please add the following for consistency:

```
printError(`${failedFormatResults.length} file(s) failed to format.`);
```
The files themselves should be already printed as we discussed in the thread.

created_at: 2025-04-26T13:08:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -303,5 +303,53 @@ select 1 as \${dataform.projectConfig.vars.testVar2}
       },
       warehouseState: {}
     });
+
+    // Create a correctly formatted file
+    const formattedFilePath = path.join(projectDir, "definitions", "formatted.sqlx");
+    fs.ensureFileSync(formattedFilePath);
+    fs.writeFileSync(
+      formattedFilePath,
+      `
+config {
+  type: "table"
+}
+
+SELECT
+  1 AS test
+`
+    );
+
+    // Create a file that needs formatting (extra spaces, inconsistent indentation)
+    const unformattedFilePath = path.join(projectDir, "definitions", "unformatted.sqlx");
+    fs.ensureFileSync(unformattedFilePath);
+    fs.writeFileSync(
+      unformattedFilePath,
+      `
+config {   type:  "table"   }
+SELECT  1  as   test
+`
+    );
+
+    // Test with --check flag on a project with files needing formatting
+    const checkResult = await getProcessResult(
+      execFile(nodePath, [cliEntryPointPath, "format", projectDir, "--check"])
+    );
+
+    // Should exit with code 1 when files need formatting
+    expect(checkResult.exitCode).equals(1);
+    expect(checkResult.stderr).contains("Files that need formatting");
+    expect(checkResult.stderr).contains("unformatted.sqlx");
+
+    // Format the files (without check flag)
+    await getProcessResult(execFile(nodePath, [cliEntryPointPath, "format", projectDir]));
path: cli/index_test.ts
user: login: Ceridan
body: Could we please add `expect(checkResult.exitCode).equals(0);` check after this call?
created_at: 2025-04-26T13:12:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -303,5 +303,53 @@ select 1 as \${dataform.projectConfig.vars.testVar2}
       },
       warehouseState: {}
     });
+
+    // Create a correctly formatted file
path: cli/index_test.ts
user: login: Ceridan
body: One more thing actually. Could we extract formatting to a separate test? You can just copy paste the setup from the original test. It will be cleaner test will be more focused on formatting only.
created_at: 2025-04-26T13:19:18Z
author_association: CONTRIBUTOR

PR comments:
user: login: google-cla[bot]
created_at: 2025-04-22T13:40:44Z
body: Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this [failed invocation](https://github.com/dataform-co/dataform/pull/1962/checks?check_run_id=40942893024) of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.
author_association: NONE
user: login: AobaIwaki123
created_at: 2025-04-26T13:40:49Z
body: @caridan
I pushed changes you mentioned!
author_association: CONTRIBUTOR
user: login: Ceridan
created_at: 2025-04-26T13:51:58Z
body: > @Caridan I pushed changes you mentioned!

LGTM! Thank you a lot! I will merge your changes and plan to build a new binary on Monday.
author_association: CONTRIBUTOR
user: login: AobaIwaki123
created_at: 2025-04-26T13:55:31Z
body: > LGTM! Thank you a lot! I will merge your changes and plan to build a new binary on Monday.

Thanks! I appreciate your help and a lot of time to review!
I was really enjoyed! 
author_association: CONTRIBUTOR