mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* fix(pregate): Dart import graph and test lanes (#3285) Port of arlophoenix's #3285. The import graph reads Dart import/export/part directives and resolves package: URIs through every tracked pubspec.yaml; _test.dart files under test/ are tests. lanes runs each Dart package's selected tests from its own root with flutter test or dart test (through fvm when the package pins it). flutter/dart/melos/very_good CI jobs are lanes, and run-step test paths resolve against working-directory and literal cd. A missing Dart SDK or a .dart file outside any package leaves the lane incomplete with a fix line; device tests (integration_test/, test_driver/) are not unit lanes. Co-authored-by: arlophoenix <arlo.phoenix@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): a --base that names a commit gets a branch fix, not an impossible fetch (#3269) gstack-pregate --base <sha> refused with "fix: git fetch origin <sha> --depth=1". That fetch can never create origin/<sha>: a short SHA fails with "couldn't find remote ref", and a full SHA only reaches FETCH_HEAD. A fork clone hits the same wall with --base upstream/main. When origin/<base> does not resolve but <base> names a commit here and no local branch (a SHA, a tag, another remote's ref), the refusal now says --base takes a branch name on origin and suggests origin/HEAD's branch. A local branch, or a name that resolves nowhere, keeps the fetch fix: that is the unfetched or shallow checkout it repairs. Same code, same exit 3, and pin files are still read only from the reviewed origin/<base>. Fixes #3269 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(pregate): Django, asset and tool-config lanes; init drafts from the repo (#3266, #3267, #3268) - Django: settings modules, settings strings, include(), app migrations, admin, templates, static files and project templates get framework edges (lib/pregate/django.ts). - Assets, stylesheets and dotfile tool config take a no-tests empty lane; licence text under any name is documentation. - gstack-pregate init drafts languages, runner notes and nearest-test dependencies; never writes []. - Review fixes on the #3285 port: untracked shell scopes leave a lane unknown; Dart directives read to their semicolon; nested comments. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(pregate): init draft API is exercised directly, so init-draft.ts has a lane Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): restore the history a truncated rewrite dropped Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): outside-voice review fixes for the non-JS lanes - Tool config is an allowlist: unknown dotfiles (.rspec, .husky hooks) and stylesheets still need a lane; snapshot, golden and fixture trees and .gstack/ are never inert. - Django edges need Django evidence (manage.py or code importing django) and settings assignments in code, not strings or comments. - A cd after ||, &, control flow or braces leaves a lane unknown. - Dart directives allow metadata and quoted semicolons. - init drafts at most 256 dependencies (the schema limit); the rest are TODO. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(windows): pregate-dart and pregate-non-js join the POSIX-run pregate fixtures Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: release v1.91.161.0 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: arlophoenix <arlo.phoenix@gmail.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> Co-authored-by: y$un_ <forrest.sun527@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
112 lines
5.8 KiB
TypeScript
112 lines
5.8 KiB
TypeScript
/**
|
|
* pregate/django — the edges Django creates by name rather than by import
|
|
* (#3266). The import graph follows Python imports, but Django loads much of
|
|
* a project from settings strings and directory conventions, so without these
|
|
* edges a migration, an admin module or a template has no test lane.
|
|
*
|
|
* A repo is a Django project when it carries Django evidence (a manage.py, or
|
|
* Python code that imports django) and a settings module: Python code (strings
|
|
* and comments excluded) that assigns INSTALLED_APPS or ROOT_URLCONF, or the
|
|
* module a config file names in DJANGO_SETTINGS_MODULE. Then:
|
|
* - every Python test depends on each settings module (the test runner
|
|
* loads it), so a settings change selects the suite;
|
|
* - a dotted string in a settings module (`"stores.backends.StoreOwnerBackend"`,
|
|
* ROOT_URLCONF) and an `include("app.urls")` argument anywhere count as
|
|
* imports of the module they name;
|
|
* - an app (a directory holding apps.py, models.py or migrations/) maps its
|
|
* migrations, admin, templates, static files, template tags, management
|
|
* commands, fixtures and locale files to that app's tests and to the tests
|
|
* that reach its models, as an edit to models.py does; an app with no tests
|
|
* of its own falls back to the settings module (the suite);
|
|
* - a template under a project-level templates/ directory (settings DIRS)
|
|
* maps to the settings module: conservatively the whole suite.
|
|
* Uncertainty widens the selection; nothing here removes an edge.
|
|
*/
|
|
import * as path from 'node:path';
|
|
|
|
export interface DjangoInput {
|
|
files: readonly string[];
|
|
tests: ReadonlySet<string>;
|
|
read: (rel: string) => string | null;
|
|
/** Python source with strings blanked and comments dropped. */
|
|
code: (source: string) => string;
|
|
/** A dotted Python name → the repo files of its longest module prefix ([] outside the repo). */
|
|
resolveDotted: (dotted: string) => string[];
|
|
}
|
|
|
|
/** One edge: `dependent` depends on `dependency`. */
|
|
export interface DjangoEdge { dependency: string; dependent: string }
|
|
|
|
const SETTINGS_ASSIGN = /^(?:INSTALLED_APPS|ROOT_URLCONF)[ \t]*(?::[^=\n]*)?[+]?=/m;
|
|
const IMPORTS_DJANGO = /^[ \t]*(?:from[ \t]+django[\w.]*[ \t]+import|import[ \t]+django\b)/m;
|
|
const SETTINGS_ENV = /DJANGO_SETTINGS_MODULE['"]?\s*[,=:]\s*['"]?([A-Za-z_][\w.]*)/g;
|
|
const SETTINGS_ENV_FILES = /(?:^|\/)(?:manage\.py|pytest\.ini|pyproject\.toml|setup\.cfg|tox\.ini|conftest\.py|\.env\.example)$/;
|
|
const DOTTED_STRING = /['"]([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+)['"]/g;
|
|
const INCLUDE = /\binclude\(\s*['"]([A-Za-z_][\w.]*)['"]/g;
|
|
/** Paths inside an app that Django loads by convention, relative to the app directory. */
|
|
const SOURCE_LIKE = /\.(?:py|[cm]?[jt]sx?)$/;
|
|
const APP_LOADED = /^(?:migrations\/.+\.py|admin\.py|admin\/.+|templates\/.+|jinja2\/.+|static\/.+|templatetags\/.+|management\/.+|fixtures\/.+|locale\/.+)$/;
|
|
|
|
function settingsModules(input: DjangoInput): string[] {
|
|
const out = new Set<string>();
|
|
let evidence = input.files.some(f => /(?:^|\/)manage\.py$/.test(f));
|
|
for (const f of input.files) {
|
|
if (f.endsWith('.py')) {
|
|
const text = input.read(f);
|
|
const code = text ? input.code(text) : '';
|
|
if (SETTINGS_ASSIGN.test(code)) out.add(f);
|
|
if (!evidence && IMPORTS_DJANGO.test(code)) evidence = true;
|
|
}
|
|
if (!SETTINGS_ENV_FILES.test(f)) continue;
|
|
const text = input.read(f) ?? '';
|
|
for (const m of text.matchAll(SETTINGS_ENV)) for (const t of input.resolveDotted(m[1]!)) if (t.endsWith('.py') && !t.endsWith('/__init__.py')) out.add(t);
|
|
}
|
|
return evidence ? [...out].sort() : [];
|
|
}
|
|
|
|
/** App directories: a directory holding apps.py, models.py, models/__init__.py or migrations/__init__.py. */
|
|
function appDirs(files: readonly string[]): string[] {
|
|
const out = new Set<string>();
|
|
for (const f of files) {
|
|
const m = /^(?:(.*)\/)?(?:apps\.py|models\.py|models\/__init__\.py|migrations\/__init__\.py)$/.exec(f);
|
|
if (m && m[1] !== undefined && !/(?:^|\/)(?:migrations|models)$/.test(m[1])) out.add(m[1]);
|
|
}
|
|
return [...out].sort((a, b) => b.length - a.length);
|
|
}
|
|
|
|
export function djangoEdges(input: DjangoInput): DjangoEdge[] {
|
|
const settings = settingsModules(input);
|
|
if (!settings.length) return [];
|
|
const edges: DjangoEdge[] = [];
|
|
const has = new Set(input.files);
|
|
const pyTests = [...input.tests].filter(t => t.endsWith('.py'));
|
|
for (const s of settings) {
|
|
for (const t of pyTests) edges.push({ dependency: s, dependent: t });
|
|
for (const m of (input.read(s) ?? '').matchAll(DOTTED_STRING)) for (const t of input.resolveDotted(m[1]!)) edges.push({ dependency: t, dependent: s });
|
|
}
|
|
for (const f of input.files) {
|
|
if (!f.endsWith('.py') || input.tests.has(f)) continue;
|
|
const text = input.read(f);
|
|
if (!text || !text.includes('include(')) continue;
|
|
for (const m of text.matchAll(INCLUDE)) for (const t of input.resolveDotted(m[1]!)) edges.push({ dependency: t, dependent: f });
|
|
}
|
|
const apps = appDirs(input.files);
|
|
const appOf = (f: string) => apps.find(a => f.startsWith(`${a}/`));
|
|
const appTests = new Map(apps.map(a => [a, pyTests.filter(t => appOf(t) === a)]));
|
|
for (const f of input.files) {
|
|
if (input.tests.has(f)) continue;
|
|
const app = appOf(f);
|
|
const rel = app === undefined ? null : f.slice(app.length + 1);
|
|
if (app !== undefined && rel !== null && APP_LOADED.test(rel)) {
|
|
const anchor = [`${app}/models.py`, `${app}/models/__init__.py`].find(a => has.has(a));
|
|
if (anchor && anchor !== f) edges.push({ dependency: f, dependent: anchor });
|
|
const own = appTests.get(app)!;
|
|
for (const t of own) edges.push({ dependency: f, dependent: t });
|
|
if (!own.length) for (const s of settings) edges.push({ dependency: f, dependent: s });
|
|
continue;
|
|
}
|
|
if (/(?:^|\/)templates\/./.test(f) && !SOURCE_LIKE.test(path.posix.basename(f))) for (const s of settings) edges.push({ dependency: f, dependent: s });
|
|
}
|
|
return edges;
|
|
}
|