Files
gstack/bin/gstack-regen
T
8f103baa71 v1.91.131.0 feat(pregate): Python import lanes (#3201) and a missing-dependencies regen diagnostic (#3252)
* feat(pregate): lanes follows Python imports and mock.patch targets

The import graph only parsed JS/TS imports, so in a Python repo every
touched module failed `lanes` with PREGATE_NO_LANE even though
SOURCE_EXT, TEST_FILE_RE and the pytest runner/summary already
understood Python.

Python sources now contribute `import a.b`, `from a.b import c`
(c may be a submodule), parenthesised multi-line lists and relative
`from ..pkg import x` edges, resolved to `a/b.py` or `a/b/__init__.py`
from the repository root. Package `__init__.py` files on the path get
edges too, since Python executes them on import. Modules outside the
repo resolve to nothing. In test files, dotted string literals such as
`'pkg.mod.Class.method'` (mock.patch targets) resolve to the longest
dotted prefix that is a repo module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(regen): report missing dependencies as REGEN_DEPS_MISSING, not REGEN_STALE

When package.json declares dependencies and node_modules is missing, the
scratch worktree runs the generator without them. A generator that degrades
and still exits 0 then looked stale, and the fix line sent people to rerun
the generator. gstack-regen check and the pregate regen check now report
"dependencies missing; run <install>" with its own result code and
troubleshooting anchor; the install command follows the lockfile. The
fixture reproduces the shape GSTA-31 saw in gbrain.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): keep Python import scanning linear and conservative

Review of #3201 on the integrated tree found two quadratic paths on
untrusted Python source and three ways a test could be left unselected:

- The from-import module spec let two repetitions share a dot, so a
  failed match on a long run of dots backtracked quadratically. The spec
  is now dots followed by an optional word-led name.
- Dotted literals and imports resolved every prefix with repeated
  split/join. Names are capped at 32 segments, deeper than any module path.
- A literal that also named a repo file skipped its mock.patch reading;
  both readings now add edges.
- A package and a same-named module both map (Python loads the package).
- Imports after `;` or a compound statement's `:` are recognised.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): linear Python string blanking, non-empty from-spec, repo-bounded depth

Second review round on the Python import scanner:

- An empty from-spec let the whitespace on both sides of it compete, so
  `from` followed by a long run of spaces backtracked quadratically. The
  spec now starts with a dot or a word character.
- Strings and comments are blanked by a single linear scanner that honours
  backslash escapes, so an escaped triple quote no longer ends a docstring
  early and swallows the imports after it.
- The fixed 32-segment cap dropped real deep modules. Dotted names are now
  bounded by the repo's deepest path, which no module can exceed.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): unreleased entry for Python import lanes and REGEN_DEPS_MISSING

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.126.0 stamp

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* release slot 1.91.126.0: back to unstamped (CHANGELOG entry unreleased)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(changelog): resolve the main-merge conflict (Unreleased entry above 1.91.127.0)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.131.0 stamp

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: Scott Gigante <scott@gethubflow.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-18 01:54:14 +00:00

133 lines
9.4 KiB
TypeScript
Executable File

#!/usr/bin/env bun
/**
* gstack-regen — the generated-file registry `.gstack/generated.json` (plan C4).
*
* gstack-regen check [--allow-repo-commands] [--base <ref>] [--registry-from base|head] [--only <id,..>] [--touched <path,..>] [--json]
* gstack-regen write [--allow-repo-commands] [--base <ref>] [--registry-from base|head] [--only <id,..>] [--json]
* gstack-regen list | validate | init [--explain] [--force]
*
* `check` regenerates every selected entry in a scratch `git worktree` seeded
* from the working tree (node_modules linked, never copied) and reports stale
* outputs without touching the tree; `write` regenerates in place. Commands
* are read from the committed copy on origin/<base>; they run only with
* --allow-repo-commands (what /ship passes). Standalone, `check`/`write` list
* the commands and exit 3 (REPO_COMMANDS_NOT_ALLOWED). Workflow commands end
* with the GSTACK_RESULT line. Logic: lib/regen.ts.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT, exitTableHelp, formatResult } from '../lib/headless-artifacts';
import {
GENERATED_PATH, exampleRegistryJson, isRegistryUnavailable, loadRegistryFromBase, loadRegistryFromHead, renderEntry, runRegen,
type RegistryLoad,
} from '../lib/regen';
import { RESULT_CODES, resultLine } from '../lib/result-codes';
import { policyUnavailableLine, resolveBaseBranch } from '../lib/ship-policy';
const HELP = `gstack-regen — generated files regenerate, never hand-merge (docs/pregate.md#generated)
check regenerate in a scratch git worktree; report stale entries (exit 1) without touching the tree
write regenerate in place (the restamp and the Step 3 conflict rule)
list print every entry: id, command, outputs, inputs
validate schema and containment check of the working-tree ${GENERATED_PATH}
init [--explain] [--force] write an example registry
--allow-repo-commands run the registry's commands (what /ship passes); without it, check/write list them and exit 3
--registry-from base|head base (default, reviewed: origin/<base>) or head (unreviewed; needs --allow-repo-commands)
--base <ref> base branch (default: origin/HEAD, else main)
--only <id,...> restrict to these entries
--touched <path,...> restrict to entries whose inputs or outputs intersect these paths
--json JSON only on stdout
Prints one \`regen <id>: fresh|stale|failed|listed|written\` line per entry and
GSTACK_RESULT: skill=regen status=complete|incomplete|refused run=<wtree>.
${exitTableHelp()}`;
function flag(args: string[], name: string): string | undefined {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
}
function list(v: string | undefined): string[] | undefined {
return v === undefined ? undefined : v.split(',').map(s => s.trim()).filter(Boolean);
}
function repoRoot(): string {
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 15_000 });
return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : process.cwd();
}
function load(root: string, rest: string[], defaultFrom: 'base' | 'head'): RegistryLoad | number {
const from = flag(rest, '--registry-from') ?? defaultFrom;
if (from !== 'base' && from !== 'head') { process.stderr.write(`gstack-regen: --registry-from needs base or head\n`); return EXIT.usage; }
if (from === 'head') {
if (defaultFrom === 'base' && !rest.includes('--allow-repo-commands')) { process.stderr.write('gstack-regen: --registry-from head needs --allow-repo-commands (the working-tree registry is unreviewed)\n'); return EXIT.refused; }
return loadRegistryFromHead(root);
}
const base = resolveBaseBranch(root, flag(rest, '--base'));
const loaded = loadRegistryFromBase(root, base);
if (isRegistryUnavailable(loaded)) { process.stderr.write(policyUnavailableLine('gstack-regen', base, loaded.detail).replace('.gstack/ship-policy.json', GENERATED_PATH) + '\n'); return EXIT.refused; }
return loaded;
}
function main(argv: string[]): number {
const [cmd, ...rest] = argv;
const json = rest.includes('--json');
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
const root = repoRoot();
if (cmd === 'init') {
if (rest.includes('--explain')) {
process.stdout.write('entries[].id: stable id ([a-z0-9][a-z0-9_.-]*), used by --only and the pre-gate\nentries[].command: the one command that regenerates the outputs; null lists outputs regenerated by hand\nentries[].outputs: repo-relative globs the command writes (non-empty)\nentries[].inputs: repo-relative globs that feed it; empty or absent means always regenerate\nentries[].note: free text shown in list and --explain\n');
if (rest.length === 1) return EXIT.ok;
}
const file = path.join(root, GENERATED_PATH);
if (fs.existsSync(file) && !rest.includes('--force')) { process.stderr.write(resultLine('gstack-regen init', 'POLICY_EXISTS', file) + '\n'); return EXIT.refused; }
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, exampleRegistryJson());
process.stdout.write(`REGISTRY_WRITTEN: ${GENERATED_PATH} (edit it, then gstack-regen validate; commit it on the base branch before /ship reads it)\n`);
return EXIT.ok;
}
if (cmd === 'validate' || cmd === 'list') {
const fromBase = cmd === 'list' && flag(rest, '--registry-from') === 'base';
const loaded = fromBase ? loadRegistryFromBase(root, resolveBaseBranch(root, flag(rest, '--base'))) : loadRegistryFromHead(root);
if (isRegistryUnavailable(loaded)) { process.stderr.write(policyUnavailableLine('gstack-regen', loaded.base, loaded.detail).replace('.gstack/ship-policy.json', GENERATED_PATH) + '\n'); return EXIT.refused; }
for (const e of loaded.errors) process.stderr.write(resultLine(`gstack-regen ${cmd}`, e.code, `${e.message} at ${e.path || GENERATED_PATH}`) + '\n');
if (json) { process.stdout.write(JSON.stringify({ valid: loaded.errors.length === 0, present: loaded.present, source: loaded.source, entries: loaded.registry.entries, errors: loaded.errors }) + '\n'); return loaded.errors.length ? EXIT.fail : EXIT.ok; }
if (cmd === 'validate') { process.stdout.write(loaded.errors.length ? `REGISTRY_INVALID: ${loaded.errors.length} error(s)\n` : `REGISTRY_VALID: ${loaded.present ? GENERATED_PATH : `no ${GENERATED_PATH} (nothing registered)`}\n`); return loaded.errors.length ? EXIT.fail : EXIT.ok; }
process.stdout.write(`registry: ${loaded.source.label}\n`);
for (const e of loaded.registry.entries) process.stdout.write(`${e.id}: command=${e.command === null ? 'null (by hand)' : JSON.stringify(e.command)} outputs=${e.outputs.join(',')} inputs=${e.inputs.join(',') || '(always)'}${e.note ? ` note=${JSON.stringify(e.note)}` : ''}\n`);
return loaded.errors.length ? EXIT.fail : EXIT.ok;
}
if (cmd !== 'check' && cmd !== 'write') { process.stderr.write(`gstack-regen: unknown command ${cmd}\n${HELP}\n`); return EXIT.usage; }
const loaded = load(root, rest, 'base');
if (typeof loaded === 'number') return loaded;
if (isRegistryUnavailable(loaded)) return EXIT.refused;
if (loaded.errors.length) {
for (const e of loaded.errors) process.stderr.write(resultLine(`gstack-regen ${cmd}`, e.code, `${e.message} at ${e.path || GENERATED_PATH} (${loaded.source.label})`) + '\n');
process.stdout.write(formatResult('regen', 'refused', loaded.source.label) + '\n');
return EXIT.refused;
}
const commands = loaded.registry.entries.filter(e => e.command);
if (commands.length && !rest.includes('--allow-repo-commands')) {
process.stderr.write(`gstack-regen ${cmd}: ${RESULT_CODES.REPO_COMMANDS_NOT_ALLOWED.summary.replace('the policy declares commands', `${GENERATED_PATH} declares ${commands.length} command(s)`)}; fix: pass --allow-repo-commands (what /ship does) (REPO_COMMANDS_NOT_ALLOWED)\n`);
for (const e of commands) process.stdout.write(`COMMAND: ${e.id}=${JSON.stringify(e.command)} outputs=${e.outputs.join(',')}\n`);
process.stdout.write(formatResult('regen', 'refused', loaded.source.label) + '\n');
return EXIT.refused;
}
let outcome;
try { outcome = runRegen({ repoRoot: root, registry: loaded.registry, mode: cmd, only: list(flag(rest, '--only')), touched: list(flag(rest, '--touched')) }); }
catch (e: any) { process.stderr.write(resultLine(`gstack-regen ${cmd}`, 'PREGATE_INCOMPLETE', e.message) + '\n'); process.stdout.write(formatResult('regen', 'incomplete', loaded.source.label) + '\n'); return EXIT.fail; }
const bad = outcome.results.filter(r => r.status === 'stale' || r.status === 'failed' || r.status === 'deps-missing');
const status = outcome.results.some(r => r.status === 'failed' || r.status === 'deps-missing') ? 'incomplete' : 'complete';
if (json) { process.stdout.write(JSON.stringify({ mode: cmd, registry: loaded.source.label, wtree: outcome.wtree, results: outcome.results, ok: bad.length === 0 }) + '\n'); return bad.length ? EXIT.fail : EXIT.ok; }
process.stdout.write(`registry: ${loaded.source.label}\n`);
for (const r of outcome.results) process.stdout.write(renderEntry(r) + '\n');
process.stdout.write(`REGEN: ${cmd} ${bad.length ? `${bad.length} stale or failed` : 'fresh'} (${outcome.results.length} entries)\n`);
process.stdout.write(formatResult('regen', status, outcome.wtree ?? loaded.source.label) + '\n');
return bad.length ? EXIT.fail : EXIT.ok;
}
process.exitCode = main(process.argv.slice(2));