Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jobs:
run: |
node checks/check.test.mjs
node checks/check-code.test.mjs
node checks/check-config.test.mjs
node checks/check-trace.test.mjs
node checks/check-stack.test.mjs
node checks/progress.test.mjs
Expand Down
30 changes: 30 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,36 @@ dist/
build/
coverage/

# The design method (impeccable) is installed per project at its current release, never
# vendored: `node checks/design-method.mjs --install` puts it here, `maintain` refreshes it.
.agents/skills/impeccable/

# The block below is impeccable's own, copied from its README (v3.5.0, "Keeping .impeccable out
# of git"). Its working files are ephemeral; the shared artifacts it names stay tracked, which is
# why .impeccable/ is not ignored wholesale.
# impeccable-ignore-start
# Ephemeral output, runtime state, and per-dev overrides.
# Unanchored: .impeccable may sit at the repo root or under a nested
# workspace (apps/web/.impeccable/...); anchored patterns would miss it.
# Shared artifacts stay tracked: config.json, live/config.json,
# design.json, critique/*.md.
.impeccable/config.local.json
.impeccable/hook.cache.json
.impeccable/hook.pending.json
.impeccable/*.png
.impeccable/live/server.json
.impeccable/live/sessions/
.impeccable/live/previews/
.impeccable/live/annotations/
.impeccable/live/cache/
.impeccable/live/manual-edit-apply-transaction.json
.impeccable/live/manual-edit-events.jsonl
.impeccable/live/manual-edit-evidence/
.impeccable/live/pending-manual-edits.json
.impeccable/live/deferred-svelte-component-accepts.json
.impeccable/live/*.png
# impeccable-ignore-end

# Secrets: never in the repo, no exceptions
.env
.env.*
Expand Down
5 changes: 3 additions & 2 deletions checks/check-code.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ export const codeChecks = ({ root, cfg, tree, textFiles, isVendored, fail, lines
const looksLikeCode = /^\s*(\/\/|#)\s*(.*[;{}]\s*$|(const|let|var|function|def |import |return |if\s*\(|for\s*\())/;
for (const f of tree.files.filter((x) => CODE_EXT.has(extname(x)))) {
const r = rel(root, f);
if (r.startsWith('checks/')) continue;
// What another project left in its own comments is not this project's discipline to keep.
if (r.startsWith('checks/') || isVendored(r)) continue;
let run = 0;
lines(f).forEach((line, i) => {
run = looksLikeCode.test(line) ? run + 1 : 0;
Expand All @@ -122,7 +123,7 @@ export const codeChecks = ({ root, cfg, tree, textFiles, isVendored, fail, lines
const cap = cfg.budgets.codeFileMaxLines ?? 500;
for (const f of tree.files.filter((x) => CODE_EXT.has(extname(x)))) {
const r = rel(root, f);
if ((cfg.codeFileCapExclude || []).some((x) => r.startsWith(x) || r.endsWith(x))) continue;
if (isVendored(r)) continue;
const content = lines(f);
if (content.length <= cap) continue;
const marker = content.map(commentOn).find((c) => c !== null && /^\s*checks:allow-length\b/.test(c));
Expand Down
15 changes: 15 additions & 0 deletions checks/check-code.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,19 @@ expectClean('code-file-cap-exclude', ({ put }) => {
put('src/vendor/lib.js', 'export const x = 1;\n'.repeat(510));
});

// A declared third-party payload is code this project did not write: neither its length budget
// nor its comment discipline is ours to enforce. The declaration is the only difference between
// this fixture and the two failing ones above, and the secrets gate still reads every line of it.
expectClean('code-gates-skip-a-declared-payload', ({ put }) => {
put('checks/config.json', JSON.stringify({
denylist: [],
budgets: { agentsMdLines: 150, stateMdLines: 150, skillMdLines: 500, skillDescriptionChars: 1024 },
allowedEmptyDirs: [], secretScanExclude: ['checks/'],
thirdParty: [{ path: 'vendor/upstream/', why: 'installed at its current release, not written here' }],
}));
put('vendor/upstream/big.js', 'export const x = 1;\n'.repeat(510));
put('vendor/upstream/dead.js', 'export const x = 1;\n// const old = 2;\n// function dead() {\n// return old;\n');
put('vendor/upstream/apology.js', '// patched for now\n');
});

report('code-gate');
93 changes: 93 additions & 0 deletions checks/check-config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// What checks/config.json means, and the gate that keeps it honest. The config is the one file
// that can weaken every other gate, in the same commit as the violation it hides, so it gates
// itself here. This file also owns the declaration the rest of the checks read: which paths this
// project did not write. Composed into the registry by check.mjs, like the other gate families.

import { readFileSync } from 'node:fs';
import { join } from 'node:path';

// Paths a project declares as somebody else's work: an installed methodology, a vendored SDK.
// One list, in the open, so the exemption is a declaration and never a silent skip. Matching is
// by prefix, which survives an upstream rename of anything inside the payload.
export function thirdPartyMatcher(cfg) {
const prefixes = ((cfg && cfg.thirdParty) || [])
.map((e) => (e && typeof e.path === 'string' ? e.path : ''))
.filter(Boolean);
return (r) => prefixes.some((p) => r === p.replace(/\/+$/, '') || r.startsWith(p));
}

// The same matcher for a reader that has the repo root rather than the parsed config (the
// document walk in links.mjs). A config that cannot be read declares nothing: the gates then
// measure everything, which is the safe direction to fail in.
export function thirdPartyForRoot(root) {
try {
return thirdPartyMatcher(JSON.parse(readFileSync(join(root, 'checks', 'config.json'), 'utf8')));
} catch {
return () => false;
}
}

export const configChecks = ({ cfg, fail }) => ({
'config-invariants'() {
// Both invariants come from a rule written down elsewhere, never from taste.
const cap = cfg.budgets?.agentFileHardCapLines;
// 200 is not a preference: past it an agent rulebook stops being read in full, so a higher
// cap does not buy a longer file, it buys a file that silently stops governing. The two
// ways to break it read differently, so they are reported differently.
if (cap !== undefined && !(Number.isInteger(cap) && cap > 0)) {
fail(`checks/config.json budgets.agentFileHardCapLines is ${JSON.stringify(cap)}, which is not a positive whole number of lines: agent-file-cap would fall back to 200 and the value would govern nothing.`);
} else if (cap !== undefined && cap > 200) {
fail(`checks/config.json budgets.agentFileHardCapLines is ${cap}: the hard cap is 200 lines and may be lowered, never raised. A rulebook past 200 lines stops being loaded in full.`);
}
// A boolean that retires a whole check is the same weakening vector as an exclusion that
// hides a path, and no reading of the config can tell the legitimate case (a checkout with
// no symlink support) from a gate somebody found inconvenient. So the exemption states its
// case in the same diff that takes it, the trade this repo already made for
// "checks:allow-length: <reason>" and "checks:allow-style". skills-symlink still reads the
// key as a plain flag: which value is honest is this gate's question, and one red is enough.
const skip = cfg.skipSymlinkCheck;
if (skip !== undefined && skip !== false && !(typeof skip === 'string' && skip.trim())) {
fail(`checks/config.json skipSymlinkCheck is ${JSON.stringify(skip)}: retiring the skills-symlink check takes a reason in the same file, as a non-empty string (e.g. "Windows without Developer Mode"). Set false to keep the check on.`);
}
// A third-party declaration stops several gates from measuring a path, so it states its case
// in the entry, like every other exemption here. The path itself is bounded below, by the
// same protected prefixes: a payload declared over checks/ would retire the gates wholesale.
((cfg.thirdParty) || []).forEach((e, i) => {
if (!(e && typeof e.why === 'string' && e.why.trim())) {
fail(`checks/config.json thirdParty[${i}] has no "why": a path this project's gates stop measuring says in the same entry whose work it is and why it is not measured.`);
}
});
// An exclusion that reaches these prefixes disarms the gates rather than tuning them:
// checks/ is where the gates themselves live, docs/standards/ is where a stack's rules do.
// secretScanExclude names checks/ by construction (the detector patterns are in check.mjs
// and would match themselves), so it is the one list allowed to, and only for that prefix.
const protectedPrefixes = ['checks/', 'docs/standards/'];
// Each list is read back by its own matching rule, so the invariant has to test the rule
// that will actually run, or it guarantees less than its message claims. An affix list
// (code-file-cap, secrets, third-party) hides a path when either end of it matches; a
// substring list (denylist) hides it when the value appears anywhere inside it. Testing only
// the prefix would let "s/" and "heck" walk past a gate whose whole job is to stop that.
const hides = (mode, v, p) => v === '' || v.startsWith(p)
|| (mode === 'affix' ? p.startsWith(v) || p.endsWith(v) : p.includes(v));
const lists = [
['codeFileCapExclude', cfg.codeFileCapExclude || [], protectedPrefixes, 'affix'],
['secretScanExclude', cfg.secretScanExclude || [], ['docs/standards/'], 'affix'],
...(cfg.thirdParty || []).map((e, i) => [`thirdParty[${i}].path`, [e?.path], protectedPrefixes, 'affix']),
...(cfg.denylist || []).map((e, i) => [`denylist[${i}].exclude`, e.exclude || [], protectedPrefixes, 'substring']),
];
for (const [where, values, guarded, mode] of lists) {
for (const v of values) {
if (typeof v !== 'string') {
fail(`checks/config.json ${where} holds ${JSON.stringify(v)}: an exclusion is a path string, and a non-string silently excludes nothing.`);
continue;
}
// Overlap in either direction is a hit: "docs/" swallows docs/standards/ from above,
// "docs/standards/react.md" carves it out from within, "" swallows everything.
const hit = guarded.find((p) => hides(mode, v, p));
if (hit) {
fail(`checks/config.json ${where} excludes "${v}", which hides ${hit}: that is where the gates (or a stack's standards) live, so excluding it disarms a check instead of tuning it. Narrow the exclusion.`);
}
}
}
},
});
96 changes: 96 additions & 0 deletions checks/check-config.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env node
// Self-test for checks/check-config.mjs: the config's self-gate, and the third-party declaration
// it owns. The config is the one file that can weaken every other gate, so both directions matter
// here more than anywhere: it must fail on a weakening, and it must stay quiet on a legitimate
// tune. Run: node checks/check-config.test.mjs

import { readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import assert from 'node:assert/strict';
import { thirdPartyMatcher } from './check-config.mjs';
import { PAYLOAD_PATH } from './design-method.mjs';
import {
expectClean, expectFail, withConfig, BASE_BUDGETS, tally, report,
} from './check-fixture.mjs';

// config-invariants: the config may be tuned, never disarmed.
expectFail('config-invariants', withConfig({ budgets: { ...BASE_BUDGETS, agentFileHardCapLines: 400 } }));
expectFail('config-invariants', withConfig({ budgets: { ...BASE_BUDGETS, agentFileHardCapLines: '200' } }));
expectFail('config-invariants', withConfig({ codeFileCapExclude: ['checks/'] }));
expectFail('config-invariants', withConfig({ codeFileCapExclude: ['docs/'] })); // swallows docs/standards/
expectFail('config-invariants', withConfig({ codeFileCapExclude: [''] })); // swallows everything
expectFail('config-invariants', withConfig({ codeFileCapExclude: [123] }));
expectFail('config-invariants', withConfig({ secretScanExclude: ['checks/', 'docs/standards/'] }));
// The evasions a prefix-only invariant would wave through: code-file-cap also matches a
// suffix, and a denylist exclude matches a substring anywhere in the path.
expectFail('config-invariants', withConfig({ codeFileCapExclude: ['s/'] }));
expectFail('config-invariants', withConfig({
denylist: [{ pattern: 'no-such-text-anywhere', why: 'x', exclude: ['heck'] }],
}));
expectFail('config-invariants', withConfig({
denylist: [{ pattern: 'no-such-text-anywhere', why: 'x', exclude: ['checks/'] }],
}));
// A boolean that retires the whole skills-symlink check is the same weakening vector as an
// exclusion that hides a path, and nothing in the config tells the legitimate case (no symlink
// support) from the illegitimate one. So the exemption states its case, as allow-length does.
expectFail('config-invariants', withConfig({ skipSymlinkCheck: true }));
expectFail('config-invariants', withConfig({ skipSymlinkCheck: ' ' }));
expectFail('config-invariants', withConfig({ skipSymlinkCheck: 1 }));
// Lowering the cap is allowed; only raising it is a weakening. And the shipped secretScanExclude
// names checks/ by construction, which the clean fixture in check.test.mjs proves stays green.
expectClean('config-invariants-allows-a-lower-cap', withConfig({
budgets: { ...BASE_BUDGETS, agentFileHardCapLines: 120 },
}));
expectClean('config-invariants-allows-a-stated-reason', withConfig({
skipSymlinkCheck: 'Windows without Developer Mode',
}));

// The third-party declaration stops several gates from measuring a path, so it is bounded the
// same way every other exclusion here is: it states its reason, and it may not reach the
// directories where the gates or a stack's standards live.
expectClean('third-party-declares-a-payload', withConfig({
thirdParty: [{ path: 'vendor/upstream/', why: 'installed at its current release, not written here' }],
}));
expectFail('config-invariants', withConfig({
thirdParty: [{ path: 'vendor/upstream/' }], // no reason given
}));
expectFail('config-invariants', withConfig({
thirdParty: [{ path: 'vendor/upstream/', why: ' ' }],
}));
expectFail('config-invariants', withConfig({ thirdParty: [{ why: 'no path at all' }] }));
expectFail('config-invariants', withConfig({
thirdParty: [{ path: 'docs/', why: 'swallows docs/standards/' }],
}));
expectFail('config-invariants', withConfig({
thirdParty: [{ path: 'checks/', why: 'would retire the gates wholesale' }],
}));
expectFail('config-invariants', withConfig({
thirdParty: [{ path: '', why: 'swallows the whole repo' }],
}));

{ // The matcher is a path prefix, so an upstream rename inside the payload changes nothing, and
// a sibling directory whose name merely starts the same way is not swallowed.
const third = thirdPartyMatcher({ thirdParty: [{ path: 'vendor/upstream/', why: 'x' }] });
try {
assert.ok(third('vendor/upstream/deep/inside/file.mjs'), 'a file inside the payload is third-party');
assert.ok(third('vendor/upstream'), 'the payload directory itself is third-party');
assert.ok(!third('vendor/upstream-fork/file.mjs'), 'a sibling directory is not the payload');
assert.ok(!third('checks/check.mjs'), 'this project\'s own code is never third-party');
assert.ok(!thirdPartyMatcher({})('anything'), 'no declaration means nothing is exempt');
tally.passed++;
} catch (e) { tally.failed.push(`third-party-matcher: ${e.message}`); }
}

{ // Two files name the payload: the install route puts it there, the config declares it. If they
// ever disagree the gates measure a path nothing installs, silently, so the drift is a test.
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const cfg = JSON.parse(readFileSync(join(root, 'checks', 'config.json'), 'utf8'));
try {
assert.ok(thirdPartyMatcher(cfg)(PAYLOAD_PATH),
`checks/config.json must declare ${PAYLOAD_PATH} as third-party: that is where the install route puts the design method.`);
tally.passed++;
} catch (e) { tally.failed.push(`third-party-declares-the-design-method: ${e.message}`); }
}

report('config-gate');
Loading
Loading