diff --git a/README.md b/README.md index f28fc68..0d01e37 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/WALKTHROUGH.md b/WALKTHROUGH.md index 08aa065..dd7dfa3 100644 --- a/WALKTHROUGH.md +++ b/WALKTHROUGH.md @@ -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 diff --git a/__tests__/format-args.test.mjs b/__tests__/format-args.test.mjs index e518af9..3211743 100644 --- a/__tests__/format-args.test.mjs +++ b/__tests__/format-args.test.mjs @@ -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); }); @@ -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: '', diff --git a/__tests__/load-config.test.mjs b/__tests__/load-config.test.mjs new file mode 100644 index 0000000..03ebeb4 --- /dev/null +++ b/__tests__/load-config.test.mjs @@ -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'); + }); +}); diff --git a/action.yml b/action.yml index be6c1b6..b52d4f3 100644 --- a/action.yml +++ b/action.yml @@ -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 @@ -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' }} diff --git a/scripts/format-args.mjs b/scripts/format-args.mjs index 1c37560..9b81f67 100644 --- a/scripts/format-args.mjs +++ b/scripts/format-args.mjs @@ -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 | undefined} block * @param {boolean} defaultValue * @returns {boolean} */ export function isEnabledBlock(block, defaultValue) { - return block?.isEnabled ?? block?.isEabled ?? defaultValue; + return block?.isEnabled ?? defaultValue; } /** @@ -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; } } @@ -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} inputs * @returns {Record} */ diff --git a/scripts/read-config.mjs b/scripts/read-config.mjs index f7659f4..b8b7fc5 100644 --- a/scripts/read-config.mjs +++ b/scripts/read-config.mjs @@ -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'; diff --git a/scripts/steps/jscpd-report.mjs b/scripts/steps/jscpd-report.mjs index 74dce86..1915040 100644 --- a/scripts/steps/jscpd-report.mjs +++ b/scripts/steps/jscpd-report.mjs @@ -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'; @@ -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; @@ -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(); diff --git a/scripts/steps/load-config.mjs b/scripts/steps/load-config.mjs index a552e4f..a354348 100644 --- a/scripts/steps/load-config.mjs +++ b/scripts/steps/load-config.mjs @@ -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@` 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 }} exec github-script's exec + * Build the loadObject(absPath) used by resolveConfig. + * @param {{ exec: (cmd: string, args: string[], opts?: any) => Promise }} exec github-script's exec + * @param {{ which: (tool: string, check?: boolean) => Promise }} [io] github-script's io (for the `yq` lookup) * @returns {(absPath: string) => Promise} */ -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} + */ +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('')); +} diff --git a/scripts/steps/resolve-config.mjs b/scripts/steps/resolve-config.mjs index 02a420f..864ab3c 100644 --- a/scripts/steps/resolve-config.mjs +++ b/scripts/steps/resolve-config.mjs @@ -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'; @@ -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, @@ -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);