Skip to content
1 change: 1 addition & 0 deletions .github/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Before changing GitHub automation, read the current versions of:
- `.github/workflows/report.atom.yml`
- `.github/scripts/handoff.py`
- `scripts/scopes.ts`
- `specs/current/ci.md` when changing scope rules, confidence tiers, or guards
- `e2e/tests/packaged-smoke-workflow.test.ts`
- `scripts/approve-fork-pr-workflows.ts` and `e2e/tests/scripts/approve-fork-pr-workflows.test.ts` when touching fork PR approval behavior

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This file is the single source of truth for agents entering this repository. Rea
- Contribution and environment: `CONTRIBUTING.md`, `docs/i18n/CONTRIBUTING.zh-CN.md`.
- Architecture and protocols: `docs/architecture.md`, `docs/skills-protocol.md`, `docs/agent-adapters.md`, `docs/modes.md`.
- Historical product baseline: `docs/spec.md`, `docs/roadmap.md` (both explicitly archived; do not treat their dated decisions as current behavior).
- References and current plans: `docs/references.md`, `docs/code-review-guidelines.md`, `specs/current/maintainability-roadmap.md`.
- References and current plans: `docs/references.md`, `docs/code-review-guidelines.md`, `specs/current/maintainability-roadmap.md`, `specs/current/ci.md` (CI scope confidence methodology — required before changing confidence or guard fields in `scripts/scopes.ts`).
- Directory-level agent guidance: `.github/AGENTS.md`, `apps/AGENTS.md`, `packages/AGENTS.md`, `tools/AGENTS.md`, `e2e/AGENTS.md`.
- Packaged auto-update architecture and high-confidence local harness: read `tools/pack/AGENTS.md` section "Packaged auto-update architecture and harness" before touching packaged updater code, release-channel identity, installer behavior, or updater UI.

Expand Down
93 changes: 85 additions & 8 deletions e2e/tests/scripts/scopes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,15 +423,26 @@ const GOLDEN_CASES: readonly GoldenCase[] = [
expected: FULL_PLAN,
},
{
// While the certain rule set is empty, every queued file sits below the
// merge-queue trust threshold and escalates: the queue stays full. The
// first certain-rule promotion is the deliberate behavior change that
// makes this case diverge.
name: "merge_group docs-only group still runs everything at the certain threshold",
// Root markdown stays medium-tier (the global md regex is not promotable:
// its safety depends on other rules covering runtime-markdown directories),
// so one README.md in the group escalates and keeps the queue full even
// though docs/ itself is certain-tier.
name: "merge_group group with root markdown still runs everything at the certain threshold",
context: { eventName: "merge_group" },
files: ["README.md", "docs/architecture.md"],
expected: FULL_PLAN,
},
{
// The first certain-tier promotion: a group confined to the certain-exempt
// core (docs/, landing-page, editor configs, LICENSE/CODEOWNERS) drops to
// the unconditional floor lanes instead of running everything. Guarded by
// the "certain-exempt surface consumption" guard check; methodology in
// specs/current/ci.md.
name: "merge_group certain-exempt core group drops to the floor lanes",
context: { eventName: "merge_group" },
files: ["docs/architecture.md", "docs/nested/guide.mdx", "apps/landing-page/src/pages/index.astro", "LICENSE", ".github/CODEOWNERS"],
expected: expectedPlan({ ciMode: "full" }),
},
{
name: "merge_group mixed group runs everything at the certain threshold",
context: { eventName: "merge_group" },
Expand Down Expand Up @@ -482,18 +493,84 @@ test("rule ids are unique", async () => {
assert.deepEqual(ids, [...new Set(ids)]);
});

test("certain rules must name their enforcing guard", async () => {
test("certain rules must name a guard that resolves to a real guard check", async () => {
const { scopeRules } = await import("../../../scripts/scopes.ts");
// guard.ts must run through tsx (its check modules use .js-suffixed TS-ESM
// specifiers), so go through the root guard script exactly like CI does.
const guardCheckNames = new Set(
execFileSync("pnpm", ["--silent", "guard", "--list-checks"], { cwd: repoRoot, encoding: "utf8" })
.split("\n")
.filter(Boolean),
);
for (const rule of scopeRules) {
if (rule.confidence === "certain") {
assert.ok(
rule.guard != null && rule.guard.length > 0,
`rule ${rule.id} is "certain" but names no guard; promotion requires the check that keeps its boundary invariant true`,
rule.guard != null && guardCheckNames.has(rule.guard),
`rule ${rule.id} is "certain" but its guard ${JSON.stringify(rule.guard)} does not resolve to a scripts/guard.ts check; promotion requires the live check that keeps its boundary invariant true`,
);
}
}
});

test("certain-exempt markdown matches only the certain rule (no medium co-match neutralizes the promotion)", async () => {
const { scopeRules, matchesRuleMatch } = await import("../../../scripts/scopes.ts");
for (const file of ["docs/architecture.md", "docs/nested/guide.mdx", "apps/landing-page/README.md"]) {
const matched = scopeRules.filter((rule) => matchesRuleMatch(file, rule.match)).map((rule) => rule.id);
assert.deepEqual(matched, ["certain-exempt-surface"], file);
}
});

test("merge-queue threshold trusts the certain-exempt core without escalation", async () => {
const { evaluateScopeOutputs, SCOPE_EFFECTS } = await import("../../../scripts/scopes.ts");
const atQueue = evaluateScopeOutputs(["docs/architecture.md"], "certain", {
deriveWorkspaceValidationFromTestScopes: true,
});
assert.deepEqual(
Object.values(atQueue.outputs),
SCOPE_EFFECTS.map(() => false),
);
assert.deepEqual(atQueue.decisions[0], {
file: "docs/architecture.md",
matchedRules: ["certain-exempt-surface"],
escalated: false,
});
});

test("the consumption guard flags repo-resolving literals and tolerates test fixtures", async () => {
const { collectCertainExemptConsumptionFromSource, isFixtureTolerantPath } = await import(
"../../../scripts/check-certain-exempt-consumption.ts"
);

// Dot-relative resolution into docs/ is consumption anywhere, tests included.
const relative = collectCertainExemptConsumptionFromSource(
"apps/daemon/tests/example.test.ts",
`const spec = readFileSync("../../../docs/spec.md", "utf8");`,
);
assert.deepEqual(relative.map((violation) => violation.literal), ["../../../docs/spec.md"]);

// Bare repo-relative literals are consumption in non-test source...
const bareInSource = collectCertainExemptConsumptionFromSource(
"tools/example/src/config.ts",
`const changelog = "docs/CHANGELOG";`,
);
assert.deepEqual(bareInSource.map((violation) => violation.literal), ["docs/CHANGELOG"]);

// ...but fixture data in tests (sandboxed project paths, never repo docs/).
assert.ok(isFixtureTolerantPath("apps/daemon/tests/example.test.ts"));
const bareInTest = collectCertainExemptConsumptionFromSource(
"apps/daemon/tests/example.test.ts",
`await writeProjectFile("docs/empty.md", "");`,
);
assert.deepEqual(bareInTest, []);

// Prose that merely mentions a docs path does not start with the prefix.
const prose = collectCertainExemptConsumptionFromSource(
"apps/web/src/copy.ts",
`const footer = "Spec: docs/skills-protocol.md covers the adapter surface.";`,
);
assert.deepEqual(prose, []);
});

test("the rule table classifies every file: no path escapes both fallbacks", async () => {
const { scopeRules, matchesRuleMatch } = await import("../../../scripts/scopes.ts");
const samples = [
Expand Down
183 changes: 183 additions & 0 deletions scripts/check-certain-exempt-consumption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import ts from "typescript";

import { CERTAIN_EXEMPT_EXACT, CERTAIN_EXEMPT_PREFIXES } from "./scopes.ts";

// Guard for the certain-tier exempt core in `scripts/scopes.ts` (rule
// `certain-exempt-surface`; methodology in `specs/current/ci.md`).
//
// Boundary invariant: no source that a *skippable* merge-gate lane executes may
// consume a certain-exempt file. If that held false, a docs-only change could
// invalidate a lane the promoted rule tells the merge queue to skip.
//
// What "consumption" means here, at two precision levels:
// - a dot-relative literal (`../../docs/...`) that resolves from the file's
// repo location into the certain-exempt surface: flagged everywhere — it can
// only mean the repository surface.
// - a bare repo-relative literal (`docs/CHANGELOG`): flagged only in non-test
// sources. In tests, bare `docs/...` strings are overwhelmingly project
// fixture paths inside sandboxed workspaces (and Vitest's cwd is the package
// dir, so a bare relative read could not reach repository docs/ anyway);
// real repo consumption in source code is a repo-root-resolved config
// default, which this level catches.
// Template literals with substitutions are not statically resolvable and are
// out of scope.
//
// Deliberately outside the checked surface:
// - root `scripts/` — floor-owned code. Preflight and workspace unit tests are
// unconditionally armed on every plan, so floor checks may read the exempt
// surface (product neutrality validates docs/ prose on every run).
// - `apps/landing-page/` — it IS the exempt surface; landing-page CI owns it.

const repoRoot = path.resolve(import.meta.dirname, "..");

const checkedRoots = ["apps", "packages", "tools", "e2e"] as const;

const skippedDirectoryNames = new Set([
".astro",
".next",
".od-data",
"dist",
"node_modules",
"out",
"reports",
"test-results",
"vendor",
]);

const skippedRepositoryPrefixes = ["apps/landing-page/"];

const checkedExtensions = new Set([".ts", ".tsx"]);

// File-level exceptions. Every entry must explain why the reference is not
// gate-lane consumption of exempt-file *content*; revisit the entry if the
// file's relationship to the exempt surface changes.
const allowedConsumers = new Map<string, string>([
[
"tools/release/src/release-note/prepare.ts",
"docs/CHANGELOG feeds release-note preparation, which runs only in release workflows; @open-design/tools-release tests run in no ci.yml lane",
],
[
"e2e/tests/scripts/scopes.test.ts",
"behavior fixtures for this very check: source snippets passed to the collector as data, never resolved or opened",
],
]);

type ConsumptionViolation = {
filePath: string;
lineNumber: number;
literal: string;
};

function toRepositoryPath(filePath: string): string {
return path.relative(repoRoot, filePath).split(path.sep).join("/");
}

function landsInCertainExemptSurface(repositoryPath: string): boolean {
return (
CERTAIN_EXEMPT_PREFIXES.some((prefix) => repositoryPath.startsWith(prefix)) ||
(CERTAIN_EXEMPT_EXACT as readonly string[]).includes(repositoryPath)
);
}

export function isFixtureTolerantPath(repositoryPath: string): boolean {
return (
/\.test\.tsx?$/.test(repositoryPath) ||
repositoryPath.includes("/tests/") ||
repositoryPath.startsWith("e2e/")
);
}

function literalConsumesCertainExemptSurface(fromRepositoryPath: string, literal: string): boolean {
if (literal.startsWith("./") || literal.startsWith("../")) {
const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(fromRepositoryPath), literal));
return landsInCertainExemptSurface(resolved);
}
if (isFixtureTolerantPath(fromRepositoryPath)) return false;
Comment thread
PerishCode marked this conversation as resolved.
Outdated
return landsInCertainExemptSurface(literal);
}

export function collectCertainExemptConsumptionFromSource(
repositoryPath: string,
source: string,
): ConsumptionViolation[] {
const sourceFile = ts.createSourceFile(
repositoryPath,
source,
ts.ScriptTarget.Latest,
true,
repositoryPath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
);
const violations: ConsumptionViolation[] = [];

const visit = (node: ts.Node): void => {
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
Comment thread
PerishCode marked this conversation as resolved.
Outdated
if (literalConsumesCertainExemptSurface(repositoryPath, node.text)) {
violations.push({
filePath: repositoryPath,
lineNumber: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1,
literal: node.text,
});
}
}
ts.forEachChild(node, visit);
};

visit(sourceFile);
return violations;
}

async function collectCheckedFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const files: string[] = [];

for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
const repositoryPath = toRepositoryPath(fullPath);

if (entry.isDirectory()) {
if (
skippedDirectoryNames.has(entry.name) ||
entry.name.startsWith(".next-") ||
skippedRepositoryPrefixes.some((prefix) => `${repositoryPath}/`.startsWith(prefix))
) {
continue;
}
files.push(...(await collectCheckedFiles(fullPath)));
continue;
}

if (entry.isFile() && checkedExtensions.has(path.extname(entry.name))) {
files.push(repositoryPath);
}
}

return files;
}

export async function checkCertainExemptConsumption(): Promise<boolean> {
const violations: ConsumptionViolation[] = [];

for (const root of checkedRoots) {
for (const repositoryPath of await collectCheckedFiles(path.join(repoRoot, root))) {
if (allowedConsumers.has(repositoryPath)) continue;
const source = await readFile(path.join(repoRoot, repositoryPath), "utf8");
violations.push(...collectCertainExemptConsumptionFromSource(repositoryPath, source));
}
}

if (violations.length > 0) {
console.error("Certain-exempt surface consumption found in gate-lane sources:");
for (const violation of violations) {
console.error(`- ${violation.filePath}:${violation.lineNumber} \`${violation.literal}\``);
}
console.error(
"Certain-tier exempt files must stay unconsumed by skippable merge-gate lanes (see specs/current/ci.md). Move the dependency, or add a justified allowlist entry in scripts/check-certain-exempt-consumption.ts.",
);
return false;
}

console.log("Certain-exempt consumption check passed: gate-lane sources do not read the certain-exempt surface.");
return true;
}
13 changes: 11 additions & 2 deletions scripts/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
import ts from "typescript";

import { checkCertainExemptConsumption } from "./check-certain-exempt-consumption.ts";
import { checkCrossAppImports } from "./check-cross-app-imports.ts";
import { checkTsNocheckImports } from "./check-ts-nocheck-imports.ts";
import { checkDesignSystemManifests } from "./check-design-system-manifests.ts";
Expand Down Expand Up @@ -1322,6 +1323,7 @@ async function checkCiTopology(): Promise<boolean> {

const checks: GuardCheck[] = [
{ name: "residual JavaScript", run: checkResidualJavaScript },
{ name: "certain-exempt surface consumption", run: checkCertainExemptConsumption },
{ name: "package dependency specs", run: checkPackageDependencySpecs },
{ name: "product neutrality", run: checkProductNeutrality },
{ name: "cross-app imports", run: checkCrossAppImports },
Expand Down Expand Up @@ -1365,6 +1367,13 @@ async function runChecks(): Promise<boolean> {
}

const isMain = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false;
if (isMain && !(await runChecks())) {
process.exitCode = 1;
if (isMain) {
// `--list-checks` is the machine-readable registry of guard check names; the
// scope rule-table invariant test resolves `certain` rules' guard fields
// against it so a renamed or deleted guard fails CI.
if (process.argv[2] === "--list-checks") {
for (const check of checks) console.log(check.name);
} else if (!(await runChecks())) {
process.exitCode = 1;
}
}
Loading
Loading