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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ bundled JavaScript to build or ship.
- **jscpd 5** — when `jscpdCheck` is enabled, the action uses a `jscpd`/`cpd` binary found
on `PATH`, otherwise it fetches it on demand with `npx --yes jscpd@5` (a one-time download).
To avoid the download, install jscpd on the runner (`npm i -g jscpd`).
- **YAML config** (`.dotnet-format.yml` / `.jscpd.yml`) is parsed on demand via
`npx -y js-yaml`; JSON config needs nothing extra.
- **YAML config** (`.dotnet-format.yml` / `.jscpd.yml`) is converted on demand using
`yq` if it is on `PATH` (GitHub-hosted runners ship it), otherwise a one-off pinned
`npx -y js-yaml@4.1.0`; JSON config needs nothing extra.

> Note on config keys: the granular `options`/`styleOptions`/`analyzersOptions`/`whitespaceOptions`
> blocks are toggled with `isEnabled`. The historical misspelling `isEabled` is still accepted for
> backward compatibility.
> blocks are toggled with `isEnabled`. The legacy misspelled `isEabled` key from older
> versions is **no longer supported** (breaking change) — use `isEnabled`.

## Demo

Expand Down
9 changes: 5 additions & 4 deletions WALKTHROUGH.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,12 @@ Config precedence is:
3. same config filename inside `workspace`

Arrays are concatenated and de-duplicated while preserving first occurrence order.
JSON config is parsed directly. YAML config is loaded on demand by calling
`npx -y js-yaml`, which keeps the action runtime dependency-free.
JSON config is parsed directly. YAML config is converted on demand by `yq` when it is
on `PATH` (GitHub-hosted runners ship it), otherwise a pinned `npx -y js-yaml@4.1.0`
fallback — which keeps the action runtime dependency-free.

The documented `isEnabled` key and historical misspelling `isEabled` are both
accepted. `isEnabled` wins when both are present.
Format blocks are toggled with the `isEnabled` key. The legacy misspelled `isEabled`
key from older versions is no longer supported (breaking change).

## Repository Layout

Expand Down
17 changes: 2 additions & 15 deletions __tests__/format-args.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@ import {
} from '../scripts/format-args.mjs';

describe('isEnabledBlock', () => {
it('prefers isEnabled, falls back to legacy isEabled, then default', () => {
it('reads isEnabled, otherwise the default', () => {
assert.equal(isEnabledBlock({ isEnabled: true }, false), true);
assert.equal(isEnabledBlock({ isEabled: true }, false), true);
assert.equal(isEnabledBlock({ isEnabled: false, isEabled: true }, true), false);
assert.equal(isEnabledBlock({ isEnabled: false }, true), false);
assert.equal(isEnabledBlock(undefined, true), true);
assert.equal(isEnabledBlock({}, false), false);
});
Expand Down Expand Up @@ -105,18 +104,6 @@ describe('generateFormatCommandArgs', () => {
]);
});

it('treats the legacy isEabled key as enabled (backward compatibility)', () => {
const config = {
projectFileName: 'test.csproj',
onlyChangedFiles: false,
options: { isEabled: true, verbosity: 'normal', noRestore: true, severity: 'error', verifyNoChanges: true }
};
const result = generateFormatCommandArgs(config, '/path/to/workspace', []);
assert.equal(result.length, 1);
assert.equal(result[0][0], 'format');
assert.equal(result[0][1], '/path/to/workspace/test.csproj');
});

it('prepends changed files to --include when onlyChangedFiles is active', () => {
const config = {
projectFileName: '',
Expand Down
80 changes: 80 additions & 0 deletions __tests__/load-config.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { makeLoader } from '../scripts/steps/load-config.mjs';

// Fake github-script `exec`: records calls and replays a scripted exit code + stdout.
function fakeExec(script) {
const calls = [];
const exec = {
async exec(cmd, args, opts) {
calls.push({ cmd, args });
const { code = 0, out = '' } = script(cmd, args) || {};
if (out && opts?.listeners?.stdout) {
opts.listeners.stdout(Buffer.from(out));
}
return code;
}
};
return { exec, calls };
}

const yqIo = { which: async tool => (tool === 'yq' ? '/usr/bin/yq' : '') };
const noYqIo = { which: async () => '' };

describe('makeLoader', () => {
let dir;
let yamlFile;
let jsonFile;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'loader-'));
yamlFile = path.join(dir, 'c.yaml');
jsonFile = path.join(dir, 'c.json');
fs.writeFileSync(yamlFile, 'threshold: 5');
fs.writeFileSync(jsonFile, '{"threshold":7}');
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});

it('returns null for a missing file', async () => {
const { exec } = fakeExec(() => ({}));
assert.equal(await makeLoader(exec, yqIo)(path.join(dir, 'nope.yaml')), null);
});

it('reads JSON natively without spawning anything', async () => {
const { exec, calls } = fakeExec(() => ({}));
assert.deepEqual(await makeLoader(exec, yqIo)(jsonFile), { threshold: 7 });
assert.equal(calls.length, 0);
});

it('uses yq when it is on PATH', async () => {
const { exec, calls } = fakeExec(cmd => (cmd.includes('yq') ? { code: 0, out: '{"threshold":5}' } : {}));
assert.deepEqual(await makeLoader(exec, yqIo)(yamlFile), { threshold: 5 });
assert.equal(calls.length, 1);
assert.match(calls[0].cmd, /yq$/);
assert.deepEqual(calls[0].args, ['-o=json', '.', yamlFile]);
});

it('falls back to a pinned npx js-yaml when yq is absent', async () => {
const { exec, calls } = fakeExec(cmd => (cmd === 'npx' ? { code: 0, out: '{"threshold":5}' } : {}));
assert.deepEqual(await makeLoader(exec, noYqIo)(yamlFile), { threshold: 5 });
assert.equal(calls.length, 1);
assert.equal(calls[0].cmd, 'npx');
assert.deepEqual(calls[0].args, ['-y', 'js-yaml@4.1.0', yamlFile]);
});

it('falls back to npx when yq exits non-zero', async () => {
const { exec, calls } = fakeExec(cmd => {
if (cmd.includes('yq')) return { code: 1, out: '' };
if (cmd === 'npx') return { code: 0, out: '{"threshold":5}' };
return {};
});
assert.deepEqual(await makeLoader(exec, yqIo)(yamlFile), { threshold: 5 });
assert.equal(calls.length, 2);
assert.match(calls[0].cmd, /yq$/);
assert.equal(calls[1].cmd, 'npx');
});
});
4 changes: 2 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ runs:
const { pathToFileURL } = require('node:url');
const url = pathToFileURL(`${process.env.GITHUB_ACTION_PATH}/scripts/steps/resolve-config.mjs`).href;
const { run } = await import(url);
await run({ github, context, core, exec });
await run({ github, context, core, exec, io });

- name: "🏃 Run dotnet format"
id: dotnet-format
Expand Down Expand Up @@ -401,7 +401,7 @@ runs:
const { pathToFileURL } = require('node:url');
const url = pathToFileURL(`${process.env.GITHUB_ACTION_PATH}/scripts/steps/jscpd-report.mjs`).href;
const { run } = await import(url);
await run({ github, context, core, exec });
await run({ github, context, core, exec, io });

- name: "📦 Upload jscpd report"
if: ${{ always() && inputs.jscpdCheck == 'true' }}
Expand Down
9 changes: 4 additions & 5 deletions scripts/format-args.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,13 @@ export const FormatType = {
};

/**
* Resolve whether a format block is enabled, accepting both the canonical
* `isEnabled` key and the legacy misspelled `isEabled` key for compatibility.
* Resolve whether a format block is enabled via its `isEnabled` key.
* @param {Record<string, any> | undefined} block
* @param {boolean} defaultValue
* @returns {boolean}
*/
export function isEnabledBlock(block, defaultValue) {
return block?.isEnabled ?? block?.isEabled ?? defaultValue;
return block?.isEnabled ?? defaultValue;
}

/**
Expand All @@ -34,7 +33,7 @@ export function isEnabledBlock(block, defaultValue) {
*/
function resolveEnabled(block, defaultValue) {
if (block) {
block.isEnabled = block.isEnabled ?? block.isEabled ?? defaultValue;
block.isEnabled = block.isEnabled ?? defaultValue;
}
}

Expand Down Expand Up @@ -115,7 +114,7 @@ export function generateFormatCommandArgs(config, workspace, changedFiles = [],
/**
* Build the default options object derived from the action inputs (ported from
* getOptions in src/format.ts). The enabled flag is intentionally omitted so a
* legacy `isEabled` key in user config is not masked; it is resolved afterwards.
* user's `isEnabled` is not masked by a default; it is resolved afterwards.
* @param {Record<string, any>} inputs
* @returns {Record<string, any>}
*/
Expand Down
4 changes: 2 additions & 2 deletions scripts/read-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
// JSON is parsed natively. YAML support is kept, but the YAML parser is injected
// (`parseYaml`, e.g. js-yaml's `load`) instead of imported, so this module stays
// dependency-free at action runtime. The github-script wrapper converts any
// `.yml`/`.yaml` config to a parsed object (via `npx -y js-yaml`) and the tests
// inject js-yaml directly.
// `.yml`/`.yaml` config to a parsed object (via `yq`, or a pinned `npx -y js-yaml`
// fallback — see steps/load-config.mjs) and the tests inject js-yaml directly.

import * as fs from 'node:fs';
import { basename, resolve } from 'node:path';
Expand Down
8 changes: 4 additions & 4 deletions scripts/steps/jscpd-report.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// build the PR message + job summary, emit annotations, set hasDuplicates, and fail
// the build when jscpdCheckAsError && over threshold. Ported 1:1 from
// duplicatedCheck in src/duplicated.ts. Receives github-script's
// { github, context, core, exec }; scanPath/outputDir + inputs arrive via env.
// { github, context, core, exec, io }; scanPath/outputDir + inputs arrive via env.

import * as fs from 'node:fs';
import path from 'node:path';
Expand All @@ -15,9 +15,9 @@ const ANNOTATION = { title: 'JSCPD Check' };
const REPORT_JSON = 'jscpd-report.json';

/**
* @param {{ github: any, context: any, core: any, exec: any }} ctx
* @param {{ github: any, context: any, core: any, exec: any, io: any }} ctx
*/
export async function run({ github, context, core, exec }) {
export async function run({ github, context, core, exec, io }) {
const env = process.env;
const workspace = env.WORKSPACE || '';
const scanPath = env.SCAN_PATH || workspace;
Expand All @@ -42,7 +42,7 @@ export async function run({ github, context, core, exec }) {
}

// Threshold: merged from the resolved jscpd config (matches getThreshold).
const cfg = await resolveConfig({}, jscpdConfigPath, scanPath, '.jscpd.json', makeLoader(exec));
const cfg = await resolveConfig({}, jscpdConfigPath, scanPath, '.jscpd.json', makeLoader(exec, io));
const threshold = cfg.threshold ?? 0;

const cwd = process.cwd();
Expand Down
57 changes: 46 additions & 11 deletions scripts/steps/load-config.mjs
Original file line number Diff line number Diff line change
@@ -1,28 +1,63 @@
// Shared config loader for resolveConfig: returns a loadObject(absPath) that yields
// null when the file is absent, parses JSON natively, and converts YAML
// out-of-process with `npx -y js-yaml` (so no YAML dependency is needed at action
// runtime). Used by both the dotnet format and jscpd resolve paths.
// null when the file is absent, parses JSON natively, and converts YAML to JSON
// out-of-process. It prefers a `yq` binary already on the runner (GitHub-hosted
// runners ship it) and otherwise falls back to a one-off, version-pinned
// `npx -y js-yaml@<ver>` download — so no YAML dependency is needed at runtime.

import * as fs from 'node:fs';
import { readData } from '../read-config.mjs';

// Pinned so the on-demand fallback is reproducible (mirrors `jscpd@5`).
const JS_YAML_SPEC = 'js-yaml@4.1.0';

/**
* @param {{ exec: (cmd: string, args: string[], opts: any) => Promise<number> }} exec github-script's exec
* Build the loadObject(absPath) used by resolveConfig.
* @param {{ exec: (cmd: string, args: string[], opts?: any) => Promise<number> }} exec github-script's exec
* @param {{ which: (tool: string, check?: boolean) => Promise<string> }} [io] github-script's io (for the `yq` lookup)
* @returns {(absPath: string) => Promise<any | null>}
*/
export function makeLoader(exec) {
export function makeLoader(exec, io) {
return async p => {
if (!fs.existsSync(p)) {
return null;
}
if (/\.ya?ml$/i.test(p)) {
let out = '';
await exec.exec('npx', ['-y', 'js-yaml', p], {
silent: true,
listeners: { stdout: d => (out += d.toString()) }
});
return JSON.parse(out);
return parseYaml(p, exec, io);
}
return readData(p);
};
}

/**
* Convert a YAML file to a JS object. Prefers an installed `yq` (present on
* GitHub-hosted runners); otherwise falls back to a pinned `npx -y js-yaml`.
* @param {string} p
* @param {{ exec: Function }} exec
* @param {{ which: Function } | undefined} io
* @returns {Promise<any>}
*/
async function parseYaml(p, exec, io) {
const yqPath = io ? await io.which('yq', false) : '';
if (yqPath) {
const out = [];
const code = await exec.exec(yqPath, ['-o=json', '.', p], {
silent: true,
ignoreReturnCode: true,
listeners: { stdout: d => out.push(d.toString()) }
});
if (code === 0) {
try {
return JSON.parse(out.join(''));
} catch {
// yq produced unexpected output; fall through to the js-yaml fallback.
}
}
}

const out = [];
await exec.exec('npx', ['-y', JS_YAML_SPEC, p], {
silent: true,
listeners: { stdout: d => out.push(d.toString()) }
});
return JSON.parse(out.join(''));
}
8 changes: 4 additions & 4 deletions scripts/steps/resolve-config.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// github-script wrapper (Phase 3, T6+T7): resolve the dotnet format config, fold in
// the PR changed-files lookup, and write the normalized run plan to
// $RUNNER_TEMP/df-config.json for the shell runner. Receives github-script's
// { github, context, core, exec }; all action inputs arrive via env (composite
// { github, context, core, exec, io }; all action inputs arrive via env (composite
// actions don't expose inputs to core.getInput).

import * as fs from 'node:fs';
Expand All @@ -15,9 +15,9 @@ const INCLUDED_FILE_TYPES = ['.cs', '.vb', '.cspoj', '.vbproj', '.fs', '.fsproj'
const ANNOTATION = { title: 'DOTNET FORMAT Check' };

/**
* @param {{ github: any, context: any, core: any, exec: any }} ctx
* @param {{ github: any, context: any, core: any, exec: any, io: any }} ctx
*/
export async function run({ github, context, core, exec }) {
export async function run({ github, context, core, exec, io }) {
const env = process.env;
const inputs = {
action: env.ACTION,
Expand All @@ -30,7 +30,7 @@ export async function run({ github, context, core, exec }) {
workspace: env.WORKSPACE || ''
};

const loadObject = makeLoader(exec);
const loadObject = makeLoader(exec, io);
const defaults = buildDefaultOptions(inputs);
const merged = await resolveConfig(defaults, inputs.dotnetFormatConfigPath, inputs.workspace, '.dotnet-format.json', loadObject);

Expand Down
Loading