Files
gstack/lib/pregate/django.ts
5ebda5999f v1.91.161.0 fix(pregate): Django, Dart, asset and tool-config lanes; init drafts from the repo (#3266-#3269) (#3306)
* 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>
2026-10-20 01:38:26 +00:00

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;
}