Files
gstack/test/pregate-non-js.test.ts
T
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

247 lines
17 KiB
TypeScript

/**
* gstack-pregate on non-JS repos (#3266, #3267, #3268): free regressions on
* fixture repos with no .gstack/pregate.json, the state a user is in before
* opting in. Django files loaded by name select the app's tests (or the
* suite through the settings module); assets, licence text and dotfile tool
* config take an empty lane; `init` drafts a policy from the repo. Each
* case keeps a negative control: unmapped source code still fails closed, a
* repo without Django settings gets no Django edges, and runtime dotfiles and
* plain text stay unmapped. Dart lanes are covered by pregate-dart.test.ts,
* the --base refusal by pregate-wtree-refusal.test.ts; the last block pins the
* review fixes made while porting #3285 (shell scopes, Dart directive shapes).
*/
import { afterEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { referencesIn } from '../lib/pregate/import-graph';
import { describeDraft, draftConfig } from '../lib/pregate/init-draft';
import { parseWorkflow } from '../lib/pregate/workflows';
import { cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture';
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const fixture = (baseFiles: Record<string, string> = {}) => { const r = makeFixtureRepo({ pregate: null, baseFiles }); repos.push(r); return r; };
const lanes = (repo: FixtureRepo) => runBin(repo, 'gstack-pregate', ['--base', 'main', '--only', 'lanes', '--explain']);
const DJANGO_APP = {
'orders/__init__.py': '',
'orders/apps.py': 'from django.apps import AppConfig\n\nclass OrdersConfig(AppConfig):\n name = "orders"\n',
'orders/models.py': 'from django.db import models\n\nclass Order(models.Model):\n sku = models.CharField(max_length=20)\n',
'orders/admin.py': 'from django.contrib import admin\nfrom .models import Order\n\nadmin.site.register(Order)\n',
'orders/migrations/__init__.py': '',
'orders/migrations/0001_initial.py': 'from django.db import migrations\n\nclass Migration(migrations.Migration):\n operations = []\n',
'orders/templates/orders/detail.html': '{% extends "base.html" %}\n',
'orders/tests/__init__.py': '',
'orders/tests/test_models.py': 'from orders.models import Order\n\ndef test_order():\n assert Order\n',
'stores/__init__.py': '',
'stores/backends.py': 'class StoreOwnerBackend:\n pass\n',
'shop/__init__.py': '',
'shop/urls.py': 'from django.urls import path\nfrom . import views\n\nurlpatterns = [path("", views.home)]\n',
'shop/views.py': 'def home(request):\n return None\n',
'config/__init__.py': '',
'config/urls.py': 'from django.urls import include, path\n\nurlpatterns = [path("shop/", include("shop.urls"))]\n',
'templates/base.html': '<html>{% block content %}{% endblock %}</html>\n',
'tests/test_smoke.py': 'def test_smoke():\n assert True\n',
};
const SETTINGS = 'INSTALLED_APPS = [\n "orders.apps.OrdersConfig",\n]\nAUTHENTICATION_BACKENDS = ["stores.backends.StoreOwnerBackend"]\nROOT_URLCONF = "config.urls"\n';
describe('Django files loaded by name have a lane (#3266)', () => {
test('a migration, admin.py and an app template select the app tests; a settings string, an include() and a project template reach the suite', () => {
const repo = fixture({ ...DJANGO_APP, 'config/settings.py': SETTINGS });
commit(repo, {
'orders/migrations/0002_order_supplier_sku.py': 'from django.db import migrations\n\nclass Migration(migrations.Migration):\n dependencies = [("orders", "0001_initial")]\n',
'orders/admin.py': 'from django.contrib import admin\nfrom .models import Order\n\nadmin.site.register(Order)\nadmin.site.site_header = "Orders"\n',
'orders/templates/orders/detail.html': '{% extends "base.html" %}\n{% block content %}detail{% endblock %}\n',
'templates/base.html': '<html><body>{% block content %}{% endblock %}</body></html>\n',
'stores/backends.py': 'class StoreOwnerBackend:\n def authenticate(self, request):\n return None\n',
'shop/views.py': 'def home(request):\n return "home"\n',
});
const r = lanes(repo);
expect(r.err).not.toContain('PREGATE_NO_LANE');
expect(r.out).not.toContain('has no test lane');
expect(r.out).toContain('orders/tests/test_models.py ← orders/migrations/0002_order_supplier_sku.py (framework)');
expect(r.out).toContain('orders/tests/test_models.py ← orders/admin.py (framework)');
expect(r.out).toContain('orders/tests/test_models.py ← orders/templates/orders/detail.html (framework)');
expect(r.out).toContain('tests/test_smoke.py ← config/settings.py ← templates/base.html (framework)');
expect(r.out).toContain('tests/test_smoke.py ← config/settings.py ← stores/backends.py (framework)');
expect(r.out).toMatch(/tests\/test_smoke\.py ← config\/settings\.py ← config\/urls\.py ← shop\/urls\.py ← shop\/views\.py \(framework\)/);
});
test('negative controls: no Django edges without a settings module, from settings text inside a string or comment, or without Django evidence; unmapped Python in a Django repo still fails closed', () => {
const plain = fixture(DJANGO_APP);
commit(plain, { 'orders/admin.py': 'from django.contrib import admin\nadmin.site.site_header = "Orders"\n' });
const r = lanes(plain);
expect(r.status).toBe(EXIT.fail);
expect(r.out).toContain('FAIL lanes — orders/admin.py has no test lane');
expect(r.out).not.toContain('(framework)');
const quoted = fixture({ ...DJANGO_APP, 'docs_example.py': 'EXAMPLE = """\nINSTALLED_APPS = []\n"""\n# ROOT_URLCONF = "config.urls"\n' });
commit(quoted, { 'orders/admin.py': 'from django.contrib import admin\nadmin.site.site_header = "Orders"\n' });
const q = lanes(quoted);
expect(q.out).toContain('FAIL lanes — orders/admin.py has no test lane');
expect(q.out).not.toContain('(framework)');
const noDjango = fixture({ 'app/settings.py': 'INSTALLED_APPS = ["app.plugins.Core"]\n', 'app/__init__.py': '', 'app/plugins.py': 'class Core: pass\n', 'app/admin.py': 'X = 1\n', 'app/models.py': 'Y = 2\n', 'tests/test_app.py': 'def test_x():\n assert True\n' });
commit(noDjango, { 'app/admin.py': 'X = 2\n' });
const n = lanes(noDjango);
expect(n.out).toContain('FAIL lanes — app/admin.py has no test lane');
expect(n.out).not.toContain('(framework)');
const django = fixture({ ...DJANGO_APP, 'config/settings.py': SETTINGS });
commit(django, { 'scripts/oneoff.py': 'print("not loaded by Django or any test")\n' });
const bad = lanes(django);
expect(bad.status).toBe(EXIT.fail);
expect(bad.err).toContain('(PREGATE_NO_LANE)');
expect(bad.out).toContain('FAIL lanes — scripts/oneoff.py has no test lane');
});
test('DJANGO_SETTINGS_MODULE in pytest.ini names the settings module when it assigns neither INSTALLED_APPS nor ROOT_URLCONF directly', () => {
const repo = fixture({ ...DJANGO_APP, 'config/settings/__init__.py': '', 'config/settings/base.py': 'DEBUG = False\n', 'config/settings/test.py': 'from .base import *\nAUTHENTICATION_BACKENDS = ["stores.backends.StoreOwnerBackend"]\n', 'pytest.ini': '[pytest]\nDJANGO_SETTINGS_MODULE = config.settings.test\n' });
commit(repo, { 'stores/backends.py': 'class StoreOwnerBackend:\n pass\n\n# edited\n' });
const r = lanes(repo);
expect(r.err).not.toContain('PREGATE_NO_LANE');
expect(r.out).toContain('tests/test_smoke.py ← config/settings/test.py ← stores/backends.py (framework)');
});
});
describe('assets, licence text and tool config take an empty lane (#3267)', () => {
test('binary assets, a vendored licence and dotfile tool config clear without a policy', () => {
const repo = fixture();
commit(repo, {
'.pre-commit-config.yaml': 'repos: []\n',
'.impeccable/config.json': '{"theme": "dark"}\n',
'static/fonts/roboto-latin-500-normal.woff2': 'wOF2\u0000\u0001\u0000\u0000',
'static/fonts/roboto-LICENSE.txt': 'Apache License\nVersion 2.0\n',
'templates/snippets/google_g_mark.svg': '<svg xmlns="http://www.w3.org/2000/svg"/>\n',
'static/img/logo.png': '\u0089PNG\r\n\u001a\n',
});
const r = lanes(repo);
expect(r.status).toBe(EXIT.ok);
expect(r.err).not.toContain('PREGATE_NO_LANE');
expect(r.out).not.toContain('has no test lane');
expect(r.out).toContain('docs/no-tests (empty lane): static/fonts/roboto-LICENSE.txt');
expect(r.out).toContain('no-tests (assets and tool config): .impeccable/config.json .pre-commit-config.yaml static/fonts/roboto-latin-500-normal.woff2 static/img/logo.png templates/snippets/google_g_mark.svg');
expect(r.out).toMatch(/docs\/no-tests: 1 documentation file\(s\) and 5 asset or tool-config file\(s\); nothing touched selects a test/);
});
test('a tool-config-only branch passes on its own: the reported .pre-commit-config.yaml case', () => {
const repo = fixture();
commit(repo, { '.pre-commit-config.yaml': 'repos: []\n', 'notes/why.md': '# why\n' });
const r = lanes(repo);
expect(r.status).toBe(EXIT.ok);
expect(r.out).toContain('no-tests (assets and tool config): .pre-commit-config.yaml');
});
test('negative controls: runtime and unknown dotfiles, hooks, CI workflows, stylesheets, snapshot baselines, plain text and code stay unmapped; an asset a test names selects that test', () => {
const repo = fixture();
commit(repo, {
'.nvmrc': '24\n',
'.env.test': 'DATABASE_URL=sqlite://\n',
'.github/workflows/lint.yml': 'name: Lint\non: [push]\njobs:\n lint:\n runs-on: ubuntu-24.04\n steps:\n - run: echo lint\n',
'notes/sketch.txt': 'not licence text\n',
'lib/orphan.js': 'exports.lonely = () => 3;\n',
'.rspec': '--tag ~slow\n',
'.husky/pre-commit': 'npx lint-staged\n',
'styles/colors.css': ':root { --brand: red; }\n',
'src/__image_snapshots__/home.png': '\u0089PNG',
'.gstack/icon.svg': '<svg/>\n',
});
const r = lanes(repo);
expect(r.status).toBe(EXIT.fail);
for (const f of ['.nvmrc', '.env.test', '.github/workflows/lint.yml', 'notes/sketch.txt', 'lib/orphan.js', '.rspec', '.husky/pre-commit', 'styles/colors.css', 'src/__image_snapshots__/home.png', '.gstack/icon.svg']) expect(r.out).toContain(`FAIL lanes — ${f} has no test lane`);
expect(r.out).not.toContain('no-tests (assets and tool config)');
const named = fixture({ 'assets/logo.svg': '<svg/>\n', 'test/logo.test.js': "const test = require('node:test');\nconst fs = require('node:fs');\ntest('logo', () => { fs.readFileSync('assets/logo.svg', 'utf8'); });\n" });
commit(named, { 'assets/logo.svg': '<svg viewBox="0 0 1 1"/>\n' });
const sel = lanes(named);
expect(sel.out).toContain('test/logo.test.js ← assets/logo.svg');
expect(sel.out).not.toContain('no-tests (assets and tool config)');
});
});
describe('gstack-pregate init drafts a policy from the repo (#3268)', () => {
test('the draft names the languages, maps each unmapped file to its nearest tests, and lists the rest as TODO; it validates', () => {
const repo = fixture({
...DJANGO_APP,
'config/settings.py': SETTINGS,
'orders/services.py': 'def total():\n return 0\n',
'tools/release.py': 'print("release")\n',
});
const r = runBin(repo, 'gstack-pregate', ['init']);
expect(r.status).toBe(EXIT.ok);
expect(r.out).toContain('PREGATE_CONFIG_WRITTEN: .gstack/pregate.json');
expect(r.out).toMatch(/PREGATE_INIT: languages python \d+, javascript \d+/);
expect(r.out).toContain('python tests detected at run time: python3 -m pytest {files}');
expect(r.out).toContain('a selection mixing these languages needs runner set');
expect(r.out).not.toContain('TODO src/version.js');
expect(r.out).toContain('TODO tools/release.py: no test lane and no nearby test');
expect(r.out).not.toContain('TODO orders/services.py');
expect(r.out).not.toContain('TODO orders/admin.py');
const draft = JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/pregate.json'), 'utf8'));
expect(JSON.stringify(draft)).not.toContain('example-tool');
expect(draft.runner).toBeNull();
expect(draft.dependencies['orders/services.py']).toEqual(['orders/tests/test_models.py']);
expect(draft.dependencies['tools/release.py']).toBeUndefined();
expect(Object.values(draft.dependencies).every(list => (list as string[]).length > 0)).toBe(true);
expect(runBin(repo, 'gstack-pregate', ['validate']).status).toBe(EXIT.ok);
const again = runBin(repo, 'gstack-pregate', ['init']);
expect(again.status).toBe(EXIT.refused);
expect(again.err).toContain('POLICY_EXISTS');
});
test('a repo with only JavaScript tests drafts no dependencies for files the graph already maps', () => {
const repo = fixture();
const r = runBin(repo, 'gstack-pregate', ['init']);
expect(r.status).toBe(EXIT.ok);
expect(r.out).toMatch(/PREGATE_INIT: languages javascript \d+/);
const draft = JSON.parse(fs.readFileSync(path.join(repo.work, '.gstack/pregate.json'), 'utf8'));
expect(draft.dependencies['lib/util.js']).toBeUndefined();
expect(draft.dependencies['lib/retry.js']).toBeUndefined();
expect(r.out).toContain('TODO selection: windows.yml/windows-shard computes its matrix');
const direct = draftConfig(repo.work);
expect(direct.todo).toContain('lib/orphan.js');
expect(describeDraft(direct)).toContain('TODO lib/orphan.js: no test lane and no nearby test; add a test, map it under dependencies, or declare [] if no test can cover it');
});
});
describe('gstack-pregate init respects the schema limit (#3268)', () => {
test('past 256 drafted entries the rest are TODO, and the draft still validates', () => {
const many: Record<string, string> = { 'pkg/tests/test_pkg.py': 'def test_pkg():\n assert True\n' };
for (let n = 0; n < 260; n++) many[`pkg/mod_${n}.py`] = `VALUE = ${n}\n`;
const repo = fixture(many);
const draft = draftConfig(repo.work);
expect(Object.keys(JSON.parse(draft.json).dependencies)).toHaveLength(256);
expect(draft.todo.filter(f => f.startsWith('pkg/mod_'))).toHaveLength(4);
expect(runBin(repo, 'gstack-pregate', ['init']).status).toBe(EXIT.ok);
expect(runBin(repo, 'gstack-pregate', ['validate']).status).toBe(EXIT.ok);
});
});
describe('review fixes on the #3285 port: selection never shrinks on syntax it cannot follow', () => {
const files = (run: string) => parseWorkflow('ci.yml', `jobs:\n unit:\n runs-on: ubuntu-24.04\n steps:\n - run: ${run}\n`)[0]!.testFiles;
test('a cd inside a pipeline, an alternative, a background job, control flow or a redirected subshell leaves the lane unknown instead of moving later paths', () => {
expect(files('cd pkg | cat; bun test test/a.test.ts')).toEqual([]);
expect(files('(cd pkg && echo ok) > logfile; bun test test/a.test.ts')).toEqual([]);
expect(files('cd pkg && bun test test/a.test.ts')).toEqual(['pkg/test/a.test.ts']);
expect(files('(cd pkg && bun test test/b.test.ts); bun test test/a.test.ts')).toEqual(['pkg/test/b.test.ts', 'test/a.test.ts']);
expect(files('bun test test/a.test.ts | tee log')).toEqual(['test/a.test.ts']);
expect(files('cd . || cd elsewhere; bun test test/a.test.ts')).toEqual([]);
expect(files('cd pkg & wait; bun test test/a.test.ts')).toEqual([]);
expect(files('if [ -d pkg ]; then cd pkg; fi; bun test test/a.test.ts')).toEqual([]);
});
test('Dart directives: an unindented conditional clause, nested comments, a long show list, two on a line, metadata, a quoted semicolon; part of is not a directive', () => {
const files = new Set(['lib/part;name.dart', 'lib/core.dart', 'lib/core_io.dart', 'lib/a.dart', 'lib/b.dart', 'lib/c.dart', 'lib/big.dart', 'lib/nope.dart', 'lib/commented.dart', 'lib/lib.dart']);
const targets = (source: string) => referencesIn(files, new Set(['lib']), 'lib/main.dart', source).map(r => r.target);
expect(targets("import 'core.dart'\nif (dart.library.io) 'core_io.dart';\n")).toEqual(['lib/core.dart', 'lib/core_io.dart']);
expect(targets("/* outer /* inner */ still a comment 'nope.dart' */\nimport 'a.dart';\n")).toEqual(['lib/a.dart']);
expect(targets(`import 'big.dart' show ${Array.from({ length: 600 }, (_, n) => `name${n}`).join(', ')};\n`)).toEqual(['lib/big.dart']);
expect(targets("import 'a.dart'; export 'b.dart';\npart 'c.dart';\npart of 'lib.dart';\n")).toEqual(['lib/a.dart', 'lib/b.dart', 'lib/c.dart']);
expect(targets("// import 'commented.dart';\nvoid main() {}\n")).toEqual([]);
expect(targets("@deprecated import 'a.dart';\n@Deprecated('use b') export 'b.dart';\n")).toEqual(['lib/a.dart', 'lib/b.dart']);
expect(targets("import 'part;name.dart';\n")).toEqual(['lib/part;name.dart']);
});
});