flowpact

API

Run flowpact from JavaScript, one function per CLI command, and build your own GitHub Action on it.

The flowpact npm package is also a library. It has one function per CLI command, and the CLI is built on these functions, so both give the same results. Use it to run flowpact inside your own program, such as your organization's own JavaScript action.

npm install --save-exact flowpact@0.12
import { lint } from 'flowpact';

const analysis = await lint({ root: 'path/to/repository' });
console.log(`${analysis.report.summary.errors} error(s)`);
process.exitCode = analysis.exitCode();
  • The package is an ES module (import, not require()) for Node.js 22 or later. Its types are included, and it has no dependencies.
  • Options are the command's flags in camelCase. Relative paths in options are read from the working directory, like the flags; paths are relative to root.
  • The functions print nothing and never exit. They return their results, and throw a FlowpactError for a problem with the options, the config or a plugin.
  • Nothing is logged unless you pass log.

Functions

FunctionCommandReturns
lint(options?)flowpact lintAnalysis
check(options?)flowpact checkAnalysis, with contracts
impact(options?)flowpact impactAnalysis, with impact
generate(options?)flowpact generateContractGeneration
graph(options?)flowpact graph --format jsonCallGraph
trace(options)flowpact trace --format jsonTraceResult
rules(options?)flowpact rules --format jsonRuleInfo[]
explain(codeOrName, options?)flowpact explainRuleDocs

Each returns a promise. flowpact lsp has no function: editors start the language server through the CLI. What the commands print in the terminal comes from these results: pretty() and the other methods of an Analysis, and the format functions for the rest.

Every function takes these options:

OptionFlagDescription
root--rootRepository root. Default: the working directory.
config--configConfig file. Default: .github/flowpact/flowpact.config.yml under the root, when it exists.
baseConfig--base-configOrganization defaults under the repository's config.
plugins--pluginPlugins to load besides those the config lists, such as your organization rules. repositoryPlugins does not affect them.
repositoryPlugins--no-pluginsfalse skips the plugins the config lists, which run the repository's code. Default: true.
log--debug, -v, -q{ level, write(record) }: receives each log record at level (default info) or above, with its level, scope, message, data and time. Like an error's, a record's message is written for people and can name the CLI's flags.

graph takes only root, config, baseConfig and log: it runs no rules.

lint, check and impact

import { check } from 'flowpact';

const analysis = await check({ paths: ['.github/workflows/deploy.yml'], only: ['FP401'] });
OptionFlagDescription
paths[paths…]Workflow and action files or directories to report on, relative to the root. Default: all. Not for impact.
only--onlyRule codes or names to run.
schema--no-schemafalse skips the validation against GitHub's workflow schema. Not for impact.
impact--impactlint and check: also check the release impact of the changes.
base, expect, title, labels--base, --expect, --title, --labelsImpact mode's baseline and declared impact; labels is an array. In GitHub Actions, impact mode reads them from the pull request, as the CLI does.
nowFLOWPACT_NOWThe current time, a Date, for the expiry of overrides.

Impact mode compares with the baseline commit, which must be in the clone: check out with fetch-depth: 0. On merge_group it is skipped (notes says so), and impact() then reports nothing.

Analysis

MemberDescription
reportThe JSON report: what --format json prints, as an object.
exitCode(failOn?)The CLI's exit code for --fail-on ('error' by default, 'warning' or 'never'): 1 when there are findings at that level, else 0.
annotations({ pathPrefix }?)One annotation per finding, for @actions/core's error(), warning() and notice(): level, message, title, file, startLine, endLine, and for a finding on one line startColumn and endColumn.
github({ pathPrefix }?)The annotations as GitHub workflow commands (--format github), one line each: printed in a GitHub Actions step, they annotate the run and the pull request, without @actions/core.
sarif({ pathPrefix }?)SARIF 2.1.0 for code scanning, as text.
markdown(options?)The Markdown report, for job summaries and pull request comments. Options: title, maxFindings (default 50), repoUrl and sha (to link locations to the files at that commit), includeGraph.
json({ includeGraph }?)The JSON report as --format json prints it.
pretty(options?)The terminal report. Options: color, width (default 100), ascii, hideInfo, hyperlinks.
contractscheck only: drift, breaking, and patch(), the regenerated contracts as a patch for git apply when they drifted (--patch).
impactImpact mode's verdict, as in the report: required, declared, ok, changes and baseline.
notesWhat the CLI prints as impact: … lines: impact mode skipped, or notes about its baseline.

pathPrefix is the root relative to the repository, when the root is a subdirectory of it. GitHub resolves annotation and SARIF paths against the repository; flowpact reports them relative to the root. pretty() and markdown() are safe to print in a GitHub Actions log, like the text of the other commands.

generate

import { writeFile } from 'node:fs/promises';
import { generate } from 'flowpact';

const result = await generate({ dryRun: true });
if (result.drift) await writeFile('contracts.patch', result.patch());

The options are paths, dryRun (--dry-run) and out (--out); for --patch, run with dryRun and write patch() yourself. The result has:

  • drift and breaking: whether any contract changes, and how many of the changes are breaking;
  • counts and entries: each contract file with its status (create, update, delete, unchanged) and changes;
  • dryRun: whether nothing was written (the option);
  • written: the files written, relative to the root (or to out); none with dryRun;
  • skipped: workflows and actions with YAML syntax errors, whose contracts were kept;
  • patch(): the changes as a patch for git apply, or undefined when nothing changes.

Like the CLI, generate refuses to write through a symlink or out of the repository.

graph, trace, rules and explain

They return what the command prints as JSON:

import { explain, graph, rules, trace } from 'flowpact';

const { nodes, edges } = await graph();
const { traces } = await trace({ symbol: 'pipeline.yml#inputs.config', up: true, depth: 5 });
const enabled = (await rules()).filter((rule) => rule.severity !== 'off');
const { summary, why, fix, docsUrl } = await explain('FP401');
  • graph(): nodes (workflows, actions, remote workflows, missing targets) and edges, who calls whom via which job or step.
  • trace({ symbol, up, depth }): one tree per matching symbol, as in flowpact trace; for a workflow or action without inputs, secrets or outputs, traces is empty and unit names it.
  • rules(): each rule's code, name, category, defaultSeverity, severity after the config, summary and docsUrl.
  • explain(codeOrName): the same for one rule, with why, fix, examples and generatedFiles, and scope when the rule has one.

Text

What the commands print, from what the functions return: the same text as the CLI's for the same options.

import { formatGraph, formatTrace, graph, terminalOptions, trace } from 'flowpact';

const calls = await graph();
console.log(formatGraph(calls, terminalOptions())); // the tree `flowpact graph` prints in this terminal
const diagram = formatGraph(calls, { format: 'mermaid' }); // for a job summary, in a mermaid code block
console.log(formatTrace(await trace({ symbol: 'pipeline.yml#inputs.config' })));
FunctionPrints what
formatGraph(graph, options?)flowpact graph. format: tree (default), mermaid (without code fences) or dot.
formatTrace(result, options?)flowpact trace
formatRules(rules, options?)flowpact rules
formatExplain(rule, options?)flowpact explain
formatGeneration(generation, options?)flowpact generate: what was written, or after a dryRun, what would be. diff: true adds the diff, as --dry-run prints it.

For the reports of lint, check and impact, use the Analysis methods: pretty() is --format pretty.

  • The options are those of pretty() except hideInfo: color, width (default 100), ascii and hyperlinks. The functions never look at the terminal: without options, the text has no colors, and what wraps (rules, explanations and pretty()) wraps at 100 columns. That is the text for files and job summaries.
  • terminalOptions() is for text printed to the standard output: it returns what the CLI would use there. Colors are off with NO_COLOR or FORCE_COLOR=0 and on with FORCE_COLOR; otherwise they are on in a terminal and in CI (on GitHub Actions runners too). The width is the terminal's, else COLUMNS, from 40 to 140 columns.
  • graph, trace and rules results are plain data, so JSON that the CLI printed with --format json renders as well.
  • Like the CLI's output, the text is safe to print in a GitHub Actions log: no line of it is read as a workflow command, whatever names the workflows use. github() is the exception by design: its lines are workflow commands, with every value escaped.
  • The text is written for people: its wording can change in any minor release. A program reads the results instead.

Errors

import { FlowpactError, lint } from 'flowpact';

try {
  await lint();
} catch (err) {
  if (!(err instanceof FlowpactError)) throw err;
  console.error(`${err.kind}: ${err.message}`, err.issues);
}

A FlowpactError is what the CLI reports with exit code 2. Act on its kind; the message is written for people and can name the CLI's flags. issues lists the individual problems (one per invalid config entry), and file names the config file when there is one.

kindWhat went wrong
usageAn option or path that cannot work: a path that does not exist, a root without workflows, a rule explain does not know, a symbol trace cannot find.
configThe config or base config is invalid, or names a rule that does not exist. So does an unknown rule in only.
pluginA plugin is missing, fails to load, exports no rules or registers an invalid rule.
unsafe-pathgenerate refused to write through a symlink or out of the repository.
impact-setupImpact mode has no baseline, or cannot read it.

Any other error is a bug (report it) or a plugin rule that threw.

TypeScript

The package's types describe the options and results above, the report (Report, Finding, Loc, Severity) and Annotation. They also type plugin rules: a type-only import keeps the compiled plugin free of imports, so it loads wherever flowpact runs.

rules/acme.ts
import type { RuleDefinition } from 'flowpact';

const prodNeedsApproval: RuleDefinition = {
  code: 'ACME601',
  name: 'prod-deploy-needs-approval',
  category: 'structure',
  defaultSeverity: 'error',
  docsUrl: 'https://wiki.acme.dev/ci/ACME601',
  docs: { summary: '…', why: '…', fix: '…' },
  check(ctx) {
    for (const wf of ctx.index.project.workflows.values()) {
      // …
    }
  },
};

export default [prodNeedsApproval];

defineRule(rule) does the same as a function that returns the rule, for a plugin that has flowpact installed where it runs. Compile the plugin to JavaScript before listing it: flowpact loads plugins with Node's import().

ctx.index, the data-flow graph, follows flowpact's internals: its types can change in any minor release, without the notice the rest of the API gets. Use the members listed in Custom rules.

Write your own action

A JavaScript action of your own runs flowpact with your defaults, rules, inputs and reports, where your policy allows only your organization's actions. It uses no other actions and installs nothing when it runs: the bundle holds flowpact and @actions/core.

acme/flowpact-action
├── action.yml
├── src/main.js
├── build.mjs
├── dist/index.mjs        # the bundle, committed
├── acme.base.yml         # organization defaults
└── rules/acme.mjs        # organization rules
npm install --save-exact flowpact@0.12 @actions/core
npm install --save-dev esbuild
node build.mjs            # after every update; commit dist/index.mjs

acme.base.yml and rules/acme.mjs are the base config and the plugin of Build on flowpact.

action.yml
name: Acme flowpact
description: flowpact with Acme's defaults, rules and reports
inputs:
  command:
    description: lint, or check when the repository commits contracts
    default: lint
  fail-on:
    description: Fail the step on findings at this level (error, warning or never)
    default: error
  sarif:
    description: Write SARIF for code scanning (its path is the sarif output)
    default: 'true'
outputs:
  sarif:
    description: The SARIF file, for github/codeql-action/upload-sarif
  errors:
    description: Number of errors
  warnings:
    description: Number of warnings
  patch:
    description: The regenerated contracts as a patch, when they drifted (check)
runs:
  using: node24
  main: dist/index.mjs
src/main.js
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import * as core from '@actions/core';
import { check, FlowpactError, lint } from 'flowpact';

// This file runs as dist/index.mjs; the defaults and rules ship next to it, in the action's directory.
const action = join(import.meta.dirname, '..');
// The repository's own plugins run its code: not on events that run a fork's code with secrets.
const UNTRUSTED = ['pull_request_target', 'workflow_run'];

try {
  const workspace = process.env.GITHUB_WORKSPACE ?? process.cwd();
  const run = core.getInput('command') === 'check' ? check : lint;
  const analysis = await run({
    root: workspace,
    baseConfig: join(action, 'acme.base.yml'),
    plugins: [join(action, 'rules/acme.mjs')],
    repositoryPlugins: !UNTRUSTED.includes(process.env.GITHUB_EVENT_NAME ?? ''),
    log: { level: 'warn', write: (record) => core.info(`${record.level}: ${record.message}`) },
  });

  // An annotation on the file and line of each finding.
  for (const { level, message, ...properties } of analysis.annotations()) {
    if (level === 'error') core.error(message, properties);
    else if (level === 'warning') core.warning(message, properties);
    else core.notice(message, properties);
  }

  // The Markdown report as the job summary, linking to the files at this commit.
  const { GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_SHA } = process.env;
  const repoUrl = `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}`;
  await core.summary.addRaw(analysis.markdown({ title: 'Acme flowpact', repoUrl, sha: GITHUB_SHA })).write();

  // SARIF for code scanning, and the regenerated contracts as a patch when they drifted (check). Both go to
  // RUNNER_TEMP, not the checkout, where a pull request could have committed a symlink in their place.
  if (core.getBooleanInput('sarif')) {
    const file = join(process.env.RUNNER_TEMP, 'flowpact.sarif');
    writeFileSync(file, analysis.sarif());
    core.setOutput('sarif', file);
  }
  const patch = analysis.contracts?.patch();
  if (patch) {
    const file = join(process.env.RUNNER_TEMP, 'flowpact-contracts.patch');
    writeFileSync(file, patch);
    core.setOutput('patch', file);
  }

  const { errors, warnings } = analysis.report.summary;
  core.setOutput('errors', errors);
  core.setOutput('warnings', warnings);
  if (analysis.exitCode(core.getInput('fail-on')) === 1)
    core.setFailed(`flowpact found ${errors} error(s) and ${warnings} warning(s)`);
} catch (err) {
  // A config, plugin or path problem: its message and each issue. Anything else is a bug, with its stack.
  if (!(err instanceof FlowpactError)) throw err;
  core.setFailed([err.message, ...err.issues.map((issue) => `  - ${issue}`)].join('\n'));
}
build.mjs
import { build } from 'esbuild';

await build({
  entryPoints: ['src/main.js'],
  bundle: true,
  platform: 'node',
  format: 'esm',
  target: 'node24',
  // .mjs: an ES module whatever package.json the action's repository has.
  outfile: 'dist/index.mjs',
  // Some of @actions/core's dependencies are CommonJS and call require().
  banner: { js: "import { createRequire } from 'node:module';\nconst require = createRequire(import.meta.url);" },
});

From one run, this gives the annotations, the job summary, the SARIF file for github/codeql-action/upload-sarif (its path is the sarif output), a failed step at fail-on, the patch when contracts drifted, and the counts as outputs. Repositories use it after actions/checkout, as uses: acme/flowpact-action@<commit-sha>.

  • @vercel/ncc works as well when the action's package.json has "type": "module" (npm init writes "commonjs", and ncc then fails with 'import' and 'export' may appear only with 'sourceType: module'). ncc build src/main.js -o dist writes dist/index.js and a dist/package.json that marks it as an ES module; commit both, and point main at dist/index.js.
  • A bundler's license report lists flowpact as one package. flowpact's own notices and SBOM list what it bundles: copy node_modules/flowpact/dist/THIRD_PARTY_LICENSES.txt and sbom.cdx.json next to yours.
  • When flowpact is updated (Renovate or Dependabot, as in Keeping it current), rebuild and commit the bundle. A CI job that rebuilds it and fails on a difference keeps the committed bundle in step with the source.

Stability

The API follows the same policy as the CLI (see What is stable): before 1.0, a minor release can change it in an incompatible way and says so under Breaking changes in its release notes; a patch release never does. RuleContext.index and the types it leads to are the exception above.

On this page