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
107 changes: 107 additions & 0 deletions .github/scripts/simplicity-guardian/comment.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
'use strict';

const COMMENT_MARKER = '<!-- simplicity-guardian -->';

function formatGuardianLayer(violations, layer, label) {
const layerViolations = violations.filter(violation => violation.layer === layer);
if (layerViolations.length === 0) {
return `### ✅ ${label} — OK`;
}
const items = layerViolations.map(violation => `- \`${violation.file}:${violation.line}\` — ${violation.message}`).join('\n');
return `### ❌ ${label} (${layerViolations.length})\n${items}`;
}

function countEslintErrors(lintResult) {
return lintResult.flatMap(file => file.messages.filter(msg => msg.severity === 2)).length;
}

function formatEslintSection(lintResult) {
const errors = lintResult.flatMap(file =>
file.messages
.filter(msg => msg.severity === 2)
.map(msg => `- \`${file.filePath}:${msg.line}\` — ${msg.message}`)
);
if (errors.length === 0) {
return '### ✅ ESLint — OK';
}
return `### ⚠️ ESLint — ${errors.length} violation(s)\n${errors.join('\n')}`;
}

function buildViolationsComment(guardianResult, lintResult) {
const { violations } = guardianResult;
return [
COMMENT_MARKER,
'## 🛡️ Simplicity Guardian',
'',
formatGuardianLayer(violations, 'zero-dependency', 'Zero-dependency'),
formatGuardianLayer(violations, 'metaprogramming', 'Metaprogramming'),
formatGuardianLayer(violations, 'fan-out', 'Fan-out'),
formatEslintSection(lintResult),
].join('\n');
}

function buildCleanComment() {
return [COMMENT_MARKER, '## 🛡️ Simplicity Guardian', '', '✅ All checks passed'].join('\n');
}

async function findExistingComment(github, context) {
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
return comments.find(comment => comment.body.includes(COMMENT_MARKER)) ?? null;
}

async function upsertComment(github, context, body, existingComment) {
if (existingComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
}

module.exports = async function postPrComment({ github, context, core, guardianResult, lintResult }) {
if (!guardianResult || typeof guardianResult.summary?.total !== 'number') {
core.setFailed('Simplicity Guardian: invalid guardianResult — check that the guardian script ran successfully');
return true;
}
if (!Array.isArray(lintResult)) {
core.setFailed('Simplicity Guardian: invalid lintResult — check that ESLint ran successfully');
return true;
}

const eslintErrors = countEslintErrors(lintResult);
const hasViolations = guardianResult.summary.total > 0 || eslintErrors > 0;

const existingComment = await findExistingComment(github, context);

if (hasViolations) {
const body = buildViolationsComment(guardianResult, lintResult);
await upsertComment(github, context, body, existingComment);
core.info(`Simplicity Guardian: ${guardianResult.summary.total} guardian violation(s), ${eslintErrors} ESLint error(s)`);
} else if (existingComment) {
await upsertComment(github, context, buildCleanComment(), existingComment);
core.info('Simplicity Guardian: all checks passed — comment updated');
} else {
core.info('Simplicity Guardian: all checks passed');
}

return hasViolations;
};

module.exports.buildViolationsComment = buildViolationsComment;
module.exports.buildCleanComment = buildCleanComment;
module.exports.formatGuardianLayer = formatGuardianLayer;
module.exports.formatEslintSection = formatEslintSection;
module.exports.countEslintErrors = countEslintErrors;
32 changes: 32 additions & 0 deletions .github/scripts/simplicity-guardian/entrypoint.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use strict';

const fs = require('fs');
const path = require('path');

const GUARDIAN_JSON_PATH = '/tmp/guardian.json';
const LINT_JSON_PATH = '/tmp/lint.json';
const EMPTY_GUARDIAN_RESULT = {
violations: [],
summary: { total: 0, byLayer: { 'zero-dependency': 0, 'metaprogramming': 0, 'fan-out': 0 } },
};

function safeReadJson(filePath, fallback) {
try {
const content = fs.readFileSync(filePath, 'utf8');
return JSON.parse(content);
} catch (_err) {
return fallback;
}
}

module.exports = async function run({ github, context, core }) {
const guardianResult = safeReadJson(GUARDIAN_JSON_PATH, EMPTY_GUARDIAN_RESULT);
const lintResult = safeReadJson(LINT_JSON_PATH, []);

const postComment = require(path.join(__dirname, 'comment.cjs'));
const hasViolations = await postComment({ github, context, core, guardianResult, lintResult });

if (hasViolations) {
core.setFailed('Simplicity violations found — see PR comment for details');
}
};
4 changes: 2 additions & 2 deletions .github/workflows/mutation_testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ jobs:
with:
node-version: 22.x
- name: Install Deps
run: npm install
run: npm ci --ignore-scripts
- name: Install Stryker
run: npm install -g @stryker-mutator/core
run: npm install -g @stryker-mutator/core --ignore-scripts
- name: Run Stryker (and report results)
run: npm run test:mutation:ci
env:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/node_ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
node-version: ${{ matrix.node-version }}
- name: Build NPM package, run analysis and tests
run: |
npm install
npm ci --ignore-scripts
npm run lint
npm run test
env:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/npm_publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: 22
- run: npm ci
- run: npm ci --ignore-scripts
- run: npm test

publish-npm:
Expand Down
36 changes: 36 additions & 0 deletions .github/workflows/simplicity_guardian.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Simplicity Guardian

on:
pull_request:
branches:
- main

jobs:
guard:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Checkout repo
uses: actions/checkout@v6

- name: Use Node.js 22.x
uses: actions/setup-node@v6
with:
node-version: 22.x

- name: Install dependencies
run: npm ci --ignore-scripts

- name: Run simplicity guardian
run: node bin/simplicity-guardian.js --format json > /tmp/guardian.json || true

- name: Run ESLint (JSON output)
run: npm run lint -- --format json > /tmp/lint.json || true

- name: Post PR comment and evaluate result
uses: actions/github-script@v9
with:
script: |
const run = require('./.github/scripts/simplicity-guardian/entrypoint.cjs');
await run({ github, context, core });
22 changes: 21 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
These values act as a filter for every proposed change. Reject anything that violates them.

- **Zero dependencies** — no runtime npm dependencies in library code (see `doc/decisions/0003-zero-dependencies.md`). Refuse any change that adds an `import` from an external package inside `lib/` or `bin/`.
- **No dark magic** — no Proxies, no monkey-patching, no metaprogramming. Every line must be readable in a classroom without prior explanation.
- **No metaprogramming** — no Proxies, no `Object.defineProperty`, no `__proto__` assignment. Every property access must follow the normal prototype chain. Every line must be readable in a classroom without prior explanation.
- **OOP in the Smalltalk spirit** — behaviour through message sends and polymorphism, not conditionals over types. Add a method to an object before adding a `switch`/`if` chain in a caller.

## Commands
Expand Down Expand Up @@ -66,6 +66,26 @@ Node 22+ is required. The repo uses asdf; `.tool-versions` pins `nodejs 22.21.0`
- **`formatter_helpers.js`** — `runResultsWith(suiteName, ...factories)` runs tests and returns `{ runner, suite }`; `driveFormatter(formatter, runner, suite)` replays the full event stream into a formatter.
- **`runner_helpers.js` / `suites_factory.js`** — lower-level helpers for building runners and suites in tests.

## Simplicity Guardian

A CI check that blocks PRs violating Testy's simplicity contract. It runs on every PR against `main`.
See `doc/decisions/0017-simplicity-guardian.md` for the full decision record.

**Run locally:**
```bash
node bin/simplicity-guardian.js # text output, exits 1 on violations
node bin/simplicity-guardian.js --format json # machine-readable output
```

**Three layers checked:**
- **Zero-dependency** — no external package imports in `lib/` or `bin/`
- **Metaprogramming** — no `new Proxy`, `Object.defineProperty`, or `__proto__=`
- **Fan-out** — no file with more than 7 imports

**ESLint note:** `class-methods-use-this` is intentionally absent from Testy's ESLint config.
In Smalltalk-style OOP, methods are polymorphic message handlers — a method that doesn't reference
`this` today may still be meant for subclass override. OO purity takes precedence over JS idioms.

## Key conventions

- **Pure ES modules** — `"type": "module"` throughout. No CommonJS `require` in `lib/`, except `createRequire` for reading JSON files (e.g. `package.json`).
Expand Down
121 changes: 121 additions & 0 deletions bin/simplicity-guardian.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
#!/usr/bin/env node

import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';

const EXTERNAL_IMPORT_PATTERN = /^import\s+(?:.+?\s+from\s+)?['"](?!\.|\/|node:)(?<pkg>[^'"]+)['"]/u;
const FAN_OUT_THRESHOLD = 7;
// Object.defineProperty is also caught by ESLint no-restricted-syntax (AST-based).
// We keep it here too so the guardian produces a unified JSON report that drives the PR comment
// independently of ESLint's separate output.
const METAPROGRAMMING_PATTERNS = [
{ pattern: /new\s+Proxy\s*\(/u, label: 'Proxy' },
{ pattern: /Object\.definePropert(?:y|ies)\s*\(/u, label: 'Object.defineProperty' },
{ pattern: /__proto__\s*=/u, label: '__proto__ assignment' },
];

export function detectExternalImports(source, filePath) {
return source.split('\n')
.map((line, index) => ({ line, lineNumber: index + 1 }))
.filter(({ line }) => EXTERNAL_IMPORT_PATTERN.test(line))
.map(({ line, lineNumber }) => {
const { pkg: packageName } = line.match(EXTERNAL_IMPORT_PATTERN)?.groups ?? {};
return {
layer: 'zero-dependency',
file: filePath,
line: lineNumber,
message: `External import '${packageName ?? '(unknown)'}' — violates zero-dependency DNA`,
};
});
}

export function detectMetaprogramming(source, filePath) {
return source.split('\n')
.flatMap((line, index) =>
METAPROGRAMMING_PATTERNS
.filter(({ pattern }) => pattern.test(line))
.map(({ label }) => ({
layer: 'metaprogramming',
file: filePath,
line: index + 1,
message: `'${label}' is a metaprogramming pattern — Testy avoids runtime interception`,
})),
);
}

export function detectHighFanOut(source, filePath) {
// Count all imports regardless of source — total coupling is what matters
const importCount = source.split('\n')
.filter(line => /^import\s+.+?\s+from\s+/u.test(line))
.length;
if (importCount <= FAN_OUT_THRESHOLD) {
return [];
}
return [{
layer: 'fan-out',
file: filePath,
line: 1,
message: `${importCount} imports exceed threshold of ${FAN_OUT_THRESHOLD} — class may be doing too much`,
}];
}

export function analyzeFile(filePath) {
const source = fs.readFileSync(filePath, 'utf8');
return [
...detectExternalImports(source, filePath),
...detectMetaprogramming(source, filePath),
...detectHighFanOut(source, filePath),
];
}

function collectFiles(dir) {
return fs.readdirSync(dir, { withFileTypes: true })
.flatMap(entry => {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
return collectFiles(fullPath);
}
return entry.name.endsWith('.js') ? [fullPath] : [];
});
}

export function buildSummary(violations) {
return {
total: violations.length,
byLayer: {
'zero-dependency': violations.filter(violation => violation.layer === 'zero-dependency').length,
metaprogramming: violations.filter(violation => violation.layer === 'metaprogramming').length,
'fan-out': violations.filter(violation => violation.layer === 'fan-out').length,
},
};
}

export function formatTextOutput(violations) {
if (violations.length === 0) {
return '✅ Simplicity Guardian: all checks passed\n';
}
const lines = violations.map(violation => `${violation.file}:${violation.line} [${violation.layer}] ${violation.message}`);
return [...lines, `\n${violations.length} violation(s) found\n`].join('\n');
}

function run(dirs, format) {
const violations = dirs.flatMap(collectFiles).flatMap(analyzeFile);
const summary = buildSummary(violations);
if (format === 'json') {
process.stdout.write(`${JSON.stringify({ violations, summary }, null, 2)}\n`);
} else {
process.stdout.write(formatTextOutput(violations));
}
process.exit(violations.length > 0 ? 1 : 0);
}

const isMain = process.argv[1] === fileURLToPath(import.meta.url);
if (isMain) {
const cliArgs = process.argv.slice(2);
const formatIndex = cliArgs.indexOf('--format');
const format = formatIndex === -1 ? 'text' : cliArgs[formatIndex + 1];
const dirs = cliArgs.filter(arg => !arg.startsWith('--') && arg !== format);
run(dirs.length > 0 ? dirs : ['lib', 'bin'], format);
}
Loading
Loading