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
60 changes: 41 additions & 19 deletions .github/scripts/arch-lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ export const ARCHITECTURE_PATH = "architecture.yml";
export interface Architecture {
/** layer -> the src/ paths it owns (a `/` suffix means a directory). */
readonly layers: Readonly<Record<string, readonly string[]>>;

/** Glob patterns of the src/ files outside the lint. */
readonly exclude: readonly string[];

/** from -> the layers it may import. */
readonly edges: Readonly<Record<string, readonly string[]>>;
}
Expand All @@ -38,13 +41,16 @@ const ARCHITECTURE = z.strictObject({
/** `layers.engine[0]`, `edges["plain-data"]`: the yaml key as a reader would write it in code. */
function keyPath(path: readonly PropertyKey[]): string {
return path
.map((segment, index) =>
typeof segment === "number"
? `[${segment}]`
: /^[A-Za-z_]\w*$/.test(String(segment))
? `${index === 0 ? "" : "."}${String(segment)}`
: `[${JSON.stringify(String(segment))}]`,
)
.map((segment, index) => {
if (typeof segment === "number") {
return `[${segment}]`;
}
const name = String(segment);
if (/^[A-Za-z_]\w*$/.test(name)) {
return index === 0 ? name : `.${name}`;
}
return `[${JSON.stringify(name)}]`;
})
.join("");
}

Expand Down Expand Up @@ -179,17 +185,21 @@ function isModuleLoad(node: Node): boolean {
}
}

/** The expression naming the module a load reads: `require`'s first argument, else the node's source. */
function requestExpression(node: Node): Node | null | undefined {
if (node.type === "TSExternalModuleReference") {
return node.expression;
}
if (node.type === "CallExpression") {
return node.arguments[0];
}
return "source" in node ? node.source : undefined;
}

/** The module a load names, or undefined when the specifier is computed (`import(x)`, `require(x)`, a template
* with expressions), which the graph cannot follow. */
function literalModuleRequest(node: Node): string | undefined {
const raw =
node.type === "TSExternalModuleReference"
? node.expression
: node.type === "CallExpression"
? node.arguments[0]
: "source" in node
? node.source
: undefined;
const raw = requestExpression(node);
const request = raw === undefined || raw === null ? undefined : unwrapped(raw);
if (request?.type === "Literal" && typeof request.value === "string") {
return request.value;
Expand Down Expand Up @@ -255,7 +265,11 @@ function* sourceFiles(root: string, arch: Architecture): Generator<string> {
}
}

export function lintArchitecture(root: string, arch = readArchitecture(root)): string[] {
/** The cross-layer edges src/ draws, keyed `from -> to` with the imports drawing each, and the files no layer owns. */
function drawnEdges(
root: string,
arch: Architecture,
): { drawn: Map<string, string[]>; problems: string[] } {
const drawn = new Map<string, string[]>();
const problems: string[] = [];
for (const file of sourceFiles(root, arch)) {
Expand All @@ -272,12 +286,20 @@ export function lintArchitecture(root: string, arch = readArchitecture(root)): s
problems.push(
`${target} (imported by ${file}) belongs to no layer in ${ARCHITECTURE_PATH}`,
);
} else if (to !== from) {
const key = `${from} -> ${to}`;
drawn.set(key, [...(drawn.get(key) ?? []), `${file} -> ${target}`]);
continue;
}
if (to === from) {
continue;
}
const key = `${from} -> ${to}`;
drawn.set(key, [...(drawn.get(key) ?? []), `${file} -> ${target}`]);
}
}
return { drawn, problems };
}

export function lintArchitecture(root: string, arch = readArchitecture(root)): string[] {
const { drawn, problems } = drawnEdges(root, arch);
const declared = new Set(
Object.entries(arch.edges).flatMap(([from, targets]) =>
targets.map((to) => `${from} -> ${to}`),
Expand Down
4 changes: 2 additions & 2 deletions .github/scripts/auto-fix-steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ import { dispatch } from "./lib/entry.js";
import { configureBotIdentity, leasePush } from "./lib/pr-branch.js";
import { capture, requireEnv, run, setOutput, status } from "./lib/workflow-step.js";

/** In generated.ts table order: the later generators import the descriptor-derived files and the gaps index
* through src/, and the inputs table reads action.yml, so each source renders first. */
/** In generated.ts table order, each generator after its sources: the descriptor-derived files and the gaps index are imported through src/, and the inputs table reads action.yml. */
export const GENERATORS = [
"build:openapi",
"build:gaps-index",
Expand All @@ -40,6 +39,7 @@ const GENERATED_FILES = [
"src/generated/upstream-gaps.ts",
] as const;

/** With its slash: a directory entry of FIX_OWNED_PATHS, and the pathspec the pruned verdict reads. */
export const GAPS_DIR = "src/upstream-gaps/";

/** A `dir/` entry covers everything under it, deletions included (a graduated gap file is a deletion). */
Expand Down
18 changes: 13 additions & 5 deletions .github/scripts/check-compat-markers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@ import { lstatSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { runMain } from "./lib/entry.js";

/** Built output, dependencies, release-please's changelog (it quotes PR titles, so a removal PR's title would
* outlive the marker it deleted), and the two files that spell the syntax to define and test it.
* A trailing slash skips a directory; anything else is one exact path. */
/** A trailing slash skips a directory, anything else one exact path; CHANGELOG.md quotes PR titles, so a removal PR's title would outlive the marker it deleted, and the last two spell the syntax to define and test it. */
const SKIPPED_PATHS = [
"lib/",
"node_modules/",
Expand All @@ -32,14 +30,22 @@ function skipped(path: string): boolean {
return SKIPPED_PATHS.some((skip) => (skip.endsWith("/") ? path.startsWith(skip) : path === skip));
}

/** Every candidate on a line, well-formed or not, so a malformed marker is reported rather than missed. */
const OCCURRENCE = /COMPAT\(/g;

/** The major as digits only, so a marker cannot dodge the gate by leaving it out. */
const MARKER = /^COMPAT\(v(0|[1-9]\d*)\):(.*)$/;

/** The placeholder inside an inline-code span: a backtick before it (checked at the call) and one after it. */
const PLACEHOLDER = /^COMPAT\(vN\)[^`]*`/;
// A description ends at its block comment's closer; in a line comment or Markdown prose it runs to the end of the
// line. A `#` or `//` before the marker is not an opener: "/* See #123. COMPAT(v3): */" sits in the block comment.

// A description ends at its block comment's closer, else at the end of the line; a `#` or `//` before the marker is no opener, so "/* See #123. COMPAT(v3): */" sits in the block comment.
const BLOCK_OPENER = /\/\*|<!--/g;

/** The closer of each BLOCK_OPENER alternative. */
const CLOSER_OF: Readonly<Record<string, string>> = { "/*": "*/", "<!--": "-->" };

/** Quoted in every malformed-marker failure. */
export const SYNTAX = "COMPAT(v<major>): <what stays working and what to delete>";

export interface Marker {
Expand Down Expand Up @@ -143,7 +149,9 @@ function packageVersion(cwd: string): { version: string; major: number } {
}

export interface CheckOptions {
/** The repository root, where package.json and the git tree are read. */
cwd: string;

/** The major a release cuts; defaults to package.json's. Never below it: a release never targets an older major. */
targetMajor?: number;
}
Expand Down
7 changes: 5 additions & 2 deletions .github/scripts/coverage-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ const GapRow = z
why: z.string().min(1),
})
.readonly();

export type GapRow = z.infer<typeof GapRow>;

// The Repo-scoped gaps table: either no known gap, with the paragraph shown in place of rows,
// or the rows. One arm at a time, so a note cannot go stale behind rows that hide it.
/** One arm at a time, the empty-table paragraph or the rows, so a note cannot go stale behind rows that hide it. */
const CoverageGaps = z.union([
z.strictObject({ emptyNote: z.string().min(1), rows: z.undefined().optional() }).readonly(),
z
Expand All @@ -40,8 +40,10 @@ const CoverageGaps = z.union([
.readonly(),
]);

/** The page's paragraph runs and bullet lists, one line per item, never empty. */
const Items = z.tuple([z.string().min(1)], z.string().min(1)).readonly();

/** The authored half of the page, as coverage-data.yml declares it. */
export const CoverageData = z
.strictObject({
/** The paragraphs under the page title, one line each. */
Expand All @@ -59,6 +61,7 @@ export const CoverageData = z
outOfScope: z.strictObject({ items: Items }).readonly(),
})
.readonly();

export type CoverageData = z.infer<typeof CoverageData>;

export const COVERAGE_DATA = readDocsYaml(
Expand Down
62 changes: 44 additions & 18 deletions .github/scripts/endpoint-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ import { allEndpoints, allGraphqlOps } from "../../src/sections/registry.js";
import { loadSpec } from "../../test/e2e/openapi/validate.js";

const DOCS_URL = z.string().url().startsWith("https://docs.github.com/en/");

/** A route as a declaration spells it ("METHOD /path/{param}") and a GraphQL operation's wire name. */
const ROUTE = /^(?:GET|POST|PUT|PATCH|DELETE) \/\S+$/;
const OPERATION = /^[A-Z][A-Za-z0-9]*$/;

Expand Down Expand Up @@ -53,21 +51,27 @@ export type SpecOperations = Readonly<
Record<string, Readonly<Record<string, { readonly externalDocs?: { readonly url: string } }>>>
>;

/** One kind's pages by call, and every problem met resolving them. */
interface Resolved {
readonly pages: Record<string, string>;
readonly problems: string[];
}

/** The descriptor's link in the spelling docs.github.com serves: the locale added, the Pages placeholder rendered. */
export function renderedDocsUrl(url: string): string {
return url
.replace("https://docs.github.com/rest/", "https://docs.github.com/en/rest/")
.replace("apiname", "github");
}

export function resolveAnchors(
/** Each declared route's page: the descriptor's, else the hand entry; both or neither is a problem, as is a hand entry no section declares. */
function resolveRest(
spec: SpecOperations,
hand: EndpointDocs,
routes: readonly string[],
operations: readonly string[],
): EndpointAnchors {
): Resolved {
const pages: Record<string, string> = {};
const problems: string[] = [];
const rest: Record<string, string> = {};
for (const route of routes) {
const space = route.indexOf(" ");
const documented =
Expand All @@ -77,15 +81,19 @@ export function resolveAnchors(
problems.push(
`"${route}" has a page in both the descriptor and ${ENDPOINT_DOCS_PATH}; delete the hand entry`,
);
} else if (documented !== undefined) {
rest[route] = renderedDocsUrl(documented);
} else if (byHand !== undefined) {
rest[route] = byHand;
} else {
problems.push(
`"${route}" has no page: the descriptor does not document it, so add one under rest: in ${ENDPOINT_DOCS_PATH}`,
);
continue;
}
if (documented !== undefined) {
pages[route] = renderedDocsUrl(documented);
continue;
}
if (byHand !== undefined) {
pages[route] = byHand;
continue;
}
problems.push(
`"${route}" has no page: the descriptor does not document it, so add one under rest: in ${ENDPOINT_DOCS_PATH}`,
);
}
for (const route of Object.keys(hand.rest)) {
if (!routes.includes(route)) {
Expand All @@ -94,16 +102,22 @@ export function resolveAnchors(
);
}
}
const graphql: Record<string, string> = {};
return { pages, problems };
}

/** Each declared operation's page, which only the hand file holds; a missing one and a hand entry no section declares are problems. */
function resolveGraphql(hand: EndpointDocs, operations: readonly string[]): Resolved {
const pages: Record<string, string> = {};
const problems: string[] = [];
for (const name of operations) {
const byHand = hand.graphql[name];
if (byHand === undefined) {
problems.push(
`the GraphQL operation "${name}" has no page; add one under graphql: in ${ENDPOINT_DOCS_PATH}`,
);
} else {
graphql[name] = byHand;
continue;
}
pages[name] = byHand;
}
for (const name of Object.keys(hand.graphql)) {
if (!operations.includes(name)) {
Expand All @@ -112,10 +126,22 @@ export function resolveAnchors(
);
}
}
return { pages, problems };
}

export function resolveAnchors(
spec: SpecOperations,
hand: EndpointDocs,
routes: readonly string[],
operations: readonly string[],
): EndpointAnchors {
const rest = resolveRest(spec, hand, routes);
const graphql = resolveGraphql(hand, operations);
const problems = [...rest.problems, ...graphql.problems];
if (problems.length > 0) {
throw new Error(`endpoint-docs:\n ${problems.join("\n ")}`);
}
return { rest, graphql };
return { rest: rest.pages, graphql: graphql.pages };
}

/** The distinct routes and operation names the sections declare, in registry order. */
Expand Down
Loading
Loading