mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-19 01:45:48 +08:00
chore: Improve Cubic performance and configuration clarity (#37193)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -432,6 +432,7 @@ Push to master/1.x
|
||||
| Monday 00:00 | `util-update-node-popularity.yml` | Node usage stats |
|
||||
| Monday 02:00 | `test-e2e-coverage-weekly.yml` | Weekly E2E coverage |
|
||||
| Saturday 22:00 | `test-evals-ai.yml` | AI workflow evals |
|
||||
| 1st of month 04:00 | `util-refresh-cubic-schema.yml` | Refresh vendored cubic schema |
|
||||
|
||||
---
|
||||
|
||||
@@ -553,6 +554,7 @@ Scripts in `.github/scripts/`:
|
||||
| `validate-docs-links.js`| Check doc URLs | `util-check-docs-urls.yml`|
|
||||
| `send-build-stats.mjs` | Build telemetry | `setup-nodejs` action |
|
||||
| `db-test-matrix.mjs` | DB test matrix from `postgres-versions.json` | `ci-pull-requests.yml` |
|
||||
| `quality/check-cubic-config.mjs` | Validate `cubic.yaml` against the vendored cubic schema; enforce its silent agent/character limits. `--refresh` re-pulls the schema | `test-workflow-scripts-reusable.yml`, `util-refresh-cubic-schema.yml` |
|
||||
| `probe-registry.mjs` | Registry path throughput probe (temporary) | `util-probe-registry.yml` |
|
||||
|
||||
### Branch Replay Scripts
|
||||
|
||||
@@ -17,16 +17,18 @@
|
||||
"@actions/github": "9.0.0",
|
||||
"@cyclonedx/cdxgen": "12.4.0",
|
||||
"@octokit/core": "7.0.6",
|
||||
"ajv": "8.20.0",
|
||||
"conventional-changelog": "7.2.0",
|
||||
"debug": "4.4.3",
|
||||
"json-with-bigint": "3.5.11",
|
||||
"glob": "13.0.6",
|
||||
"json-with-bigint": "3.5.11",
|
||||
"minimatch": "10.2.4",
|
||||
"semver": "7.7.4",
|
||||
"tempfile": "6.0.1",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.3.0",
|
||||
"conventional-changelog-angular": "8.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+18
@@ -17,6 +17,9 @@ importers:
|
||||
'@octokit/core':
|
||||
specifier: 7.0.6
|
||||
version: 7.0.6
|
||||
ajv:
|
||||
specifier: 8.20.0
|
||||
version: 8.20.0
|
||||
conventional-changelog:
|
||||
specifier: 7.2.0
|
||||
version: 7.2.0(conventional-commits-filter@5.0.0)
|
||||
@@ -42,6 +45,9 @@ importers:
|
||||
specifier: ^2.8.3
|
||||
version: 2.9.0
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^26.3.0
|
||||
version: 26.3.0
|
||||
conventional-changelog-angular:
|
||||
specifier: 8.3.0
|
||||
version: 8.3.0
|
||||
@@ -323,6 +329,9 @@ packages:
|
||||
'@types/http-cache-semantics@4.2.0':
|
||||
resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==}
|
||||
|
||||
'@types/node@26.3.0':
|
||||
resolution: {integrity: sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==}
|
||||
|
||||
'@types/normalize-package-data@2.4.4':
|
||||
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
|
||||
|
||||
@@ -1072,6 +1081,9 @@ packages:
|
||||
engines: {node: '>=0.8.0'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@8.3.0:
|
||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||
|
||||
undici@6.28.0:
|
||||
resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==}
|
||||
engines: {node: '>=18.17'}
|
||||
@@ -1523,6 +1535,10 @@ snapshots:
|
||||
|
||||
'@types/http-cache-semantics@4.2.0': {}
|
||||
|
||||
'@types/node@26.3.0':
|
||||
dependencies:
|
||||
undici-types: 8.3.0
|
||||
|
||||
'@types/normalize-package-data@2.4.4': {}
|
||||
|
||||
ajv-formats@3.0.1(ajv@8.20.0):
|
||||
@@ -2324,6 +2340,8 @@ snapshots:
|
||||
uglify-js@3.19.3:
|
||||
optional: true
|
||||
|
||||
undici-types@8.3.0: {}
|
||||
|
||||
undici@6.28.0: {}
|
||||
|
||||
undici@7.29.0: {}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Validates `cubic.yaml` against cubic's published schema and the limits it
|
||||
* enforces silently.
|
||||
*
|
||||
* cubic drops custom rules past the agent cap and truncates any rule past the
|
||||
* character ceiling without reporting either, and it never resolves a repo path
|
||||
* mentioned in prose — only `file_paths` entries. A rule that trips any of these
|
||||
* simply stops running, which is invisible until someone counts review comments.
|
||||
*
|
||||
* The schema is vendored rather than fetched so the check has no network
|
||||
* dependency. `--refresh` pulls the current copy from cubic.dev and exits without
|
||||
* validating — a refreshed schema that rejects the config should surface as a red
|
||||
* check on the refresh PR, not as a failure that stops the PR being opened.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 – config is valid
|
||||
* 1 – config has at least one violation
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import Ajv from 'ajv/dist/2020.js';
|
||||
import { parse } from 'yaml';
|
||||
|
||||
/** https://docs.cubic.dev/ai-review/custom-agents — only the first N agents take effect. */
|
||||
export const MAX_CUBIC_AGENTS = 5;
|
||||
|
||||
/** Description plus linked file contents; characters past this are dropped from the prompt. */
|
||||
export const MAX_RULE_CHARS = 10_000;
|
||||
|
||||
/** Fraction of the ceiling at which a rule is reported as close to silent truncation. */
|
||||
export const WARN_RATIO = 0.8;
|
||||
|
||||
/** Every markdown file here must be linked by some agent, or it silently does nothing. */
|
||||
export const RULES_DIR = '.agents/review-rules';
|
||||
|
||||
/** Vendored copy of the schema the `# yaml-language-server:` directive points at. */
|
||||
export const SCHEMA_PATH = '.github/scripts/quality/cubic-config.schema.json';
|
||||
|
||||
export const SCHEMA_URL = 'https://www.cubic.dev/schema/cubic-repository-config.schema.json';
|
||||
|
||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
|
||||
/**
|
||||
* Characters, not bytes — cubic's ceiling is a character count, and a byte count
|
||||
* overstates it for any non-ASCII content (an em dash is 3 bytes, one character).
|
||||
* `.length` counts UTF-16 code units, matching how the description is measured.
|
||||
*
|
||||
* @param {string} path - repo-relative
|
||||
* @returns {number} character count, or -1 when the path does not resolve
|
||||
*/
|
||||
export function fileCharacters(path) {
|
||||
try {
|
||||
return readFileSync(join(REPO_ROOT, path), 'utf8').length;
|
||||
} catch {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate against cubic's own schema. Catches what the hand-written checks below
|
||||
* cannot: a mistyped key inside `reviews` / `pr_descriptions` / `issues` (all
|
||||
* `additionalProperties: false`), a bad enum value, a wrong type.
|
||||
*
|
||||
* @param {unknown} config
|
||||
* @param {object} schema
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function schemaErrors(config, schema) {
|
||||
const ajv = new Ajv({ allErrors: true, strict: false });
|
||||
const validate = ajv.compile(schema);
|
||||
if (validate(config)) return [];
|
||||
|
||||
return (validate.errors ?? []).map((error) => {
|
||||
const path = error.instancePath || '/';
|
||||
const { allowedValues, additionalProperty } = error.params ?? {};
|
||||
|
||||
if (additionalProperty) {
|
||||
return `${path} has an unknown key \`${additionalProperty}\`.`;
|
||||
}
|
||||
|
||||
const allowed = allowedValues ? ` (allowed: ${allowedValues.join(', ')})` : '';
|
||||
return `${path} ${error.message}${allowed}`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown rule files on disk, repo-relative, excluding the README.
|
||||
*
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function ruleFiles() {
|
||||
try {
|
||||
return readdirSync(join(REPO_ROOT, RULES_DIR), { recursive: true, withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'README.md')
|
||||
.map((entry) => relative(REPO_ROOT, join(entry.parentPath, entry.name)))
|
||||
.sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} config - parsed cubic.yaml
|
||||
* @param {(path: string) => number} charsIn - characters in a linked file, -1 if missing
|
||||
* @param {string[]} [onDisk] - rule files that must each be linked by some agent
|
||||
* @returns {{ violations: string[], warnings: string[], ruleLengths: Record<string, number> }}
|
||||
*/
|
||||
export function checkConfig(config, charsIn, onDisk = []) {
|
||||
const violations = [];
|
||||
/** @type { string[] } */
|
||||
const warnings = [];
|
||||
/** @type { Record<string, number> } */
|
||||
const ruleLengths = {}
|
||||
const linked = new Set();
|
||||
|
||||
if (config?.version !== 1) {
|
||||
violations.push(`\`version\` must be 1, found ${JSON.stringify(config?.version)}.`);
|
||||
}
|
||||
|
||||
const rules = config?.reviews?.custom_rules ?? [];
|
||||
if (!Array.isArray(rules)) {
|
||||
violations.push('`reviews.custom_rules` must be a list.');
|
||||
return { violations, warnings, ruleLengths };
|
||||
}
|
||||
|
||||
if (rules.length > MAX_CUBIC_AGENTS) {
|
||||
const dropped = rules.slice(MAX_CUBIC_AGENTS).map((rule) => rule?.name ?? '(unnamed)');
|
||||
violations.push(
|
||||
`${rules.length} custom rules defined but only the first ${MAX_CUBIC_AGENTS} take effect. ` +
|
||||
`These never run: ${dropped.join(', ')}. Merge related rules instead of appending.`,
|
||||
);
|
||||
}
|
||||
|
||||
rules.forEach((rule, index) => {
|
||||
const label = rule?.name ? `"${rule.name}"` : `rule #${index + 1}`;
|
||||
|
||||
if (!rule?.name) {
|
||||
violations.push(`${label} has no \`name\`.`);
|
||||
}
|
||||
|
||||
const description = rule?.description ?? '';
|
||||
const filePaths = rule?.file_paths ?? [];
|
||||
|
||||
if (!description && filePaths.length === 0) {
|
||||
violations.push(`${label} needs a \`description\`, \`file_paths\`, or both.`);
|
||||
}
|
||||
|
||||
let total = description.length;
|
||||
for (const path of filePaths) {
|
||||
linked.add(path);
|
||||
const chars = charsIn(path);
|
||||
if (chars < 0) {
|
||||
violations.push(`${label} links \`${path}\`, which does not exist.`);
|
||||
continue;
|
||||
}
|
||||
total += chars;
|
||||
}
|
||||
|
||||
if (total > MAX_RULE_CHARS) {
|
||||
violations.push(
|
||||
`${label} is ${total.toLocaleString()} characters; everything past ` +
|
||||
`${MAX_RULE_CHARS.toLocaleString()} is dropped from the review prompt.`,
|
||||
);
|
||||
} else if (total > MAX_RULE_CHARS * WARN_RATIO) {
|
||||
warnings.push(
|
||||
`${label} is at ${Math.round((total / MAX_RULE_CHARS) * 100)}% of the ` +
|
||||
`${MAX_RULE_CHARS.toLocaleString()}-character ceiling. Trim it before adding more.`,
|
||||
);
|
||||
}
|
||||
|
||||
ruleLengths[label] = total
|
||||
});
|
||||
|
||||
for (const path of onDisk) {
|
||||
if (!linked.has(path)) {
|
||||
violations.push(`\`${path}\` is not linked by any agent, so it is never applied.`);
|
||||
}
|
||||
}
|
||||
|
||||
return { violations, warnings, ruleLengths };
|
||||
}
|
||||
|
||||
async function refreshSchema() {
|
||||
const response = await fetch(SCHEMA_URL);
|
||||
if (!response.ok) {
|
||||
console.error(`Could not fetch ${SCHEMA_URL}: HTTP ${response.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const schema = await response.json();
|
||||
writeFileSync(join(REPO_ROOT, SCHEMA_PATH), `${JSON.stringify(schema, null, '\t')}\n`);
|
||||
console.log(`Refreshed ${SCHEMA_PATH} from ${SCHEMA_URL}.`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.argv.includes('--refresh')) {
|
||||
await refreshSchema();
|
||||
return;
|
||||
}
|
||||
|
||||
const config = parse(readFileSync(join(REPO_ROOT, 'cubic.yaml'), 'utf8'));
|
||||
const schema = JSON.parse(readFileSync(join(REPO_ROOT, SCHEMA_PATH), 'utf8'));
|
||||
|
||||
const { violations, warnings, ruleLengths } = checkConfig(config, fileCharacters, ruleFiles());
|
||||
violations.unshift(...schemaErrors(config, schema));
|
||||
|
||||
console.log("Rule sizes:")
|
||||
for (const [label, ruleLength] of Object.entries(ruleLengths)) {
|
||||
console.log(` ${label}: ${ruleLength} characters (${Math.floor(ruleLength / MAX_RULE_CHARS * 100)}%)`);
|
||||
}
|
||||
|
||||
for (const warning of warnings) {
|
||||
console.log(`::warning file=cubic.yaml::${warning}`);
|
||||
}
|
||||
|
||||
if (violations.length === 0) {
|
||||
console.log(
|
||||
`cubic.yaml is valid (${config.reviews?.custom_rules?.length ?? 0}/${MAX_CUBIC_AGENTS} agents).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const violation of violations) {
|
||||
console.log(`::error file=cubic.yaml::${violation}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
await main();
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, relative } from 'node:path';
|
||||
import { after, before, describe, it } from 'node:test';
|
||||
|
||||
/**
|
||||
* Run with:
|
||||
* node --test .github/scripts/quality/check-cubic-config.test.mjs
|
||||
*/
|
||||
|
||||
let checkConfig, fileCharacters, schemaErrors, MAX_CUBIC_AGENTS, MAX_RULE_CHARS, WARN_RATIO;
|
||||
let schema;
|
||||
before(async () => {
|
||||
({ checkConfig, fileCharacters, schemaErrors, MAX_CUBIC_AGENTS, MAX_RULE_CHARS, WARN_RATIO } =
|
||||
await import('./check-cubic-config.mjs'));
|
||||
schema = JSON.parse(readFileSync(new URL('./cubic-config.schema.json', import.meta.url), 'utf8'));
|
||||
});
|
||||
|
||||
/** Violations only, for the cases that assert nothing about warnings. */
|
||||
const violationsOf = (...args) => checkConfig(...args).violations;
|
||||
|
||||
/** @param {Array<object>} rules */
|
||||
const config = (rules) => ({ version: 1, reviews: { custom_rules: rules } });
|
||||
|
||||
/** @param {string} name */
|
||||
const rule = (name) => ({ name, description: 'x' });
|
||||
|
||||
const noFiles = () => -1;
|
||||
const emptyFiles = () => 0;
|
||||
|
||||
describe('limits', () => {
|
||||
it('caps agents at 5', () => {
|
||||
assert.equal(MAX_CUBIC_AGENTS, 5);
|
||||
});
|
||||
|
||||
it('caps rule size at 10000', () => {
|
||||
assert.equal(MAX_RULE_CHARS, 10_000);
|
||||
});
|
||||
|
||||
it('warns at 80% of the ceiling', () => {
|
||||
assert.equal(WARN_RATIO, 0.8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkConfig', () => {
|
||||
it('accepts a config at the agent cap', () => {
|
||||
const rules = Array.from({ length: MAX_CUBIC_AGENTS }, (_, i) => rule(`r${i}`));
|
||||
assert.deepEqual(violationsOf(config(rules), emptyFiles), []);
|
||||
});
|
||||
|
||||
it('names every rule that will never run when the cap is exceeded', () => {
|
||||
const rules = Array.from({ length: MAX_CUBIC_AGENTS + 2 }, (_, i) => rule(`r${i}`));
|
||||
const [violation, ...rest] = violationsOf(config(rules), emptyFiles);
|
||||
|
||||
assert.deepEqual(rest, []);
|
||||
assert.match(violation, /only the first 5 take effect/);
|
||||
assert.match(violation, /r5, r6/);
|
||||
});
|
||||
|
||||
it('flags a linked file that does not exist', () => {
|
||||
const rules = [{ name: 'Frontend', description: '', file_paths: ['.agents/gone.md'] }];
|
||||
const violations = violationsOf(config(rules), noFiles);
|
||||
|
||||
assert.equal(violations.length, 1);
|
||||
assert.match(violations[0], /"Frontend" links `\.agents\/gone\.md`, which does not exist/);
|
||||
});
|
||||
|
||||
it('counts linked file length toward the character ceiling', () => {
|
||||
const rules = [{ name: 'Big', description: 'a'.repeat(9_000), file_paths: ['doc.md'] }];
|
||||
|
||||
assert.deepEqual(
|
||||
violationsOf(config(rules), () => 500),
|
||||
[],
|
||||
);
|
||||
|
||||
const violations = violationsOf(config(rules), () => 2_000);
|
||||
assert.equal(violations.length, 1);
|
||||
assert.match(violations[0], /"Big" is 11,000 characters/);
|
||||
});
|
||||
|
||||
it('measures a description in characters, not UTF-8 bytes', () => {
|
||||
// Each em dash is 3 bytes but one character. By bytes this is 12,000 and
|
||||
// would fail; by characters it is 4,000 and fits.
|
||||
const rules = [{ name: 'Dashes', description: '—'.repeat(4_000) }];
|
||||
|
||||
assert.deepEqual(violationsOf(config(rules), emptyFiles), []);
|
||||
});
|
||||
|
||||
it('flags a rule with neither a description nor linked files', () => {
|
||||
const violations = violationsOf(config([{ name: 'Empty' }]), emptyFiles);
|
||||
|
||||
assert.equal(violations.length, 1);
|
||||
assert.match(violations[0], /needs a `description`, `file_paths`, or both/);
|
||||
});
|
||||
|
||||
it('flags an unnamed rule', () => {
|
||||
const violations = violationsOf(config([{ description: 'x' }]), emptyFiles);
|
||||
|
||||
assert.equal(violations.length, 1);
|
||||
assert.match(violations[0], /rule #1 has no `name`/);
|
||||
});
|
||||
|
||||
it('flags a wrong schema version', () => {
|
||||
const violations = violationsOf({ version: 2, reviews: { custom_rules: [] } }, emptyFiles);
|
||||
|
||||
assert.equal(violations.length, 1);
|
||||
assert.match(violations[0], /`version` must be 1, found 2/);
|
||||
});
|
||||
|
||||
it('accepts a config with no custom rules at all', () => {
|
||||
assert.deepEqual(violationsOf({ version: 1 }, emptyFiles), []);
|
||||
});
|
||||
|
||||
it('flags a rule file that no agent links', () => {
|
||||
const rules = [{ name: 'Backend', description: '', file_paths: ['rules/linked.md'] }];
|
||||
const onDisk = ['rules/linked.md', 'rules/orphan.md'];
|
||||
const { violations } = checkConfig(config(rules), emptyFiles, onDisk);
|
||||
|
||||
assert.equal(violations.length, 1);
|
||||
assert.match(violations[0], /`rules\/orphan\.md` is not linked by any agent/);
|
||||
});
|
||||
|
||||
it('accepts rule files that are all linked', () => {
|
||||
const rules = [
|
||||
{ name: 'A', description: '', file_paths: ['rules/a.md'] },
|
||||
{ name: 'B', description: '', file_paths: ['rules/b.md'] },
|
||||
];
|
||||
const { violations } = checkConfig(config(rules), emptyFiles, ['rules/a.md', 'rules/b.md']);
|
||||
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ceiling warnings', () => {
|
||||
it('warns without failing once a rule passes 80%', () => {
|
||||
const rules = [{ name: 'Security', description: 'a'.repeat(8_500) }];
|
||||
const { violations, warnings } = checkConfig(config(rules), emptyFiles);
|
||||
|
||||
assert.deepEqual(violations, []);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0], /"Security" is at 85% of the 10,000-character ceiling/);
|
||||
});
|
||||
|
||||
it('stays quiet below the warning threshold', () => {
|
||||
const rules = [{ name: 'Security', description: 'a'.repeat(7_000) }];
|
||||
const { violations, warnings } = checkConfig(config(rules), emptyFiles);
|
||||
|
||||
assert.deepEqual(violations, []);
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
it('reports a violation rather than a warning once over the ceiling', () => {
|
||||
const rules = [{ name: 'Security', description: 'a'.repeat(10_001) }];
|
||||
const { violations, warnings } = checkConfig(config(rules), emptyFiles);
|
||||
|
||||
assert.equal(violations.length, 1);
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('schemaErrors', () => {
|
||||
/** @param {object} reviews */
|
||||
const cfg = (reviews) => ({ version: 1, reviews });
|
||||
|
||||
it('accepts the settings we actually use', () => {
|
||||
const errors = schemaErrors(
|
||||
cfg({ enabled: true, sensitivity: 'medium', incremental_commits: true, check_drafts: true }),
|
||||
schema,
|
||||
);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
it('rejects a sensitivity outside the enum and lists the allowed values', () => {
|
||||
const [error, ...rest] = schemaErrors(cfg({ sensitivity: 'strict' }), schema);
|
||||
|
||||
assert.deepEqual(rest, []);
|
||||
assert.match(error, /\/reviews\/sensitivity must be equal to one of the allowed values/);
|
||||
assert.match(error, /allowed: low, medium, high/);
|
||||
});
|
||||
|
||||
it('names a mistyped key rather than saying "additional properties"', () => {
|
||||
const [error, ...rest] = schemaErrors(cfg({ check_draft: true }), schema);
|
||||
|
||||
assert.deepEqual(rest, []);
|
||||
assert.equal(error, '/reviews has an unknown key `check_draft`.');
|
||||
});
|
||||
|
||||
it('rejects a wrong type', () => {
|
||||
const [error] = schemaErrors(cfg({ incremental_commits: 'sometimes' }), schema);
|
||||
assert.match(error, /\/reviews\/incremental_commits must be boolean/);
|
||||
});
|
||||
|
||||
it('rejects a per-rule field cubic does not support', () => {
|
||||
const config = cfg({ custom_rules: [{ name: 'A', description: 'x', severity: 'high' }] });
|
||||
const [error] = schemaErrors(config, schema);
|
||||
|
||||
assert.equal(error, '/reviews/custom_rules/0 has an unknown key `severity`.');
|
||||
});
|
||||
|
||||
it('requires a version', () => {
|
||||
const errors = schemaErrors({ reviews: {} }, schema);
|
||||
assert.ok(errors.some((e) => e.includes('version')));
|
||||
});
|
||||
});
|
||||
|
||||
describe('fileCharacters', () => {
|
||||
const REPO_ROOT = new URL('../../..', import.meta.url).pathname;
|
||||
let dir;
|
||||
|
||||
before(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'cubic-chars-'));
|
||||
});
|
||||
after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
/** @param {string} name @param {string} content */
|
||||
const write = (name, content) => {
|
||||
const abs = join(dir, name);
|
||||
writeFileSync(abs, content, 'utf8');
|
||||
return relative(REPO_ROOT, abs);
|
||||
};
|
||||
|
||||
it('counts characters, not UTF-8 bytes', () => {
|
||||
// 100 em dashes: 300 bytes, 100 characters. statSync().size would say 300.
|
||||
const path = write('dashes.md', '—'.repeat(100));
|
||||
assert.equal(fileCharacters(path), 100);
|
||||
});
|
||||
|
||||
it('counts ASCII unchanged, where bytes and characters agree', () => {
|
||||
const path = write('ascii.md', 'a'.repeat(100));
|
||||
assert.equal(fileCharacters(path), 100);
|
||||
});
|
||||
|
||||
it('returns -1 for a path that does not resolve', () => {
|
||||
assert.equal(fileCharacters('.agents/review-rules/does-not-exist.md'), -1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://cubic.dev/schema/cubic-repository-config.schema.json",
|
||||
"title": "Cubic Repository Configuration",
|
||||
"description": "JSON schema for cubic.yaml (and the legacy .cubic.yaml) so editors can validate and autocomplete repository settings.",
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"required": [
|
||||
"version"
|
||||
],
|
||||
"properties": {
|
||||
"version": {
|
||||
"const": 1,
|
||||
"description": "Schema version. Must always be 1."
|
||||
},
|
||||
"reviews": {
|
||||
"type": "object",
|
||||
"description": "AI review behavior and optional YAML-defined custom agents.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Master toggle for AI reviews on this repository."
|
||||
},
|
||||
"sensitivity": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high"
|
||||
],
|
||||
"description": "Controls how strictly Cubic flags issues."
|
||||
},
|
||||
"incremental_commits": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "If true, reviews new commits pushed to open PRs (only new issues are posted). If false, reviews only when the PR is first opened."
|
||||
},
|
||||
"check_drafts": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "If true, reviews draft PRs immediately when opened. If false, skips them."
|
||||
},
|
||||
"architecture_diagrams": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "If true, includes AI-generated architecture diagrams in review summaries."
|
||||
},
|
||||
"external_contributors_require_manual_review": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "If true, cubic skips automatic reviews for public-repository PRs from external contributors until a trusted installation member manually triggers a review."
|
||||
},
|
||||
"show_ai_feedback_buttons": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"deprecated": true,
|
||||
"description": "Deprecated and ignored. Retained so existing cubic.yaml files continue to validate."
|
||||
},
|
||||
"resolve_threads_when_addressed": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Automatically resolve GitHub review threads when cubic detects the issue has been addressed in a subsequent commit."
|
||||
},
|
||||
"merge_confidence_summary": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "If true, includes an AI-generated merge confidence summary on full reviews."
|
||||
},
|
||||
"auto_approve_behavior": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"disabled",
|
||||
"shadow",
|
||||
"live"
|
||||
],
|
||||
"description": "Controls whether auto-approval is disabled, simulated in shadow mode, or submitted live to GitHub."
|
||||
},
|
||||
"auto_approve": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"disabled",
|
||||
"always",
|
||||
"low_risk_only",
|
||||
"custom"
|
||||
],
|
||||
"description": "Auto-approve mode: disabled, always approve clean runs, use low-risk heuristics, or use a custom prompt."
|
||||
},
|
||||
"auto_approve_custom_prompt": {
|
||||
"type": "string",
|
||||
"description": "Custom criteria for auto-approval. Only used when auto_approve is custom."
|
||||
},
|
||||
"auto_approve_rules": {
|
||||
"$ref": "#/$defs/autoApproveRules"
|
||||
},
|
||||
"ultrareview": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"disabled",
|
||||
"manual",
|
||||
"automatic"
|
||||
],
|
||||
"description": "Master switch for ultrareviews. disabled blocks all ultrareviews (manual and automatic), manual allows only manual triggers (the default), automatic also enables the automatic triggers."
|
||||
},
|
||||
"auto_ultrareview": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"disabled",
|
||||
"high_risk_only",
|
||||
"custom"
|
||||
],
|
||||
"description": "Auto-trigger ultrareview mode: disabled, let cubic decide per PR using built-in high-risk criteria, or use a custom prompt."
|
||||
},
|
||||
"auto_ultrareview_custom_prompt": {
|
||||
"type": "string",
|
||||
"description": "Custom criteria describing which PRs warrant an ultrareview. Only used when auto_ultrareview is custom."
|
||||
},
|
||||
"auto_ultrareview_file_patterns": {
|
||||
"$ref": "#/$defs/globList",
|
||||
"description": "File or directory globs that always trigger an ultrareview. If any changed file in a PR matches, cubic runs an ultrareview regardless of the auto_ultrareview mode."
|
||||
},
|
||||
"custom_instructions": {
|
||||
"type": "string",
|
||||
"description": "Free-form reviewer guidance. Leading/trailing whitespace is trimmed."
|
||||
},
|
||||
"ignore": {
|
||||
"$ref": "#/$defs/ignore"
|
||||
},
|
||||
"custom_rules": {
|
||||
"type": "array",
|
||||
"description": "List of YAML-managed custom agents enforced before UI-defined agents.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/customRule"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"pr_descriptions": {
|
||||
"type": "object",
|
||||
"description": "Configuration for AI-authored pull request descriptions.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"generate": {
|
||||
"type": "boolean",
|
||||
"description": "Enable AI-authored PR descriptions."
|
||||
},
|
||||
"instructions": {
|
||||
"type": "string",
|
||||
"description": "Extra guidance inserted into generated summaries."
|
||||
},
|
||||
"cubic_review_link": {
|
||||
"type": "boolean",
|
||||
"description": "Include a link to review the PR in cubic (eg www.cubic.dev/pr/owner/repo/123)."
|
||||
},
|
||||
"skip_if_author_description": {
|
||||
"type": "boolean",
|
||||
"description": "Skip AI description generation on new PRs when the author already wrote a substantive description. Empty or template-only descriptions still get an AI description."
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"type": "object",
|
||||
"description": "Issue and PR comment fix settings.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"fix_with_cubic_buttons": {
|
||||
"type": "boolean",
|
||||
"description": "Toggle Fix with cubic buttons inside GitHub issues."
|
||||
},
|
||||
"pr_comment_fixes": {
|
||||
"type": "boolean",
|
||||
"description": "Allow cubic to make code changes when requested from PR comment threads."
|
||||
},
|
||||
"fix_commits_to_pr": {
|
||||
"type": "boolean",
|
||||
"description": "When true, fix commits are pushed directly to the PR branch instead of opening a new PR."
|
||||
},
|
||||
"auto_fix_sign_commits": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "When true, cubic signs the commits it pushes so GitHub marks them as verified. This applies only to the 'cubic' coding agent; the Cursor agent always signs its commits."
|
||||
},
|
||||
"coding_agent_provider": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"cubic",
|
||||
"cursor_cloud_agent"
|
||||
],
|
||||
"description": "Which coding agent applies automatic fixes. 'cubic' uses cubic's built-in agent; 'cursor_cloud_agent' dispatches a Cursor cloud agent (requires Cursor configuration)."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"globList": {
|
||||
"type": "array",
|
||||
"description": "List of glob patterns. Empty strings are ignored at runtime.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"ignore": {
|
||||
"type": "object",
|
||||
"description": "Conditional ignore filters that skip AI reviews.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"files": {
|
||||
"$ref": "#/$defs/globList",
|
||||
"description": "File globs to skip entirely."
|
||||
},
|
||||
"head_branches": {
|
||||
"$ref": "#/$defs/globList",
|
||||
"description": "Source branches that should not trigger reviews."
|
||||
},
|
||||
"base_branches": {
|
||||
"$ref": "#/$defs/globList",
|
||||
"description": "Target branches that should not trigger reviews."
|
||||
},
|
||||
"pr_labels": {
|
||||
"$ref": "#/$defs/globList",
|
||||
"description": "Labels that disable reviews when present."
|
||||
},
|
||||
"pr_titles": {
|
||||
"$ref": "#/$defs/globList",
|
||||
"description": "Wildcard matches applied to pull request titles."
|
||||
},
|
||||
"max_changed_lines": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 2147483647,
|
||||
"description": "Store the automatic-review threshold for reviewable added plus deleted lines. Positive integers up to 2,147,483,647 are preserved, but cubic applies an effective 50,000-line ceiling to every review. Manual triggers can bypass a lower configured threshold, but not the effective ceiling."
|
||||
}
|
||||
}
|
||||
},
|
||||
"autoApproveRules": {
|
||||
"type": "object",
|
||||
"description": "Rules that control whether auto-approval evaluation is allowed.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"exclude": {
|
||||
"$ref": "#/$defs/globList",
|
||||
"description": "Changed files matching these globs are never eligible for auto-approval."
|
||||
},
|
||||
"only_files": {
|
||||
"$ref": "#/$defs/globList",
|
||||
"description": "Every changed path must match at least one of these globs for the pull request to be eligible for auto-approval. Renamed files must match on both their current and previous paths."
|
||||
},
|
||||
"exclude_external_contributors": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "If true, public-repository PRs from external contributors are reviewed but never auto-approved."
|
||||
},
|
||||
"exclude_authors": {
|
||||
"$ref": "#/$defs/globList",
|
||||
"description": "PRs from a matching author are never auto-approved. Logins match case-insensitively and brackets are literal, so `renovate[bot]` and `*[bot]` both work."
|
||||
},
|
||||
"only_authors": {
|
||||
"$ref": "#/$defs/globList",
|
||||
"description": "Only PRs from a matching author can be auto-approved."
|
||||
},
|
||||
"exclude_head_branches": {
|
||||
"$ref": "#/$defs/globList",
|
||||
"description": "PRs whose source branch matches are never auto-approved."
|
||||
},
|
||||
"only_head_branches": {
|
||||
"$ref": "#/$defs/globList",
|
||||
"description": "Only PRs whose source branch matches can be auto-approved."
|
||||
},
|
||||
"exclude_base_branches": {
|
||||
"$ref": "#/$defs/globList",
|
||||
"description": "PRs targeting a matching base branch are never auto-approved."
|
||||
},
|
||||
"only_base_branches": {
|
||||
"$ref": "#/$defs/globList",
|
||||
"description": "Only PRs targeting a matching base branch can be auto-approved."
|
||||
},
|
||||
"exclude_labels": {
|
||||
"$ref": "#/$defs/globList",
|
||||
"description": "PRs carrying a matching label are never auto-approved. Labels match exactly, case-insensitively."
|
||||
},
|
||||
"only_labels": {
|
||||
"$ref": "#/$defs/globList",
|
||||
"description": "Only PRs carrying a matching label can be auto-approved. Labels match exactly, case-insensitively."
|
||||
},
|
||||
"exclude_titles": {
|
||||
"$ref": "#/$defs/globList",
|
||||
"description": "PRs whose title matches are never auto-approved."
|
||||
},
|
||||
"only_titles": {
|
||||
"$ref": "#/$defs/globList",
|
||||
"description": "Only PRs whose title matches can be auto-approved."
|
||||
}
|
||||
}
|
||||
},
|
||||
"customRule": {
|
||||
"type": "object",
|
||||
"description": "Definition of a YAML-managed custom agent.",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"anyOf": [
|
||||
{
|
||||
"required": [
|
||||
"description"
|
||||
]
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"file_paths"
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Rule title shown in the dashboard."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Explain what the rule enforces."
|
||||
},
|
||||
"file_paths": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 10,
|
||||
"description": "Ordered repo-relative instruction files. The first 10,000 characters of the description plus concatenated file text are used.",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 500
|
||||
}
|
||||
},
|
||||
"include": {
|
||||
"$ref": "#/$defs/globList",
|
||||
"description": "Optional include globs. Defaults to all files."
|
||||
},
|
||||
"exclude": {
|
||||
"$ref": "#/$defs/globList",
|
||||
"description": "Optional exclude globs that override includes."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,8 @@ jobs:
|
||||
.github/scripts/**
|
||||
scripts/licenses/**
|
||||
scripts/mutation-health/**
|
||||
cubic.yaml
|
||||
.agents/review-rules/**
|
||||
performance:
|
||||
packages/testing/performance/**
|
||||
packages/workflow/src/**
|
||||
|
||||
@@ -36,3 +36,7 @@ jobs:
|
||||
- name: Run tests
|
||||
id: run-tests
|
||||
run: npm test --prefix=.github/scripts
|
||||
|
||||
- name: Check cubic.yaml
|
||||
if: ${{ !cancelled() }}
|
||||
run: node .github/scripts/quality/check-cubic-config.mjs
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
name: 'Util: Refresh cubic schema'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 1st of the month at 04:00 UTC
|
||||
- cron: '0 4 1 * *'
|
||||
workflow_dispatch: # Allow manual trigger for testing
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
refresh-schema:
|
||||
name: Refresh vendored cubic schema
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'schedule' && github.repository == 'n8n-io/n8n')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# Every write goes through the scoped app token below, so the job's own
|
||||
# GITHUB_TOKEN only needs to read for checkout.
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
with:
|
||||
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
|
||||
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
|
||||
# Scope the token to what create-pull-request does: push the branch,
|
||||
# open the PR (the label already exists, so no issues access needed).
|
||||
permission-contents: write
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# create-pull-request unsets any persisted credential and configures
|
||||
# its own from `token`, so nothing here needs to survive the checkout.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-nodejs
|
||||
with:
|
||||
build-command: ''
|
||||
install-command: pnpm install --frozen-lockfile --dir ./.github/scripts --ignore-workspace
|
||||
cache-dependency-path: .github/scripts/pnpm-lock.yaml
|
||||
|
||||
# Refreshes only. Validation runs on the PR this opens, so a schema change
|
||||
# that rejects the current cubic.yaml shows up as a red check to act on
|
||||
# rather than a failure here that hides the change.
|
||||
- name: Refresh vendored schema
|
||||
run: node .github/scripts/quality/check-cubic-config.mjs --refresh
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
# Stage only the schema. setup-nodejs can leave unrelated files dirty,
|
||||
# and this PR should never carry anything but the refreshed copy.
|
||||
add-paths: .github/scripts/quality/cubic-config.schema.json
|
||||
commit-message: 'chore: Refresh vendored cubic config schema (no-changelog)'
|
||||
labels: 'automation:scheduled-update'
|
||||
title: 'chore: Refresh vendored cubic config schema (no-changelog)'
|
||||
body: |
|
||||
cubic's published config schema has changed since the vendored copy was last updated.
|
||||
|
||||
The copy at `.github/scripts/quality/cubic-config.schema.json` is what
|
||||
`pnpm check:cubic-config` validates `cubic.yaml` against, so it is vendored
|
||||
to keep that check off the network.
|
||||
|
||||
**Review the diff for new options worth adopting** — new keys under `reviews`
|
||||
are how cubic ships features, and the vendored copy is the only place they
|
||||
become visible to us.
|
||||
|
||||
If the `Workflow scripts` check is red, the new schema rejects something in
|
||||
`cubic.yaml`; fix the config in this PR.
|
||||
|
||||
_Generated by the monthly cubic schema refresh workflow._
|
||||
branch: refresh-cubic-schema
|
||||
base: master
|
||||
delete-branch: true
|
||||
author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
|
||||
committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
|
||||
Reference in New Issue
Block a user