Skip to content

feat(safegres): source-level convention linter (C1–C4) in the constructive preset - #1613

Merged
pyramation merged 1 commit into
mainfrom
feat/safegres-lint-rules
Aug 2, 2026
Merged

feat(safegres): source-level convention linter (C1–C4) in the constructive preset#1613
pyramation merged 1 commit into
mainfrom
feat/safegres-lint-rules

Conversation

@pyramation

Copy link
Copy Markdown
Contributor

Summary

Adds a source-level convention linter to safegres: house-style rules that read function definitions (pg_get_functiondef) rather than the catalog, and enables the first four in the safegres:constructive preset. This is the ESLint-for-SQL layer we discussed — stable rule codes, per-rule severity, and ESLint/Prettier-style inline waivers with a mandatory reason for the one case we actually allow (dynamic SQL).

The lint module lives under packages/safegres/src/lint/* and is deliberately pure source → findings with zero pg imports, so it stays mechanically liftable into a standalone @pgsql/lint the day a second consumer appears. Dependency direction stays safegres → lint → pgsql-parser; no backward edge.

The four rules

Code id Sev Detects
C1 no-set-search-path high function-level SET search_path and set_config('search_path', …)
C2 no-variable-conflict medium a #variable_conflict directive
C3 require-qualified-refs low an unqualified relation reference (ignores schema-qualified refs and CTE names)
C4 no-dynamic-sql high EXECUTE / EXECUTE … USING / FOR … IN EXECUTE

C4 can't be proven read-only statically (the executed string is opaque), so every site is flagged and must be waived inline with a categorized reason (lookup-only / codegen); a reasonless waiver does not suppress it. Waived findings are not dropped — they surface as acknowledged (accepted-risk) findings carrying their reason, off the score.

Inline suppressions (ESLint/Prettier style)

-- safegres-disable-next-line no-dynamic-sql -- lookup-only: building an IN-list of integers
EXECUTE format('SELECT ... WHERE id = ANY(%L)', ids);

EXECUTE 'REFRESH MATERIALIZED VIEW app.mv';  -- safegres-disable-line no-dynamic-sql -- codegen: fixed DDL

-- safegres-disable no-dynamic-sql -- lookup-only: whole block probes the catalog
...
-- safegres-enable no-dynamic-sql

-- safegres-disable-file no-set-search-path -- vendored extension shim

A directive with no rule id applies to every convention rule.

Wiring

  • registry.ts: adds C1–C4 with a new scope: 'function-src'. allAstRulesDisabled only inspects policy-ast, so the convention rules never hold policy parsing open — they run their own pass.
  • types.ts: Finding.category gains 'convention' (renderers/SARIF treat category as an opaque tag, so no switch needs updating).
  • presets.ts: recommended carries 'C*': 'off' (house style, not a universal fact — the big-tent presets that extend it inherit off); constructive turns them on (C1 high, C2 medium, C3 low, C4 high).
  • audit.ts: when any C* rule is enabled, lints every function definition and maps problems to Findings. The function name rides in the table slot so exposure/overrides/sorting key on it exactly like table findings; suppressed problems become acknowledged findings with context.reason / context.suppressionScope. Function introspection is now memoized so the linter and --call-graph share one round-trip.
const enabledLintRules = LINT_RULES.filter(r => resolved.rules.get(r.code)?.enabled !== false);
if (enabledLintRules.length > 0) {
  for (const fn of await getFunctions()) {
    if (!fn.definition) continue; // non-SQL/plpgsql langs have no definition
    const { problems, suppressed } = await lintDefinition(fn.definition, fn.language, subject, { rules });
    problems.forEach(p => findings.push(lintFinding(fn, subject, p)));
    suppressed.forEach(s => findings.push(lintFinding(fn, subject, s, /*acknowledged*/ true)));
  }
}

An unparseable definition yields no lint findings (opaque bodies are the call-graph's concern, not the linter's).

Test plan

  • New __tests__/lint.test.ts (15 pure unit tests): rule codes/reason policy, C1–C4 detection, all suppression scopes, unparseable input.
  • New __tests__/lint-audit.test.ts (6 tests, pgsql-test): C1/C3/C4 fire through the real audit pipeline over a live DB; a reasoned C4 waiver becomes an acknowledged finding with its reason; a clean fully-qualified function yields nothing; preset on/off wiring.
  • Extended the existing presets.test.ts "configures rather than off" invariant to carve out C* (house-style opt-in, mirroring the existing minimal exception).
  • pnpm build, pnpm lint, pnpm test all green (357 tests).

Design issue: constructive-io/constructive-planning#1376

Link to Devin session: https://app.devin.ai/sessions/af81a09043504701874ca63e67a9cd4b
Requested by: @pyramation

@pyramation pyramation self-assigned this Aug 2, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@pyramation
pyramation merged commit 3d5cd3c into main Aug 2, 2026
16 checks passed
@pyramation
pyramation deleted the feat/safegres-lint-rules branch August 2, 2026 18:56
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