feat(devkit): add resumable workspace automation and UI review tooling
Verified with the coordinated workspace changes by devkit full run 2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed). This shared UI pass does not mark the individual module reviews complete.
This commit is contained in:
Executable
+55
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { auditRepository } from "../tools/devkit/audit-display-labels.mjs";
|
||||
|
||||
const require = createRequire(resolve(import.meta.dirname, "../../govoplan-core/webui/package.json"));
|
||||
const ts = require("typescript");
|
||||
function fixture(fn) {
|
||||
const root = mkdtempSync(join(tmpdir(), "govoplan-label-audit-"));
|
||||
const write = (relative, content) => { const target = join(root, relative); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, content); };
|
||||
try {
|
||||
write("core/webui/src/i18n/generatedTranslations.ts", 'export const generatedTranslations = { en: { Shared: "Shared" }, de: { Shared: "Gemeinsam" } };');
|
||||
return fn(root, write);
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
test("plain labels resolve owning registered catalogs, aliases, constants and Core defaults", () => fixture((root, write) => {
|
||||
write("module/webui/src/i18n/generatedTranslations.ts", 'const en = { Projects: "Projects" }; const de = { Projects: "Projekte" }; export const generatedTranslations = { en, de };');
|
||||
write("module/webui/src/module.ts", 'import { generatedTranslations as words } from "./i18n/generatedTranslations"; export const exampleModule: PlatformWebModule = { translations: words };');
|
||||
write("module/webui/src/Page.tsx", 'import { PageLayout as Layout, PageTitle } from "@govoplan/core-webui"; const title = "Projects"; export const page = <><Layout title={title} /><PageTitle>Shared</PageTitle></>;');
|
||||
const result = auditRepository(ts, join(root, "module"), join(root, "core"));
|
||||
assert.equal(result.registration, "registered");
|
||||
assert.equal(result.labels.length, 2);
|
||||
assert.deepEqual(result.findings, []);
|
||||
}));
|
||||
|
||||
test("a catalog file without module registration cannot mask untranslated titles", () => fixture((root, write) => {
|
||||
write("module/webui/src/i18n/generatedTranslations.ts", 'export const generatedTranslations = { en: { Projects: "Projects" }, de: { Projects: "Projekte" } };');
|
||||
write("module/webui/src/module.ts", 'export const exampleModule = { id: "example" };');
|
||||
write("module/webui/src/Page.tsx", 'import * as Core from "@govoplan/core-webui"; export const page = <Core.PageLayout title="Projects" />;');
|
||||
const result = auditRepository(ts, join(root, "module"), join(root, "core"));
|
||||
assert(result.findings.some((item) => item.code === "catalog-not-registered"));
|
||||
assert.deepEqual(result.labels[0].missing_locales, ["en", "de"]);
|
||||
}));
|
||||
|
||||
test("only known static display slots fail; markers and runtime data remain separate", () => fixture((root, write) => {
|
||||
write("module/webui/src/Page.tsx", 'import { PageLayout } from "@govoplan/core-webui"; export const page = <><PageLayout title="Untranslated" /><PageLayout title={campaign.name} /><PageLayout title="i18n:known.key" /><CustomThing title="Not a known slot" /></>;');
|
||||
const result = auditRepository(ts, join(root, "module"), join(root, "core"));
|
||||
assert.equal(result.findings.length, 1);
|
||||
assert.equal(result.findings[0].text, "Untranslated");
|
||||
assert.equal(result.review.length, 1);
|
||||
assert.equal(result.review[0].code, "dynamic-display-slot");
|
||||
}));
|
||||
|
||||
test("dynamic translation registration is reported for review, not falsely missing", () => fixture((root, write) => {
|
||||
write("module/webui/src/module.ts", 'export const exampleModule: PlatformWebModule = { translations: configuredCatalog() };');
|
||||
write("module/webui/src/Page.tsx", 'export const page = <PageTitle>Example</PageTitle>;');
|
||||
const result = auditRepository(ts, join(root, "module"), join(root, "core"));
|
||||
assert.equal(result.registration, "dynamic");
|
||||
assert.deepEqual(result.findings, []);
|
||||
assert(result.review.some((item) => item.code === "dynamic-catalog-review"));
|
||||
}));
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { findDetachedDocumentation } from "../tools/checks/check-heading-help.mjs";
|
||||
import "./test-heading-translations.mjs";
|
||||
|
||||
const inspect = (source) => findDetachedDocumentation([{ path: "/fixture/Page.tsx", source }]).findings;
|
||||
const book = '<DocumentationHelpLink reference={topic} />';
|
||||
|
||||
test("heading and text contracts accept contextual help", () => {
|
||||
for (const component of ["PageHeader", "PageLayout", "AdminPageLayout", "Card", "Dialog", "PageActionBar", "WorkspaceActionBar"]) {
|
||||
assert.equal(inspect(`const Page=()=> <${component} title="Topic" titleHelp={${book}} />`).length, 0, component);
|
||||
}
|
||||
assert.equal(inspect(`const Page=()=> <PageTitle titleHelp={${book}}>Topic</PageTitle>`).length, 0);
|
||||
assert.equal(inspect(`const Page=()=> <TextWithHelp help={${book}}>Existing label</TextWithHelp>`).length, 0);
|
||||
});
|
||||
|
||||
test("detached action and body links are rejected", () => {
|
||||
for (const source of [`<PageActionBar helpAction={${book}} />`, `<Card title="Topic" actions={${book}} />`, `<div>${book}</div>`, `<Card title={${book}} />`]) {
|
||||
assert.equal(inspect(`const Page=()=> ${source}`).length, 1, source);
|
||||
}
|
||||
});
|
||||
|
||||
test("aliases, conditional help and simple local references remain checked", () => {
|
||||
assert.equal(inspect(`import {DocumentationHelpLink as Book, Card as Box} from '@govoplan/core-webui'; const Page=()=> <Box title="Topic" titleHelp={<Book reference={topic}/>} />`).length, 0);
|
||||
assert.equal(inspect(`import {DocumentationHelpLink as Book} from '@govoplan/core-webui'; const Page=()=> <div><Book reference={topic}/></div>`).length, 1);
|
||||
assert.equal(inspect(`const help=${book}; const Page=()=> <Card title="Topic" titleHelp={enabled ? help : null}/>`).length, 0);
|
||||
assert.equal(inspect(`const help=${book}; const Page=()=> <><Card title="Topic" titleHelp={help}/><PageActionBar helpAction={help}/></>`).length, 1);
|
||||
assert.equal(inspect(`const help=${book}; const again=help; const Page=()=> <Card title="Topic" titleHelp={again}/>`).length, 0);
|
||||
});
|
||||
|
||||
test("empty anchors, unknown contracts and nested interactive elements fail", () => {
|
||||
for (const source of [`<Card titleHelp={${book}}/>`, `<TextWithHelp help={${book}}/>`, `<TextWithHelp help={${book}}> </TextWithHelp>`, `<Unknown title="Topic" titleHelp={${book}}/>`, `<Card title="Topic" titleHelp={<button>${book}</button>}/>`]) {
|
||||
assert.equal(inspect(`const Page=()=> ${source}`).length, 1, source);
|
||||
}
|
||||
});
|
||||
|
||||
test("namespace and explicit component default imports cannot bypass placement", () => {
|
||||
for (const source of [
|
||||
`import * as UI from '@govoplan/core-webui'; const Page=()=> <div><UI.DocumentationHelpLink reference={topic}/></div>`,
|
||||
`import Book from './components/help/DocumentationHelpLink'; const Page=()=> <div><Book reference={topic}/></div>`,
|
||||
]) assert.equal(inspect(source).length, 1, source);
|
||||
assert.equal(inspect(`import * as UI from '@govoplan/core-webui'; const Page=()=> <UI.Card title="Topic" titleHelp={<UI.DocumentationHelpLink reference={topic}/>}/>`).length, 0);
|
||||
assert.equal(inspect(`import Book from './components/help/DocumentationHelpLink'; import Label from './components/help/TextWithHelp'; const Page=()=> <Label help={<Book reference={topic}/>}>Topic</Label>`).length, 0);
|
||||
assert.equal(findDetachedDocumentation([{ path: "/fixture/Page.tsx", source: `import Book from './business/Book'; const Page=()=> <div><Book/></div>` }]).links, 0);
|
||||
});
|
||||
|
||||
test("outer interactive containers and statically absent text are rejected", () => {
|
||||
for (const source of [
|
||||
`<button><TextWithHelp help={${book}}>Topic</TextWithHelp></button>`,
|
||||
`<a href="/other"><Card title="Topic" titleHelp={${book}}/></a>`,
|
||||
`<Card title="" titleHelp={${book}}/>`,
|
||||
`<Card title={false} titleHelp={${book}}/>`,
|
||||
`<PageTitle titleHelp={${book}}>{null}</PageTitle>`,
|
||||
`<TextWithHelp help={${book}}>{/* Topic */}</TextWithHelp>`,
|
||||
`<TextWithHelp help={${book}}>{undefined}</TextWithHelp>`,
|
||||
`<TextWithHelp help={${book}}><span hidden>Topic</span></TextWithHelp>`,
|
||||
`<TextWithHelp hidden help={${book}}>Topic</TextWithHelp>`,
|
||||
]) assert.equal(inspect(`const Page=()=> ${source}`).length, 1, source);
|
||||
assert.equal(inspect(`const help=${book}; const Page=()=> <Button><TextWithHelp help={help}>Topic</TextWithHelp></Button>`).length, 1);
|
||||
assert.equal(inspect(`const title=null; const Page=()=> <Card title={title} titleHelp={${book}}/>`).length, 1);
|
||||
assert.equal(inspect(`const Page=()=> <Card title={translateText(title)} titleHelp={${book}}/>`).length, 0);
|
||||
assert.equal(inspect(`const Page=()=> <TextWithHelp help={${book}}><TranslatedTitle/></TextWithHelp>`).length, 0);
|
||||
assert.equal(inspect(`let title=""; title=translateText(key); const Page=()=> <Card title={title} titleHelp={${book}}/>`).length, 0);
|
||||
});
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { resolve } from "node:path";
|
||||
import { test } from "node:test";
|
||||
|
||||
const root = resolve(import.meta.dirname, "../..");
|
||||
const require = createRequire(resolve(root, "govoplan-core/webui/package.json"));
|
||||
const { buildSync } = require("esbuild");
|
||||
const labels = {
|
||||
approvals: { "Approval requests": "Genehmigungsanträge" },
|
||||
forms: { "Form meaning": "Formularbedeutung" },
|
||||
"forms-runtime": { "Form instance": "Formularinstanz", "Forms runtime": "Formularlaufzeit" },
|
||||
helpdesk: { "Helpdesk profiles": "Helpdesk-Profile" },
|
||||
portal: { "Service directory": "Leistungsverzeichnis" },
|
||||
projects: { Projects: "Projekte" },
|
||||
reporting: { Reporting: "Reporting" },
|
||||
"risk-compliance": { "Risk Compliance": "Risiko und Compliance" },
|
||||
scheduling: { Scheduling: "Terminplanung" },
|
||||
tickets: { Tickets: "Tickets" },
|
||||
voting: { Ballots: "Abstimmungen" },
|
||||
wiki: { Wiki: "Wiki" },
|
||||
workflow: { Workflows: "Workflows", Workflow: "Workflow" },
|
||||
idm: {
|
||||
"Direct changes to this governed function are": "Direkte Änderungen an dieser gesteuerten Funktion sind",
|
||||
"emergency overrides": "Notfallübersteuerungen",
|
||||
". Use a request or grant above for the normal process.": ". Verwenden Sie für den regulären Prozess einen Antrag oder eine Vergabe.",
|
||||
},
|
||||
};
|
||||
|
||||
// Render the actual Core heading/locale contract with one owning module at a
|
||||
// time. The in-memory bundle creates no shared component-build artifacts.
|
||||
const names = Object.keys(labels);
|
||||
const imports = names.map((name, index) => `import { generatedTranslations as t${index} } from ${JSON.stringify(resolve(root, `govoplan-${name}/webui/src/i18n/generatedTranslations.ts`))};`).join("\n");
|
||||
const { outputFiles } = buildSync({
|
||||
stdin: {
|
||||
contents: `${imports}
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { PlatformLanguageProvider } from ${JSON.stringify(resolve(root, "govoplan-core/webui/src/i18n/LanguageContext.tsx"))};
|
||||
import PageTitle from ${JSON.stringify(resolve(root, "govoplan-core/webui/src/components/PageTitle.tsx"))};
|
||||
import DocumentationHelpLink from ${JSON.stringify(resolve(root, "govoplan-core/webui/src/components/help/DocumentationHelpLink.tsx"))};
|
||||
export const catalogs = [${names.map((_, index) => `t${index}`).join(",")}];
|
||||
export function heading(index, language, label) {
|
||||
return renderToStaticMarkup(<PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[catalogs[index]]}>
|
||||
<PageTitle titleHelp={<DocumentationHelpLink reference={{ contextId: "heading-test" }} />}>{label}</PageTitle>
|
||||
</PlatformLanguageProvider>);
|
||||
}`,
|
||||
loader: "tsx",
|
||||
resolveDir: resolve(root, "govoplan-core/webui"),
|
||||
},
|
||||
bundle: true,
|
||||
write: false,
|
||||
platform: "node",
|
||||
format: "cjs",
|
||||
jsx: "automatic",
|
||||
external: ["react", "react-dom/server"],
|
||||
logLevel: "silent",
|
||||
});
|
||||
const compiled = { exports: {} };
|
||||
new Function("require", "module", "exports", outputFiles[0].text)(require, compiled, compiled.exports);
|
||||
const { catalogs, heading } = compiled.exports;
|
||||
|
||||
test("new contextual labels render from their owning EN/DE catalogues", async (context) => {
|
||||
for (const [index, name] of names.entries()) {
|
||||
await context.test(name, () => {
|
||||
const moduleSource = readFileSync(resolve(root, `govoplan-${name}/webui/src/module.ts`), "utf8");
|
||||
assert.match(moduleSource, /import\s*\{\s*generatedTranslations\s*\}\s*from\s*["']\.\/i18n\/generatedTranslations["']/);
|
||||
assert.match(moduleSource, /\btranslations(?:\s*:\s*generatedTranslations)?\s*,/);
|
||||
for (const [english, german] of Object.entries(labels[name])) {
|
||||
assert.equal(catalogs[index].en[english], english);
|
||||
assert.equal(catalogs[index].de[english], german);
|
||||
for (const [language, expected] of [["en", english], ["de", german]]) {
|
||||
const markup = heading(index, language, english);
|
||||
assert(markup.includes(`<h1>${expected}</h1>`), `${name}: ${language} contextual heading`);
|
||||
assert(markup.includes(language === "de" ? "Benutzerdokumentation öffnen" : "Open user documentation"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Executable
+438
@@ -0,0 +1,438 @@
|
||||
"""Audit the selected fixture workspace, never a fuller neighboring checkout."""
|
||||
|
||||
from argparse import Namespace
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = META_ROOT / "tools/inventory/platform-interface-inventory.py"
|
||||
WEBUI_SCRIPT = META_ROOT / "tools/inventory/extract-webui-structure.mjs"
|
||||
SPEC = importlib.util.spec_from_file_location("audit_scope_inventory", SCRIPT)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
inventory = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(inventory)
|
||||
|
||||
sys.path.insert(0, str(META_ROOT / "tools/devkit"))
|
||||
from govoplan_devkit import docs, issues, runner # noqa: E402
|
||||
from govoplan_devkit.workspace import Project, Repository # noqa: E402
|
||||
|
||||
|
||||
def write(path, content):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspaces(tmp_path):
|
||||
selected = tmp_path / "selected"
|
||||
default = tmp_path / "fuller-default"
|
||||
meta = tmp_path / "legacy-siblings/govoplan"
|
||||
selected.mkdir()
|
||||
meta.mkdir(parents=True)
|
||||
catalog = {
|
||||
"default_parent": str(default),
|
||||
"repositories": [
|
||||
{"name": "govoplan-core", "path": "govoplan-core"},
|
||||
{"name": "govoplan-example", "path": "nested/example"},
|
||||
{"name": "govoplan-optional", "path": "govoplan-optional"},
|
||||
],
|
||||
}
|
||||
write(meta / "repositories.json", json.dumps(catalog))
|
||||
write(
|
||||
selected / "nested/example/webui/src/Page.tsx",
|
||||
"export const page = <h1>SELECTED_ONLY</h1>;\n",
|
||||
)
|
||||
write(
|
||||
default / "nested/example/webui/src/Page.tsx",
|
||||
"export const page = <h1>DEFAULT_ONLY</h1>;\n",
|
||||
)
|
||||
write(
|
||||
default / "govoplan-optional/webui/src/Page.tsx",
|
||||
"export const page = <h1>DEFAULT_OPTIONAL</h1>;\n",
|
||||
)
|
||||
for item in catalog["repositories"]:
|
||||
(default / item["path"] / "src").mkdir(parents=True)
|
||||
return selected, default, meta, catalog
|
||||
|
||||
|
||||
def test_explicit_partial_root_beats_fuller_legacy_discovery(workspaces, monkeypatch):
|
||||
selected, default, meta, catalog = workspaces
|
||||
monkeypatch.setattr(inventory, "META_ROOT", meta)
|
||||
assert inventory._resolve_workspace_root(catalog) == default
|
||||
assert inventory._resolve_workspace_root(catalog, selected) == selected
|
||||
with pytest.raises(ValueError, match="existing directory"):
|
||||
inventory._resolve_workspace_root(catalog, selected / "missing")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("relative", ["../fuller-default", "/absolute/path"])
|
||||
def test_repository_paths_cannot_escape_explicit_root(workspaces, relative):
|
||||
selected, _, _, _ = workspaces
|
||||
with pytest.raises(ValueError, match="inside the selected workspace"):
|
||||
inventory._validate_repository_roots(
|
||||
{"repositories": [{"path": relative}]}, selected
|
||||
)
|
||||
|
||||
|
||||
def test_linked_repository_cannot_borrow_default_sources(workspaces):
|
||||
selected, default, _, catalog = workspaces
|
||||
(selected / "govoplan-core").symlink_to(
|
||||
default / "govoplan-core", target_is_directory=True
|
||||
)
|
||||
with pytest.raises(ValueError, match="escapes the selected workspace"):
|
||||
inventory._validate_repository_roots(catalog, selected)
|
||||
|
||||
|
||||
def test_python_forwards_selected_root_and_configured_node(workspaces, monkeypatch):
|
||||
selected, _, meta, _ = workspaces
|
||||
monkeypatch.setattr(inventory, "META_ROOT", meta)
|
||||
monkeypatch.setenv("NODE", "/selected/toolchain/node")
|
||||
calls = []
|
||||
|
||||
def run(argv, **kwargs):
|
||||
calls.append((argv, kwargs))
|
||||
return SimpleNamespace(stdout=json.dumps({"workspaceRoot": str(selected)}))
|
||||
|
||||
monkeypatch.setattr(inventory.subprocess, "run", run)
|
||||
assert inventory._extract_webui(selected)["workspaceRoot"] == str(selected)
|
||||
argv, options = calls[0]
|
||||
assert argv == [
|
||||
"/selected/toolchain/node",
|
||||
str(meta / "tools/inventory/extract-webui-structure.mjs"),
|
||||
str(meta),
|
||||
"--workspace-root",
|
||||
str(selected),
|
||||
]
|
||||
assert options == {"check": True, "capture_output": True, "text": True}
|
||||
|
||||
|
||||
def test_python_rejects_webui_result_from_another_root(workspaces, monkeypatch):
|
||||
selected, default, _, _ = workspaces
|
||||
monkeypatch.setattr(
|
||||
inventory.subprocess,
|
||||
"run",
|
||||
lambda *a, **k: SimpleNamespace(
|
||||
stdout=json.dumps({"workspaceRoot": str(default)})
|
||||
),
|
||||
)
|
||||
with pytest.raises(ValueError, match="did not confirm"):
|
||||
inventory._extract_webui(selected)
|
||||
|
||||
|
||||
def test_main_forwards_same_root_to_every_collector(workspaces, monkeypatch):
|
||||
selected, _, meta, catalog = workspaces
|
||||
monkeypatch.setattr(inventory, "META_ROOT", meta)
|
||||
roots = []
|
||||
monkeypatch.setattr(
|
||||
inventory, "_extract_webui", lambda root: roots.append(root) or {}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
inventory,
|
||||
"_extract_backend_endpoints",
|
||||
lambda data, root: roots.append(root) or [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
inventory, "_extract_manifests", lambda data, root: roots.append(root) or []
|
||||
)
|
||||
monkeypatch.setattr(inventory, "_load_endpoint_declarations", lambda _: {})
|
||||
monkeypatch.setattr(inventory, "_load_high_risk_help_baseline", lambda _: {})
|
||||
monkeypatch.setattr(inventory, "_assemble_inventory", lambda **kwargs: {})
|
||||
monkeypatch.setattr(inventory, "_render_markdown", lambda data: "fixture\n")
|
||||
output = selected / "output"
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[str(SCRIPT), "--workspace-root", str(selected), "--output-dir", str(output)],
|
||||
)
|
||||
assert inventory.main() == 0
|
||||
assert roots == [selected, selected, selected]
|
||||
report = json.loads((output / "platform-interface-inventory.json").read_text())
|
||||
assert report["workspace_root"] == str(selected)
|
||||
assert report["workspace_selection"] == "explicit"
|
||||
|
||||
|
||||
def install_fixture_compiler(root):
|
||||
compiler = META_ROOT.parent / "govoplan-core/webui/node_modules/typescript"
|
||||
if not compiler.is_dir() or not shutil.which("node"):
|
||||
pytest.skip("Node and the installed Core TypeScript parser are required")
|
||||
target = root / "govoplan-core/webui/node_modules/typescript"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Parser dependencies may be shared; audited source checkouts may not.
|
||||
target.symlink_to(compiler, target_is_directory=True)
|
||||
|
||||
|
||||
def collect_webui(meta, selected=None):
|
||||
argv = [shutil.which("node") or "node", str(WEBUI_SCRIPT), str(meta)]
|
||||
if selected is not None:
|
||||
argv += ["--workspace-root", str(selected)]
|
||||
return subprocess.run(argv, capture_output=True, text=True, timeout=30)
|
||||
|
||||
|
||||
def test_javascript_explicit_root_is_authoritative_and_legacy_cli_still_works(
|
||||
workspaces,
|
||||
):
|
||||
selected, default, meta, _ = workspaces
|
||||
install_fixture_compiler(selected)
|
||||
install_fixture_compiler(default)
|
||||
explicit = collect_webui(meta, selected)
|
||||
assert explicit.returncode == 0, explicit.stderr
|
||||
report = json.loads(explicit.stdout)
|
||||
assert report["workspaceRoot"] == str(selected)
|
||||
assert [item["value"] for item in report["visibleText"]] == ["SELECTED_ONLY"]
|
||||
assert report["visibleText"][0]["file"] == "webui/src/Page.tsx"
|
||||
legacy = collect_webui(meta)
|
||||
assert legacy.returncode == 0, legacy.stderr
|
||||
assert json.loads(legacy.stdout)["workspaceRoot"] == str(default)
|
||||
assert {item["value"] for item in json.loads(legacy.stdout)["visibleText"]} == {
|
||||
"DEFAULT_ONLY",
|
||||
"DEFAULT_OPTIONAL",
|
||||
}
|
||||
|
||||
|
||||
def test_javascript_does_not_borrow_missing_core_dependencies(workspaces):
|
||||
selected, default, meta, _ = workspaces
|
||||
install_fixture_compiler(default)
|
||||
result = collect_webui(meta, selected)
|
||||
assert result.returncode != 0
|
||||
assert (
|
||||
str(selected / "govoplan-core/webui/node_modules/typescript") in result.stderr
|
||||
)
|
||||
assert not result.stdout
|
||||
|
||||
|
||||
def test_javascript_rejects_source_root_linked_outside_workspace(workspaces):
|
||||
selected, default, meta, _ = workspaces
|
||||
install_fixture_compiler(selected)
|
||||
linked = selected / "govoplan-optional/webui/src"
|
||||
linked.parent.mkdir(parents=True)
|
||||
linked.symlink_to(default / "govoplan-optional/webui/src", target_is_directory=True)
|
||||
result = collect_webui(meta, selected)
|
||||
assert result.returncode != 0
|
||||
assert "source root escapes the selected workspace" in result.stderr
|
||||
|
||||
|
||||
def test_backend_does_not_borrow_optional_endpoints(workspaces):
|
||||
selected, default, _, catalog = workspaces
|
||||
source = "from fastapi import APIRouter\nrouter = APIRouter()\n@router.get('/selected')\ndef endpoint(): pass\n"
|
||||
write(selected / "nested/example/src/routes.py", source)
|
||||
write(
|
||||
default / "govoplan-optional/src/routes.py", source.replace("selected", "other")
|
||||
)
|
||||
endpoints = inventory._extract_backend_endpoints(catalog, selected)
|
||||
assert [item["path"] for item in endpoints] == ["/selected"]
|
||||
(selected / "nested/example/src/foreign.py").symlink_to(
|
||||
default / "govoplan-optional/src/routes.py"
|
||||
)
|
||||
with pytest.raises(ValueError, match="Backend source path escapes"):
|
||||
inventory._extract_backend_endpoints(catalog, selected)
|
||||
|
||||
|
||||
def test_manifests_require_selected_core_sources(workspaces, monkeypatch):
|
||||
selected, default, _, catalog = workspaces
|
||||
write(
|
||||
default / "govoplan-core/src/govoplan_core/core/platform_interfaces.py",
|
||||
"raise AssertionError('foreign source imported')",
|
||||
)
|
||||
monkeypatch.syspath_prepend(str(default / "govoplan-core/src"))
|
||||
with pytest.raises(ValueError, match="requires Core interface sources"):
|
||||
inventory._extract_manifests(catalog, selected)
|
||||
|
||||
|
||||
def test_cached_application_import_cannot_replace_missing_checkout(
|
||||
workspaces, monkeypatch
|
||||
):
|
||||
selected, default, _, _ = workspaces
|
||||
# Meta's own devkit/release packages may audit another workspace.
|
||||
tooling = ModuleType("govoplan_release")
|
||||
tooling.__file__ = str(default / "tools/release/govoplan_release/__init__.py")
|
||||
modules = {"govoplan_release": tooling, "govoplan_devkit.docs": docs}
|
||||
# Isolate the fixture from application packages collected by unrelated
|
||||
# suites, without removing or replacing those real cached imports.
|
||||
monkeypatch.setattr(inventory, "sys", SimpleNamespace(modules=modules))
|
||||
inventory._assert_workspace_imports(selected)
|
||||
application = ModuleType("govoplan_audit_fixture")
|
||||
application.__file__ = str(
|
||||
default / "govoplan-optional/src/govoplan_audit_fixture/__init__.py"
|
||||
)
|
||||
modules["govoplan_audit_fixture"] = application
|
||||
with pytest.raises(ValueError, match="outside the selected inventory workspace"):
|
||||
inventory._assert_workspace_imports(selected)
|
||||
|
||||
|
||||
def test_partial_manifest_collection_uses_selected_sources_in_fresh_process(workspaces):
|
||||
selected, default, _, catalog = workspaces
|
||||
core = selected / "govoplan-core/src/govoplan_core"
|
||||
write(core / "__init__.py", "")
|
||||
write(core / "core/__init__.py", "")
|
||||
write(
|
||||
core / "core/platform_interfaces.py",
|
||||
"def manifest_interface_catalog(manifest): return {'selected': True}\n",
|
||||
)
|
||||
write(
|
||||
default / "govoplan-core/src/govoplan_core/__init__.py",
|
||||
"raise AssertionError('foreign Core loaded')\n",
|
||||
)
|
||||
module = selected / "nested/example/src/govoplan_example"
|
||||
write(module / "__init__.py", "")
|
||||
write(module / "backend/__init__.py", "")
|
||||
manifest = (
|
||||
"from types import SimpleNamespace as S\n"
|
||||
"def get_manifest():\n"
|
||||
" return S(id='selected', name='Selected', version='1', dependencies=(), "
|
||||
"optional_dependencies=(), required_capabilities=(), provides_interfaces=(), "
|
||||
"capability_factories={}, permissions=(), documentation=(), architecture=None, "
|
||||
"information_governance=S(to_dict=lambda: {}), frontend=None)\n"
|
||||
)
|
||||
write(module / "backend/manifest.py", manifest)
|
||||
write(
|
||||
default / "govoplan-optional/src/govoplan_optional/backend/manifest.py",
|
||||
manifest.replace("selected", "foreign"),
|
||||
)
|
||||
code = (
|
||||
"import importlib.util, json; from pathlib import Path; "
|
||||
f"spec=importlib.util.spec_from_file_location('fixture', {str(SCRIPT)!r}); "
|
||||
"module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module); "
|
||||
f"print(json.dumps(module._extract_manifests({catalog!r}, Path({str(selected)!r}))))"
|
||||
)
|
||||
env = {
|
||||
**os.environ,
|
||||
"PYTHONPATH": os.pathsep.join(
|
||||
str(default / item["path"] / "src") for item in catalog["repositories"]
|
||||
),
|
||||
}
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
manifests = json.loads(result.stdout)
|
||||
assert [manifest["id"] for manifest in manifests] == ["selected"]
|
||||
assert manifests[0]["interface_catalog"] == {"selected": True}
|
||||
|
||||
|
||||
def doc_plan(workspace, monkeypatch):
|
||||
repos = tuple(
|
||||
Repository(name, workspace / name)
|
||||
for name in ("govoplan", "govoplan-core", "govoplan-example")
|
||||
)
|
||||
project = Project("fixture", repos, {})
|
||||
monkeypatch.setattr(docs, "load_project", lambda *a: project)
|
||||
args = Namespace(
|
||||
workspace_root=workspace,
|
||||
project=None,
|
||||
state_dir=workspace.parent / "state",
|
||||
repo=["govoplan-example"],
|
||||
changed=False,
|
||||
)
|
||||
return docs.build_doc_stages(args)
|
||||
|
||||
|
||||
def test_docs_plan_forwards_root_and_persists_all_limitations(workspaces, monkeypatch):
|
||||
selected, _, _, _ = workspaces
|
||||
stages = doc_plan(selected, monkeypatch)
|
||||
by_id = {stage["id"]: stage for stage in stages}
|
||||
for stage_id in (
|
||||
"docs.manifests",
|
||||
"docs.interface-inventory",
|
||||
"docs.plain-display-labels",
|
||||
):
|
||||
argv = by_id[stage_id]["argv"]
|
||||
assert argv[argv.index("--workspace-root") + 1] == str(selected)
|
||||
assert by_id["docs.translation-structure"]["argv"][1] == str(
|
||||
selected / "govoplan-core/webui/scripts/audit-i18n-structural.mjs"
|
||||
)
|
||||
assert issues.coverage_notes(stages) == docs.LIMITATIONS
|
||||
assert by_id["docs.plain-display-labels"]["argv"][-2:] == [
|
||||
"--repo",
|
||||
"govoplan-example",
|
||||
]
|
||||
|
||||
|
||||
def test_docs_limitations_survive_real_receipt_and_issue_evidence(
|
||||
workspaces, monkeypatch, tmp_path
|
||||
):
|
||||
selected, _, _, _ = workspaces
|
||||
stages = doc_plan(selected, monkeypatch)
|
||||
repo = selected / "example"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init", "-q", str(repo)], check=True, timeout=10)
|
||||
write(repo / "source.txt", "fixture\n")
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo), "add", "source.txt"], check=True, timeout=10
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(repo),
|
||||
"-c",
|
||||
"user.name=Fixture",
|
||||
"-c",
|
||||
"user.email=fixture@example.invalid",
|
||||
"commit",
|
||||
"-qm",
|
||||
"fixture",
|
||||
],
|
||||
check=True,
|
||||
timeout=10,
|
||||
)
|
||||
project = tmp_path / "project.json"
|
||||
write(
|
||||
project,
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "fixture",
|
||||
"repositories": [{"name": "example", "path": "example"}],
|
||||
"checks": [],
|
||||
"profiles": {},
|
||||
}
|
||||
),
|
||||
)
|
||||
args = Namespace(
|
||||
workspace_root=selected,
|
||||
project=project,
|
||||
state_dir=tmp_path / "state",
|
||||
dry_run=False,
|
||||
jobs=2,
|
||||
profile="docs",
|
||||
resume=None,
|
||||
)
|
||||
for stage in stages:
|
||||
stage.update(
|
||||
argv=[sys.executable, "-c", "print('fixture audit')"],
|
||||
cwd=str(repo),
|
||||
deps=[],
|
||||
resources=[],
|
||||
timeout_seconds=10,
|
||||
)
|
||||
monkeypatch.setattr(docs, "build_doc_stages", lambda _: stages)
|
||||
monkeypatch.setattr(
|
||||
runner, "environment_fingerprint", lambda *a, **k: "fixture-env"
|
||||
)
|
||||
result = docs.audit(args)
|
||||
assert result["status"] == "passed", result
|
||||
assert all(result["summary"].count(note) == 1 for note in docs.LIMITATIONS)
|
||||
receipt = runner.read_receipt(selected, args.state_dir, result["run_id"])
|
||||
assert issues.coverage_notes(receipt["stages"]) == docs.LIMITATIONS
|
||||
evidence = issues.evidence_record(result["run_id"], args)
|
||||
assert evidence["coverage_notes"] == docs.LIMITATIONS
|
||||
assert evidence["source_state"] == "matches-current"
|
||||
target = issues.NoteTarget(
|
||||
repo, "https://gitea.example.invalid", "fixture", "example", 1
|
||||
)
|
||||
_, body = issues.render_note(
|
||||
{"summary": [], "next": [], "body": ""}, evidence, target, "fixture"
|
||||
)
|
||||
assert all(note in body for note in docs.LIMITATIONS)
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
"""Signals during snapshot probes must not become passing run evidence."""
|
||||
|
||||
import os
|
||||
import signal
|
||||
|
||||
import pytest
|
||||
|
||||
import test_devkit_runner as fixtures
|
||||
from govoplan_devkit import runner
|
||||
from govoplan_devkit.checkpoints import Checkpoints
|
||||
|
||||
example = fixtures.example
|
||||
|
||||
|
||||
@pytest.mark.parametrize("interruption", [signal.SIGINT, signal.SIGTERM])
|
||||
@pytest.mark.parametrize("probe", ["source", "environment"])
|
||||
def test_interrupt_during_final_snapshot_is_not_a_passing_run(
|
||||
example, monkeypatch, interruption, probe
|
||||
):
|
||||
args, repo = example
|
||||
calls = {"source": 0, "environment": 0}
|
||||
events, interruptions = [], []
|
||||
args.on_progress = events.append
|
||||
original = Checkpoints.source
|
||||
|
||||
def reached(kind):
|
||||
calls[kind] += 1
|
||||
if kind == probe and events and events[-1]["phase"] == "finalizing":
|
||||
# Intermediate checkpoint probes are also real verification. Target
|
||||
# finalization explicitly, while the runner owns signal handlers.
|
||||
interruptions.append(kind)
|
||||
os.kill(os.getpid(), interruption)
|
||||
|
||||
def source(self, *values, **kwargs):
|
||||
reached("source")
|
||||
return original(self, *values, **kwargs)
|
||||
|
||||
def environment(*_args, **_kwargs):
|
||||
reached("environment")
|
||||
return "fixture-environment"
|
||||
|
||||
monkeypatch.setattr(Checkpoints, "source", source)
|
||||
monkeypatch.setattr(runner, "environment_fingerprint", environment)
|
||||
result = runner.run_checks(args, [fixtures.stage(repo)])
|
||||
assert interruptions == [probe]
|
||||
assert calls[probe] >= 2
|
||||
assert result["stages"][0]["status"] == "passed"
|
||||
assert result["status"] == "interrupted"
|
||||
assert result["_exit_code"] != 0
|
||||
receipt = runner.read_receipt(args.workspace_root, args.state_dir, result["run_id"])
|
||||
assert receipt["status"] == "interrupted"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("interruption", [signal.SIGINT, signal.SIGTERM])
|
||||
@pytest.mark.parametrize("probe", ["source", "environment"])
|
||||
def test_preparation_interrupt_stops_at_probe_boundary_without_running_checks(
|
||||
example, monkeypatch, interruption, probe
|
||||
):
|
||||
args, repo = example
|
||||
calls = []
|
||||
original = Checkpoints.source
|
||||
|
||||
def reached(kind):
|
||||
calls.append(kind)
|
||||
if kind == probe:
|
||||
os.kill(os.getpid(), interruption)
|
||||
|
||||
def source(self, *values, **kwargs):
|
||||
reached("source")
|
||||
return original(self, *values, **kwargs)
|
||||
|
||||
def environment(*_args, **_kwargs):
|
||||
reached("environment")
|
||||
return "fixture-environment"
|
||||
|
||||
def must_not_execute(*args, **kwargs):
|
||||
pytest.fail("A cancelled preparation must not start a check")
|
||||
|
||||
monkeypatch.setattr(Checkpoints, "source", source)
|
||||
monkeypatch.setattr(runner, "environment_fingerprint", environment)
|
||||
monkeypatch.setattr(runner, "execute_stage", must_not_execute)
|
||||
result = runner.run_checks(args, [fixtures.stage(repo)])
|
||||
assert calls == (["source"] if probe == "source" else ["source", "environment"])
|
||||
assert result["status"] == "interrupted"
|
||||
assert result["snapshot_verified"] is False
|
||||
assert result["_exit_code"] != 0
|
||||
receipt = runner.read_receipt(args.workspace_root, args.state_dir, result["run_id"])
|
||||
assert receipt["status"] == "interrupted"
|
||||
Executable
+451
@@ -0,0 +1,451 @@
|
||||
"""Bounded planning fixtures: no application imports, compilers or servers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit"))
|
||||
from govoplan_devkit.catalog import (
|
||||
_expanded_repositories,
|
||||
build_stages,
|
||||
module_ui_stages,
|
||||
)
|
||||
from govoplan_devkit.docs import build_doc_stages
|
||||
from govoplan_devkit.workspace import Project, Repository
|
||||
|
||||
|
||||
class CatalogTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="govoplan-devkit-catalog-")
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.core = Repository("govoplan-core", self.root / "govoplan-core", ("core",))
|
||||
self.meta = Repository("govoplan", self.root / "govoplan", ("meta",))
|
||||
self.module = Repository(
|
||||
"govoplan-example", self.root / "govoplan-example", ("example",)
|
||||
)
|
||||
self.project = Project("fixture", (self.meta, self.core, self.module), {})
|
||||
for repo in self.project.repositories:
|
||||
repo.path.mkdir()
|
||||
(self.meta.path / "tools/checks").mkdir(parents=True)
|
||||
shutil.copyfile(
|
||||
Path(__file__).resolve().parents[1] / "tools/checks/focused-phases.json",
|
||||
self.meta.path / "tools/checks/focused-phases.json",
|
||||
)
|
||||
|
||||
def write(self, relative, content):
|
||||
path = self.root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
def plan(self, profile, repos=None, changed=False):
|
||||
with patch("govoplan_devkit.workspace.load_project", return_value=self.project):
|
||||
return build_stages(self.root, profile, repos or [], changed)
|
||||
|
||||
def test_ui_compiles_once_and_quick_does_not_compile(self):
|
||||
self.write(
|
||||
"govoplan-core/webui/package.json",
|
||||
json.dumps(
|
||||
{"scripts": {"test:components": "node scripts/run-component-tests.mjs"}}
|
||||
),
|
||||
)
|
||||
self.write("govoplan-core/webui/scripts/run-component-tests.mjs", "// fixture")
|
||||
self.assertFalse(
|
||||
any(
|
||||
"run-component-tests.mjs" in " ".join(item["argv"])
|
||||
for item in self.plan("quick")
|
||||
)
|
||||
)
|
||||
matches = [
|
||||
item
|
||||
for item in self.plan("ui")
|
||||
if "run-component-tests.mjs" in " ".join(item["argv"])
|
||||
]
|
||||
self.assertEqual(len(matches), 1)
|
||||
self.assertEqual(matches[0]["id"], "core.component-batch")
|
||||
|
||||
def test_module_script_metadata_is_bounded_and_deduplicated(self):
|
||||
self.write(
|
||||
"govoplan-example/webui/package.json",
|
||||
json.dumps(
|
||||
{
|
||||
"scripts": {
|
||||
"test:interface-pattern": "node scripts/test-interface-pattern-language.mjs",
|
||||
"test:source": "node --test tests/source.test.mjs",
|
||||
"test:dangerous-chain": "node tests/source.test.mjs && npm run dev",
|
||||
"test:flags": "node --eval 'startServer()'",
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
self.write(
|
||||
"govoplan-example/webui/scripts/test-interface-pattern-language.mjs",
|
||||
"// source-only fixture",
|
||||
)
|
||||
self.write(
|
||||
"govoplan-example/webui/tests/source.test.mjs", "// source-only fixture"
|
||||
)
|
||||
stages = module_ui_stages(self.module, reason="fixture")
|
||||
self.assertEqual(len(stages), 2)
|
||||
self.assertEqual(len({item["id"] for item in stages}), 2)
|
||||
self.assertTrue(all(item["argv"][0] == "{node}" for item in stages))
|
||||
self.assertFalse(any("&&" in item["argv"] for item in stages))
|
||||
|
||||
def test_full_is_never_narrowed_by_repo_filter(self):
|
||||
result = self.plan("full", ["example"])
|
||||
self.assertEqual(
|
||||
[item["id"] for item in result],
|
||||
[
|
||||
"focused." + identity
|
||||
for identity in (
|
||||
"preflight",
|
||||
"tooling",
|
||||
"backend",
|
||||
"core-ui",
|
||||
"module-builds",
|
||||
"browser",
|
||||
"module-ui",
|
||||
)
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
[item["after"] for item in result],
|
||||
[[], *[[item["id"]] for item in result[:-1]]],
|
||||
)
|
||||
self.assertTrue(all(item["deps"] == [] for item in result))
|
||||
self.assertTrue(
|
||||
all(
|
||||
item["argv"]
|
||||
== [
|
||||
"bash",
|
||||
str(self.meta.path / "tools/checks/check-focused.sh"),
|
||||
"--phase",
|
||||
item["id"].removeprefix("focused."),
|
||||
]
|
||||
for item in result
|
||||
)
|
||||
)
|
||||
self.assertIn("webui:govoplan-example", result[-1]["resources"])
|
||||
self.assertIn("backend:test-state", result[0]["resources"])
|
||||
|
||||
def test_changed_empty_is_not_full_verification(self):
|
||||
with (
|
||||
patch("govoplan_devkit.workspace.load_project", return_value=self.project),
|
||||
patch("govoplan_devkit.workspace.selected_repositories", return_value=[]),
|
||||
):
|
||||
self.assertEqual(build_stages(self.root, "quick", [], True), [])
|
||||
self.assertEqual(len(build_stages(self.root, "full", [], True)), 7)
|
||||
|
||||
def test_full_ui_scope_excludes_backend_only_but_includes_all_ui_owners(self):
|
||||
backend = Repository(
|
||||
"govoplan-backend-only", self.root / "govoplan-backend-only"
|
||||
)
|
||||
backend.path.mkdir()
|
||||
(self.module.path / "webui").mkdir()
|
||||
(self.core.path / "webui").mkdir()
|
||||
project = Project("fixture", (*self.project.repositories, backend), {})
|
||||
with patch("govoplan_devkit.workspace.load_project", return_value=project):
|
||||
checks = build_stages(self.root, "full", ["example"], False)
|
||||
for check in checks[:3]:
|
||||
self.assertNotIn("inputs", check)
|
||||
for check in checks[3:]:
|
||||
self.assertEqual(
|
||||
check["inputs"]["repos"],
|
||||
["govoplan", "govoplan-core", "govoplan-example"],
|
||||
)
|
||||
(backend.path / "webui").mkdir()
|
||||
with patch("govoplan_devkit.workspace.load_project", return_value=project):
|
||||
replanned = build_stages(self.root, "full", [], False)
|
||||
self.assertIn(backend.name, replanned[3]["inputs"]["repos"])
|
||||
|
||||
def test_full_missing_checkout_explicitly_falls_back_to_workspace_inputs(self):
|
||||
missing = Repository("govoplan-missing", self.root / "govoplan-missing")
|
||||
project = Project("fixture", (*self.project.repositories, missing), {})
|
||||
with patch("govoplan_devkit.workspace.load_project", return_value=project):
|
||||
checks = build_stages(self.root, "full", [], False)
|
||||
self.assertTrue(all("inputs" not in check for check in checks))
|
||||
self.assertTrue(
|
||||
all(
|
||||
any("workspace-wide" in note for note in check["coverage_notes"])
|
||||
for check in checks[3:]
|
||||
)
|
||||
)
|
||||
|
||||
def test_full_requires_authoritative_phase_metadata(self):
|
||||
(self.meta.path / "tools/checks/focused-phases.json").write_text("{}")
|
||||
with self.assertRaisesRegex(ValueError, "phase metadata"):
|
||||
self.plan("full")
|
||||
|
||||
def test_native_ui_guards_and_component_batch_share_safe_ui_owner_scope(self):
|
||||
backend = Repository(
|
||||
"govoplan-backend-only", self.root / "govoplan-backend-only"
|
||||
)
|
||||
backend.path.mkdir()
|
||||
(self.module.path / "webui").mkdir()
|
||||
(self.core.path / "webui").mkdir()
|
||||
project = Project("fixture", (*self.project.repositories, backend), {})
|
||||
with patch("govoplan_devkit.workspace.load_project", return_value=project):
|
||||
checks = build_stages(self.root, "ui", ["example"], False)
|
||||
for check in checks:
|
||||
if check["id"] in {
|
||||
"jsx-value-imports",
|
||||
"heading-help",
|
||||
"core.component-batch",
|
||||
}:
|
||||
self.assertEqual(
|
||||
check["inputs"]["repos"],
|
||||
["govoplan", "govoplan-core", "govoplan-example"],
|
||||
)
|
||||
else:
|
||||
self.assertNotIn("inputs", check)
|
||||
|
||||
def test_unregistered_src_or_webui_disables_all_native_reuse(self):
|
||||
for directory in ("src", "webui"):
|
||||
with self.subTest(directory=directory):
|
||||
unknown = self.root / "govoplan-unregistered" / directory
|
||||
unknown.mkdir(parents=True)
|
||||
try:
|
||||
for profile in ("quick", "ui", "backend", "full"):
|
||||
checks = self.plan(profile)
|
||||
self.assertTrue(checks)
|
||||
self.assertTrue(
|
||||
all(check["reuse"] == "never" for check in checks)
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
any(
|
||||
"Unregistered sibling" in note
|
||||
for note in check["coverage_notes"]
|
||||
)
|
||||
for check in checks
|
||||
)
|
||||
)
|
||||
finally:
|
||||
unknown.rmdir()
|
||||
unknown.parent.rmdir()
|
||||
(self.root / "govoplan-unused-empty").mkdir()
|
||||
self.assertTrue(all("reuse" not in check for check in self.plan("full")))
|
||||
|
||||
def test_unregistered_broken_source_symlink_does_not_allow_reuse(self):
|
||||
unknown = self.root / "govoplan-unregistered"
|
||||
unknown.mkdir()
|
||||
(unknown / "src").symlink_to(self.root / "missing")
|
||||
self.assertTrue(all(check["reuse"] == "never" for check in self.plan("full")))
|
||||
|
||||
def test_unknown_repo_is_not_silently_ignored(self):
|
||||
with self.assertRaisesRegex(ValueError, "Unknown repository"):
|
||||
self.plan("ui", ["not-a-repo"])
|
||||
|
||||
def test_changed_provider_selects_transitive_declared_consumers(self):
|
||||
other = Repository("govoplan-other", self.root / "govoplan-other")
|
||||
final = Repository("govoplan-final", self.root / "govoplan-final")
|
||||
project = Project("fixture", (self.meta, self.module, other, final), {})
|
||||
self.write("govoplan/tools/release/govoplan_release/contracts.py", "# fixture")
|
||||
for name in ("example", "other", "final"):
|
||||
self.write(f"govoplan-{name}/src/fixture/backend/manifest.py", "# fixture")
|
||||
|
||||
def contract(_path, repo_name):
|
||||
gives = {
|
||||
self.module.name: ["first"],
|
||||
other.name: ["second"],
|
||||
final.name: [],
|
||||
}[repo_name]
|
||||
needs = {
|
||||
self.module.name: [],
|
||||
other.name: ["first"],
|
||||
final.name: ["second"],
|
||||
}[repo_name]
|
||||
return SimpleNamespace(
|
||||
repo=repo_name,
|
||||
provides_interfaces=[SimpleNamespace(name=value) for value in gives],
|
||||
requires_interfaces=[SimpleNamespace(name=value) for value in needs],
|
||||
)
|
||||
|
||||
with patch.dict(
|
||||
sys.modules,
|
||||
{
|
||||
"govoplan_release.contracts": SimpleNamespace(
|
||||
parse_manifest_contract=contract
|
||||
)
|
||||
},
|
||||
):
|
||||
selected, reason = _expanded_repositories(
|
||||
project, [self.module], changed=True
|
||||
)
|
||||
self.assertEqual(
|
||||
[repo.name for repo in selected], [self.module.name, other.name, final.name]
|
||||
)
|
||||
self.assertIn("declared interface consumers", reason)
|
||||
|
||||
def test_generic_dependency_closure_includes_filtered_prerequisites(self):
|
||||
config = {
|
||||
"schema_version": 1,
|
||||
"name": "generic",
|
||||
"repositories": [
|
||||
{"name": "one", "path": "one"},
|
||||
{"name": "two", "path": "two"},
|
||||
],
|
||||
"checks": [
|
||||
{
|
||||
"id": "compile",
|
||||
"argv": ["{node}", "compile.mjs"],
|
||||
"cwd": "one",
|
||||
"repos": ["two"],
|
||||
},
|
||||
{
|
||||
"id": "test",
|
||||
"argv": ["{node}", "test.mjs"],
|
||||
"cwd": "one",
|
||||
"deps": ["compile"],
|
||||
"repos": ["one"],
|
||||
},
|
||||
{
|
||||
"id": "other",
|
||||
"argv": ["{python}", "test.py"],
|
||||
"cwd": "two",
|
||||
"repos": ["two"],
|
||||
},
|
||||
],
|
||||
"profiles": {"quick": ["test", "other"]},
|
||||
}
|
||||
self.write("project.json", json.dumps(config))
|
||||
result = build_stages(
|
||||
self.root, "quick", ["one"], False, self.root / "project.json"
|
||||
)
|
||||
self.assertEqual([item["id"] for item in result], ["compile", "test"])
|
||||
self.assertEqual(result[1]["deps"], ["compile"])
|
||||
self.assertEqual(result[1]["cwd"], str(self.root / "one"))
|
||||
|
||||
def test_generic_cycles_and_duplicate_ids_fail(self):
|
||||
config = {
|
||||
"schema_version": 1,
|
||||
"name": "generic",
|
||||
"repositories": [{"name": "one", "path": "one"}],
|
||||
"checks": [
|
||||
{"id": "one", "argv": ["test"], "deps": ["two"]},
|
||||
{"id": "two", "argv": ["test"], "deps": ["one"]},
|
||||
],
|
||||
"profiles": {"quick": ["one"]},
|
||||
}
|
||||
self.write("project.json", json.dumps(config))
|
||||
with self.assertRaisesRegex(ValueError, "Cyclic"):
|
||||
build_stages(self.root, "quick", [], False, self.root / "project.json")
|
||||
config["checks"][1]["id"] = "one"
|
||||
self.write("project.json", json.dumps(config))
|
||||
with self.assertRaisesRegex(ValueError, "Duplicate"):
|
||||
build_stages(self.root, "quick", [], False, self.root / "project.json")
|
||||
|
||||
def test_generic_order_prerequisites_inputs_and_reuse_survive_planning(self):
|
||||
config = {
|
||||
"schema_version": 1,
|
||||
"name": "generic",
|
||||
"repositories": [
|
||||
{"name": "one", "path": "one"},
|
||||
{"name": "two", "path": "two"},
|
||||
],
|
||||
"checks": [
|
||||
{
|
||||
"id": "prepare",
|
||||
"argv": ["prepare"],
|
||||
"repos": ["two"],
|
||||
"reuse": "never",
|
||||
"inputs": {"repos": ["two"]},
|
||||
},
|
||||
{
|
||||
"id": "test",
|
||||
"argv": ["test"],
|
||||
"repos": ["one"],
|
||||
"after": ["prepare"],
|
||||
"reuse": "verified",
|
||||
"inputs": {"repos": ["one"]},
|
||||
},
|
||||
{"id": "broad", "argv": ["check"], "repos": ["one"]},
|
||||
],
|
||||
"profiles": {"quick": ["test", "broad"]},
|
||||
}
|
||||
self.write("project.json", json.dumps(config))
|
||||
result = build_stages(
|
||||
self.root, "quick", ["one"], False, self.root / "project.json"
|
||||
)
|
||||
self.assertEqual([item["id"] for item in result], ["prepare", "test", "broad"])
|
||||
self.assertEqual(result[1]["after"], ["prepare"])
|
||||
self.assertEqual(result[1]["deps"], [])
|
||||
self.assertEqual(result[0]["reuse"], "never")
|
||||
self.assertEqual(result[1]["reuse"], "verified")
|
||||
self.assertEqual(result[0]["inputs"], {"repos": ["two"]})
|
||||
self.assertEqual(result[1]["inputs"], {"repos": ["one"]})
|
||||
self.assertNotIn("inputs", result[2])
|
||||
|
||||
def test_generic_original_types_are_validated_before_coercion(self):
|
||||
for field, invalid in (
|
||||
("argv", "false"),
|
||||
("argv", None),
|
||||
("resources", "shared"),
|
||||
("cwd", None),
|
||||
("cwd", "../outside"),
|
||||
("title", 5),
|
||||
("timeout_seconds", True),
|
||||
):
|
||||
with self.subTest(field=field, invalid=invalid):
|
||||
config = {
|
||||
"schema_version": 1,
|
||||
"name": "generic",
|
||||
"repositories": [{"name": "one", "path": "one"}],
|
||||
"checks": [{"id": "test", "argv": ["test"], field: invalid}],
|
||||
"profiles": {"quick": ["test"]},
|
||||
}
|
||||
self.write("project.json", json.dumps(config))
|
||||
with self.assertRaises(ValueError):
|
||||
build_stages(
|
||||
self.root, "quick", [], False, self.root / "project.json"
|
||||
)
|
||||
|
||||
def test_ui_coverage_notes_make_excluded_suites_explicit_and_discover_tests_folder(
|
||||
self,
|
||||
):
|
||||
self.write(
|
||||
"govoplan-example/webui/package.json",
|
||||
json.dumps({"scripts": {"test:full-ui": "tsc && node tests/full-ui.js"}}),
|
||||
)
|
||||
self.write(
|
||||
"govoplan-example/webui/tests/aggregate-report-structure.test.mjs",
|
||||
"// fixture",
|
||||
)
|
||||
stages = self.plan("ui", ["example"])
|
||||
self.assertTrue(
|
||||
any("aggregate-report-structure" in item["id"] for item in stages)
|
||||
)
|
||||
self.assertTrue(
|
||||
any("test:full-ui" in note for note in stages[0]["coverage_notes"])
|
||||
)
|
||||
|
||||
def test_docs_reuses_existing_guards_and_only_narrows_plain_labels(self):
|
||||
args = argparse.Namespace(
|
||||
workspace_root=self.root,
|
||||
state_dir=self.root / "state",
|
||||
project=None,
|
||||
repo=["example"],
|
||||
changed=False,
|
||||
)
|
||||
with patch("govoplan_devkit.docs.load_project", return_value=self.project):
|
||||
stages = build_doc_stages(args)
|
||||
self.assertEqual(len(stages), 4)
|
||||
self.assertIn("check-manifest-shapes.py", " ".join(stages[0]["argv"]))
|
||||
self.assertIn("platform-interface-inventory.py", " ".join(stages[1]["argv"]))
|
||||
self.assertIn("--strict-declarations", stages[1]["argv"])
|
||||
self.assertEqual(stages[3]["argv"][-2:], ["--repo", "govoplan-example"])
|
||||
self.assertFalse(
|
||||
(self.root / "state").exists(), "Planning must not create artifacts"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+158
@@ -0,0 +1,158 @@
|
||||
"""Public CLI contracts and an executable portable-project smoke fixture."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit"))
|
||||
from govoplan_devkit import cli, doctor, runner
|
||||
from govoplan_devkit.common import META_ROOT
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"arguments",
|
||||
[["--json", "commands"], ["commands", "--json"], ["commands", "--format", "json"]],
|
||||
)
|
||||
def test_global_output_flags_work_on_either_side_of_command(arguments, capsys):
|
||||
assert cli.main(arguments) == 0
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
names = {item["command"] for item in output["commands"]}
|
||||
assert {"context", "check", "doctor", "docs", "issues", "release", "git"} <= names
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
"context",
|
||||
"doctor",
|
||||
"check",
|
||||
"resume",
|
||||
"recover",
|
||||
"docs",
|
||||
"review",
|
||||
"issues",
|
||||
"release",
|
||||
"git",
|
||||
],
|
||||
)
|
||||
def test_command_help_does_not_require_live_services(command, capsys):
|
||||
with pytest.raises(SystemExit) as stopped:
|
||||
cli.main([command, "--help"])
|
||||
assert stopped.value.code == 0
|
||||
assert "usage:" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_portable_project_executes_registered_commands_and_reads_receipt(
|
||||
tmp_path, capsys, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state"))
|
||||
repo = tmp_path / "example"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init", "-q", str(repo)], check=True)
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(repo),
|
||||
"-c",
|
||||
"user.name=Fixture",
|
||||
"-c",
|
||||
"user.email=fixture@example.invalid",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-qm",
|
||||
"fixture",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
project = tmp_path / "project.json"
|
||||
project.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "Portable",
|
||||
"repositories": [{"name": "example", "path": "example"}],
|
||||
"checks": [
|
||||
{
|
||||
"id": "test",
|
||||
"argv": ["{python}", "-c", "print('portable ok')"],
|
||||
"cwd": "example",
|
||||
}
|
||||
],
|
||||
"profiles": {"quick": ["test"]},
|
||||
}
|
||||
)
|
||||
)
|
||||
common = ["--workspace-root", str(tmp_path), "--project", str(project), "--json"]
|
||||
with patch.object(runner, "environment_fingerprint", return_value="fixture"):
|
||||
assert cli.main(common + ["check", "--profile", "quick"]) == 0
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert result["status"] == "passed"
|
||||
assert cli.main(["status", result["run_id"], *common]) == 0
|
||||
status = json.loads(capsys.readouterr().out)
|
||||
assert status["snapshot_verified"] is True
|
||||
assert cli.main(["logs", result["run_id"], "--stage", "test", *common]) == 0
|
||||
assert "portable ok" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_portable_example_matches_published_schema():
|
||||
import jsonschema
|
||||
|
||||
jsonschema.validate(
|
||||
json.loads((META_ROOT / "tools/devkit/examples/project.json").read_text()),
|
||||
json.loads((META_ROOT / "tools/devkit/project.schema.json").read_text()),
|
||||
)
|
||||
|
||||
|
||||
def test_malformed_project_is_a_controlled_json_error(tmp_path, capsys):
|
||||
project = tmp_path / "bad.json"
|
||||
project.write_text('{"schema_version":true,"repositories":[]}')
|
||||
assert (
|
||||
cli.main(
|
||||
[
|
||||
"context",
|
||||
"--workspace-root",
|
||||
str(tmp_path),
|
||||
"--project",
|
||||
str(project),
|
||||
"--json",
|
||||
]
|
||||
)
|
||||
== 2
|
||||
)
|
||||
assert json.loads(capsys.readouterr().out)["status"] == "error"
|
||||
|
||||
|
||||
def test_doctor_is_read_only_and_preserves_dependency_warnings(tmp_path, capsys):
|
||||
project = tmp_path / "project.json"
|
||||
project.write_text(
|
||||
json.dumps(
|
||||
{"schema_version": 1, "repositories": [{"name": "example", "path": "."}]}
|
||||
)
|
||||
)
|
||||
(tmp_path / "package.json").write_text("{}")
|
||||
before = set(tmp_path.iterdir())
|
||||
with (
|
||||
patch.object(doctor, "tool_version", return_value="fixture"),
|
||||
patch.object(doctor, "inspect_repository", return_value={"errors": []}),
|
||||
):
|
||||
assert (
|
||||
cli.main(
|
||||
[
|
||||
"doctor",
|
||||
"--workspace-root",
|
||||
str(tmp_path),
|
||||
"--project",
|
||||
str(project),
|
||||
"--json",
|
||||
]
|
||||
)
|
||||
== 0
|
||||
)
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
assert any(item["status"] == "warning" for item in output["checks"])
|
||||
assert set(tmp_path.iterdir()) == before
|
||||
Executable
+339
@@ -0,0 +1,339 @@
|
||||
"""Coverage inventory is explicit intent, never execution or guessed completion."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools/devkit"))
|
||||
from govoplan_devkit.catalog import build_coverage, build_stages, module_ui_stages # noqa: E402
|
||||
from govoplan_devkit.coverage import canonical_invocations # noqa: E402
|
||||
from govoplan_devkit.package_tests import ( # noqa: E402
|
||||
CORE_COMPONENT_SUITES,
|
||||
declared_tests,
|
||||
read_package,
|
||||
)
|
||||
from govoplan_devkit.workspace import Project, Repository # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixture(tmp_path):
|
||||
core = Repository("govoplan-core", tmp_path / "govoplan-core", ("core",))
|
||||
meta = Repository("govoplan", tmp_path / "govoplan", ("meta",))
|
||||
module = Repository("govoplan-example", tmp_path / "govoplan-example", ("example",))
|
||||
for repo in (core, meta, module):
|
||||
(repo.path / "webui/scripts").mkdir(parents=True)
|
||||
(repo.path / "webui/tests").mkdir()
|
||||
scripts = {
|
||||
"test:components": "node scripts/run-component-tests.mjs",
|
||||
**{
|
||||
f"test:{name}": f"node scripts/run-component-tests.mjs {name}"
|
||||
for name in CORE_COMPONENT_SUITES
|
||||
},
|
||||
}
|
||||
(core.path / "webui/package.json").write_text(json.dumps({"scripts": scripts}))
|
||||
(core.path / "webui/scripts/run-component-tests.mjs").write_text("// fixture")
|
||||
(module.path / "webui/package.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"scripts": {
|
||||
"test:safe": "node --test tests/source.test.mjs",
|
||||
"test:compound": "tsc && node test.js",
|
||||
"test:bad-quote": "node 'broken",
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
(module.path / "webui/tests/source.test.mjs").write_text("// fixture")
|
||||
script = meta.path / "tools/checks/check-focused.sh"
|
||||
script.parent.mkdir(parents=True)
|
||||
phase_metadata = ROOT / "tools/checks/focused-phases.json"
|
||||
shutil.copyfile(phase_metadata, script.with_name("focused-phases.json"))
|
||||
bodies = {
|
||||
"core-ui": 'cd "$ROOT/webui"\n"$NPM" run test:components -- layout-primitives page-layout data-grid-actions mail-components\n',
|
||||
"module-ui": 'cd "$WORKSPACE_ROOT/govoplan-example/webui"\n"$NPM" run test:compound\n',
|
||||
}
|
||||
script.write_text(
|
||||
"\n".join(
|
||||
f"focused_phase_{phase['id'].replace('-', '_')}() {{\n# devkit-phase: {phase['id']} begin\n"
|
||||
+ bodies.get(phase["id"], 'cd "$ROOT"\n')
|
||||
+ f"# devkit-phase: {phase['id']} end\n}}\n"
|
||||
for phase in json.loads(phase_metadata.read_text())["phases"]
|
||||
)
|
||||
)
|
||||
project = Project("Fixture", (meta, core, module), {})
|
||||
with (
|
||||
patch("govoplan_devkit.workspace.load_project", return_value=project),
|
||||
patch("govoplan_devkit.coverage.load_project", return_value=project),
|
||||
):
|
||||
yield tmp_path, core, module, project
|
||||
|
||||
|
||||
def rows(coverage, repo="govoplan-core"):
|
||||
return {row["name"]: row for row in coverage["suites"] if row["repo"] == repo}
|
||||
|
||||
|
||||
def test_core_aliases_are_explicitly_excluded_in_quick_and_covered_by_one_ui_stage(
|
||||
fixture,
|
||||
):
|
||||
root, _, _, _ = fixture
|
||||
quick = rows(build_coverage(root, "quick", [], False))
|
||||
ui = rows(build_coverage(root, "ui", [], False))
|
||||
for suite in CORE_COMPONENT_SUITES:
|
||||
assert quick["test:" + suite]["disposition"] == "excluded"
|
||||
assert "quick" in quick["test:" + suite]["reason"]
|
||||
assert ui["test:" + suite]["disposition"] == "covered_elsewhere"
|
||||
assert ui["test:" + suite]["covering_stage"] == "core.component-batch"
|
||||
assert ui["test:components"]["covered_components"] == list(CORE_COMPONENT_SUITES)
|
||||
|
||||
|
||||
def test_full_reports_only_four_of_sixteen_components_and_exact_shell_suite(fixture):
|
||||
root, _, _, _ = fixture
|
||||
result = build_coverage(root, "full", ["example"], False)
|
||||
core = rows(result)
|
||||
aliases = [core["test:" + suite] for suite in CORE_COMPONENT_SUITES]
|
||||
assert sum(item["disposition"] == "covered_elsewhere" for item in aliases) == 4
|
||||
assert sum(item["disposition"] == "excluded" for item in aliases) == 12
|
||||
assert len(core["test:components"]["covered_components"]) == 4
|
||||
assert "4/16" in core["test:components"]["reason"]
|
||||
module = rows(result, "govoplan-example")
|
||||
assert module["test:compound"]["disposition"] == "planned"
|
||||
assert module["test:safe"]["disposition"] == "excluded"
|
||||
assert result["stages"] == [
|
||||
"focused." + phase["id"]
|
||||
for phase in json.loads(
|
||||
(ROOT / "tools/checks/focused-phases.json").read_text()
|
||||
)["phases"]
|
||||
]
|
||||
assert core["test:components"]["covering_stage"] == "focused.core-ui"
|
||||
assert module["test:compound"]["covering_stage"] == "focused.module-ui"
|
||||
|
||||
|
||||
def test_prebuilt_plan_avoids_replanning(fixture):
|
||||
root, _, _, _ = fixture
|
||||
stages = build_stages(root, "quick", [], False)
|
||||
with patch(
|
||||
"govoplan_devkit.catalog.build_stages",
|
||||
side_effect=AssertionError("must not replan"),
|
||||
):
|
||||
result = build_coverage(root, "quick", [], False, stages=stages)
|
||||
assert rows(result, "govoplan-example")["test:safe"]["disposition"] == "planned"
|
||||
assert sum(result["counts"].values()) == result["suite_count"]
|
||||
assert all(item["reason"] for item in result["suites"])
|
||||
|
||||
|
||||
def test_missing_or_spoofed_phase_stage_does_not_grant_component_coverage(fixture):
|
||||
root, _, _, _ = fixture
|
||||
stages = build_stages(root, "full", [], False)
|
||||
without_core = [item for item in stages if item["id"] != "focused.core-ui"]
|
||||
result = build_coverage(root, "full", [], False, stages=without_core)
|
||||
assert rows(result)["test:components"]["covered_components"] == []
|
||||
assert (
|
||||
rows(result, "govoplan-example")["test:compound"]["covering_stage"]
|
||||
== "focused.module-ui"
|
||||
)
|
||||
next(item for item in stages if item["id"] == "focused.core-ui")["argv"] = ["true"]
|
||||
spoofed = build_coverage(root, "full", [], False, stages=stages)
|
||||
assert rows(spoofed)["test:components"]["covered_components"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wrapper", ["heredoc", "function", "conditional"])
|
||||
def test_lookalike_phase_wrappers_outside_top_level_do_not_grant_coverage(
|
||||
fixture, wrapper
|
||||
):
|
||||
root, core, _, project = fixture
|
||||
meta = next(repo.path for repo in project.repositories if repo.name == "govoplan")
|
||||
path = meta / "tools/checks/check-focused.sh"
|
||||
fake = 'focused_phase_core_ui() {\n# devkit-phase: core-ui begin\ncd "$ROOT/webui"\n"$NPM" run test:spoof\n# devkit-phase: core-ui end\n}\n'
|
||||
prefix = {
|
||||
"heredoc": "cat <<'BODY'\n" + fake + "BODY\n",
|
||||
"function": "unused() {\n" + fake + "}\n",
|
||||
"conditional": "if false; then\n" + fake + "fi\n",
|
||||
}[wrapper]
|
||||
path.write_text(prefix + path.read_text())
|
||||
result = canonical_invocations(root, meta, core.path)
|
||||
assert result["notes"] == []
|
||||
assert [item["name"] for item in result["npm"]] == [
|
||||
"test:components",
|
||||
"test:compound",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage", ["missing", "duplicate", "bad-end"])
|
||||
def test_invalid_marked_phase_bodies_do_not_infer_coverage(fixture, damage):
|
||||
root, core, _, project = fixture
|
||||
meta = next(repo.path for repo in project.repositories if repo.name == "govoplan")
|
||||
path = meta / "tools/checks/check-focused.sh"
|
||||
text = path.read_text()
|
||||
if damage == "missing":
|
||||
text = text.replace("focused_phase_core_ui()", "unregistered_core_ui()")
|
||||
elif damage == "duplicate":
|
||||
text += text
|
||||
else:
|
||||
text = text.replace(
|
||||
"# devkit-phase: core-ui end", "# devkit-phase: another end"
|
||||
)
|
||||
path.write_text(text)
|
||||
result = canonical_invocations(root, meta, core.path)
|
||||
assert result["npm"] == [] and result["node"] == []
|
||||
assert "no phase coverage inferred" in result["notes"][0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"repo_name,name,command",
|
||||
[
|
||||
("govoplan-example", "test:components", "node scripts/run-component-tests.mjs"),
|
||||
(
|
||||
"govoplan-core",
|
||||
"test:dialog-focus",
|
||||
"node scripts/run-component-tests.mjs dialog-focus && npm run dev",
|
||||
),
|
||||
(
|
||||
"govoplan-core",
|
||||
"test:unrecognized",
|
||||
"node scripts/run-component-tests.mjs dialog-focus",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_component_alias_exemption_is_exact_and_core_only(
|
||||
tmp_path, repo_name, name, command
|
||||
):
|
||||
repo = Repository(repo_name, tmp_path / repo_name)
|
||||
package = repo.path / "webui/package.json"
|
||||
package.parent.mkdir(parents=True)
|
||||
package.write_text(json.dumps({"scripts": {name: command}}))
|
||||
declared = declared_tests(repo, package)
|
||||
assert declared[0]["component_suite"] is None
|
||||
assert declared[0]["_argv"] is None
|
||||
assert module_ui_stages(repo, reason="fixture") == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
"[]",
|
||||
"null",
|
||||
'{"scripts":[]}',
|
||||
'{"scripts":{"test:bad":42}}',
|
||||
'{"scripts":{"test:one":"node a","test:one":"node b"}}',
|
||||
"[" * 2000 + "]" * 2000,
|
||||
],
|
||||
)
|
||||
def test_malformed_package_metadata_is_a_controlled_error(tmp_path, body):
|
||||
package = tmp_path / "package.json"
|
||||
package.write_text(body)
|
||||
with pytest.raises(ValueError):
|
||||
read_package(package)
|
||||
|
||||
|
||||
def test_oversize_package_and_symlinked_test_are_not_discovered(tmp_path):
|
||||
repo = Repository("example", tmp_path)
|
||||
webui = tmp_path / "webui"
|
||||
(webui / "tests").mkdir(parents=True)
|
||||
package = webui / "package.json"
|
||||
package.write_text(" " * (1024 * 1024 + 1))
|
||||
with pytest.raises(ValueError):
|
||||
read_package(package)
|
||||
package.write_text(
|
||||
json.dumps({"scripts": {"test:escape": "node tests/escape.mjs"}})
|
||||
)
|
||||
(webui / "tests/escape.mjs").symlink_to(tmp_path / "outside.mjs")
|
||||
(tmp_path / "outside.mjs").write_text("// fixture")
|
||||
assert module_ui_stages(repo, reason="fixture") == []
|
||||
|
||||
|
||||
def test_unparseable_and_sensitive_commands_do_not_leak_into_coverage(fixture):
|
||||
root, _, module, _ = fixture
|
||||
package = module.path / "webui/package.json"
|
||||
package.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"scripts": {
|
||||
"test:secret": "node tests/source.test.mjs --token unknown-private-value",
|
||||
"test:broken": "node 'unknown-other-secret",
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
result = build_coverage(root, "quick", [], False)
|
||||
encoded = json.dumps(result)
|
||||
assert (
|
||||
"unknown-private-value" not in encoded and "unknown-other-secret" not in encoded
|
||||
)
|
||||
assert "_argv" not in encoded and '"command"' not in encoded
|
||||
assert all(
|
||||
item["disposition"] == "unsupported"
|
||||
for item in rows(result, "govoplan-example").values()
|
||||
)
|
||||
|
||||
|
||||
def test_custom_check_coverage_redacts_separate_token_and_includes_unselected(tmp_path):
|
||||
project = tmp_path / "project.json"
|
||||
project.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"repositories": [{"name": "app", "path": "."}],
|
||||
"checks": [
|
||||
{
|
||||
"id": "one",
|
||||
"argv": ["check", "--token", "unknown-private-value"],
|
||||
},
|
||||
{"id": "two", "argv": ["true"]},
|
||||
],
|
||||
"profiles": {"quick": ["one"]},
|
||||
}
|
||||
)
|
||||
)
|
||||
result = build_coverage(tmp_path, "quick", [], False, project)
|
||||
assert "unknown-private-value" not in json.dumps(result)
|
||||
assert result["counts"]["planned"] == 1 and result["counts"]["excluded"] == 1
|
||||
|
||||
|
||||
def test_canonical_parser_does_not_credit_comments_heredocs_conditionals_or_chains(
|
||||
tmp_path,
|
||||
):
|
||||
meta, core = tmp_path / "govoplan", tmp_path / "govoplan-core"
|
||||
path = meta / "tools/checks/check-focused.sh"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(
|
||||
'cd "$ROOT/webui"\n# "$NPM" run test:comment\n"$PYTHON" - <<\'PY\'\n"$NPM" run test:heredoc\nPY\nif false; then\n"$NPM" run test:conditional\nfi\n"$NPM" run test:compound && true\n"$NPM" run test:real\n'
|
||||
)
|
||||
result = canonical_invocations(tmp_path, meta, core)
|
||||
assert [item["name"] for item in result["npm"]] == ["test:real"]
|
||||
|
||||
|
||||
def test_real_canonical_gate_has_four_explicit_component_suites():
|
||||
result = canonical_invocations(ROOT.parent, ROOT, ROOT.parent / "govoplan-core")
|
||||
calls = [item for item in result["npm"] if item["name"] == "test:components"]
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["args"] == [
|
||||
"layout-primitives",
|
||||
"page-layout",
|
||||
"data-grid-actions",
|
||||
"mail-components",
|
||||
]
|
||||
assert calls[0]["phase"] == "core-ui"
|
||||
for name, phase in (
|
||||
("test:module-permutations", "module-builds"),
|
||||
("test:conformance", "browser"),
|
||||
):
|
||||
matching = [item for item in result["npm"] if item["name"] == name]
|
||||
assert len(matching) == 1 and matching[0]["phase"] == phase
|
||||
|
||||
|
||||
def test_known_core_component_aliases_match_the_owned_runner_registry():
|
||||
runner = (
|
||||
ROOT.parent / "govoplan-core/webui/scripts/run-component-tests.mjs"
|
||||
).read_text()
|
||||
block = runner.split("export const componentSuites = Object.freeze({", 1)[1].split(
|
||||
"});", 1
|
||||
)[0]
|
||||
assert set(re.findall(r'^ "([a-z0-9-]+)":', block, re.M)) == set(
|
||||
CORE_COMPONENT_SUITES
|
||||
)
|
||||
Executable
+164
@@ -0,0 +1,164 @@
|
||||
"""Portable preflight inference uses fixtures only and never installs or starts tools."""
|
||||
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit"))
|
||||
from govoplan_devkit import doctor
|
||||
from govoplan_devkit.workspace import Project, Repository
|
||||
|
||||
|
||||
def project(tmp_path, checks, *, tools=None, profiles=None):
|
||||
return Project(
|
||||
"Fixture",
|
||||
(Repository("app", tmp_path / "app"), Repository("other", tmp_path / "other")),
|
||||
{
|
||||
"checks": checks,
|
||||
"profiles": profiles
|
||||
if profiles is not None
|
||||
else {"quick": [item["id"] for item in checks]},
|
||||
"tools": tools or {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def check(identity, executable, *, repos=None, deps=None):
|
||||
return {
|
||||
"id": identity,
|
||||
"argv": [executable, "--version"],
|
||||
"cwd": ".",
|
||||
"repos": repos or [],
|
||||
"deps": deps or [],
|
||||
}
|
||||
|
||||
|
||||
def diagnose(tmp_path, configured, *, profile=None, repos=None, versions=None):
|
||||
args = Namespace(
|
||||
workspace_root=tmp_path,
|
||||
project=tmp_path / "not-read.json",
|
||||
repo=repos or [],
|
||||
profile=profile,
|
||||
)
|
||||
tools = {"python": sys.executable, "node": "fixture-node", "npm": "fixture-npm"}
|
||||
version_map = versions or {
|
||||
sys.executable: "Python 3",
|
||||
"fixture-node": "unavailable",
|
||||
"fixture-npm": "unavailable",
|
||||
}
|
||||
with (
|
||||
patch.object(doctor, "load_project", return_value=configured),
|
||||
patch.object(doctor, "resolve_tools", return_value=tools),
|
||||
patch.object(
|
||||
doctor,
|
||||
"tool_version",
|
||||
side_effect=lambda executable, _env: version_map[executable],
|
||||
) as probe,
|
||||
patch.object(doctor, "inspect_repository", return_value={"errors": []}),
|
||||
):
|
||||
result = doctor.diagnose(args)
|
||||
return result, probe
|
||||
|
||||
|
||||
def test_python_only_portable_project_does_not_probe_or_block_on_unused_node(tmp_path):
|
||||
configured = project(tmp_path, [check("python-test", "{python}")])
|
||||
result, probe = diagnose(tmp_path, configured)
|
||||
assert result["_exit_code"] == 0
|
||||
assert result["required_tools"] == ["python"]
|
||||
assert probe.call_count == 1
|
||||
assert {
|
||||
item["id"] for item in result["checks"] if item["status"] == "not_required"
|
||||
} == {"node", "npm"}
|
||||
|
||||
|
||||
def test_selected_profile_excludes_other_profile_tool_requirements(tmp_path):
|
||||
configured = project(
|
||||
tmp_path,
|
||||
[check("py", "{python}"), check("js", "{npm}")],
|
||||
profiles={"quick": ["py"], "ui": ["js"]},
|
||||
)
|
||||
result, _ = diagnose(tmp_path, configured, profile="quick")
|
||||
assert result["required_tools"] == ["python"]
|
||||
result, _ = diagnose(tmp_path, configured)
|
||||
assert result["required_tools"] == ["node", "npm", "python"]
|
||||
assert result["_exit_code"] == 1
|
||||
|
||||
|
||||
def test_selected_repository_includes_dependency_tool_requirements(tmp_path):
|
||||
configured = project(
|
||||
tmp_path,
|
||||
[
|
||||
check("build", "{npm}", repos=["other"]),
|
||||
check("test", "{python}", repos=["app"], deps=["build"]),
|
||||
],
|
||||
)
|
||||
result, _ = diagnose(tmp_path, configured, repos=["app"])
|
||||
assert result["required_tools"] == ["node", "npm", "python"]
|
||||
|
||||
|
||||
def test_unselected_repository_does_not_require_its_tool(tmp_path):
|
||||
configured = project(
|
||||
tmp_path,
|
||||
[check("js", "{npm}", repos=["other"]), check("py", "{python}", repos=["app"])],
|
||||
)
|
||||
result, _ = diagnose(tmp_path, configured, repos=["app"])
|
||||
assert result["required_tools"] == ["python"]
|
||||
|
||||
|
||||
def test_explicit_tool_configuration_declares_indirect_script_dependency(tmp_path):
|
||||
configured = project(tmp_path, [check("shell", "sh")], tools={"npm": "fixture-npm"})
|
||||
result, _ = diagnose(tmp_path, configured)
|
||||
assert result["required_tools"] == ["node", "npm", "python"]
|
||||
assert result["_exit_code"] == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"executable,expected",
|
||||
[
|
||||
("/opt/node/bin/node", ["node", "python"]),
|
||||
("npm", ["node", "npm", "python"]),
|
||||
("npx", ["node", "npm", "python"]),
|
||||
],
|
||||
)
|
||||
def test_direct_tool_names_are_inferred(tmp_path, executable, expected):
|
||||
result, _ = diagnose(tmp_path, project(tmp_path, [check("test", executable)]))
|
||||
assert result["required_tools"] == expected
|
||||
|
||||
|
||||
def test_context_only_project_needs_no_node_and_makes_no_files(tmp_path):
|
||||
before = set(tmp_path.iterdir())
|
||||
result, _ = diagnose(tmp_path, project(tmp_path, [], profiles={}))
|
||||
assert result["required_tools"] == ["python"]
|
||||
assert set(tmp_path.iterdir()) == before
|
||||
|
||||
|
||||
def test_missing_profile_is_not_silently_widened(tmp_path):
|
||||
with pytest.raises(ValueError, match="does not declare profile"):
|
||||
diagnose(tmp_path, project(tmp_path, []), profile="ui")
|
||||
|
||||
|
||||
def test_native_govoplan_still_requires_all_three_tools(tmp_path):
|
||||
configured = project(tmp_path, [])
|
||||
args = Namespace(workspace_root=tmp_path, project=None, repo=[], profile=None)
|
||||
with (
|
||||
patch.object(doctor, "load_project", return_value=configured),
|
||||
patch.object(
|
||||
doctor,
|
||||
"resolve_tools",
|
||||
return_value={"python": sys.executable, "node": "node", "npm": "npm"},
|
||||
),
|
||||
patch.object(
|
||||
doctor,
|
||||
"tool_version",
|
||||
side_effect=lambda executable, _env: (
|
||||
"Python" if executable == sys.executable else "unavailable"
|
||||
),
|
||||
),
|
||||
patch.object(doctor, "inspect_repository", return_value={"errors": []}),
|
||||
):
|
||||
result = doctor.diagnose(args)
|
||||
assert result["required_tools"] == ["node", "npm", "python"]
|
||||
assert result["_exit_code"] == 1
|
||||
Executable
+378
@@ -0,0 +1,378 @@
|
||||
"""Environment probes read bounded stable files; all fixtures are local."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit"))
|
||||
from govoplan_devkit import environment
|
||||
from govoplan_devkit.common import digest
|
||||
from govoplan_devkit.workspace import Project, Repository
|
||||
|
||||
|
||||
def mutate_during_read(monkeypatch, callback):
|
||||
original = hashlib.sha256
|
||||
mutated = False
|
||||
|
||||
class MutatingHasher:
|
||||
def __init__(self):
|
||||
self.hasher = original()
|
||||
|
||||
def update(self, chunk):
|
||||
nonlocal mutated
|
||||
self.hasher.update(chunk)
|
||||
if not mutated:
|
||||
mutated = True
|
||||
callback()
|
||||
|
||||
def hexdigest(self):
|
||||
return self.hasher.hexdigest()
|
||||
|
||||
monkeypatch.setattr(environment.hashlib, "sha256", MutatingHasher)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content", [b"", b"stable", b"x" * (1024 * 1024 + 3)])
|
||||
def test_file_hash_keeps_existing_digest_and_exact_size_boundary(tmp_path, content):
|
||||
path = tmp_path / "input"
|
||||
path.write_bytes(content)
|
||||
assert (
|
||||
environment._environment_file_hash(path, len(content))
|
||||
== hashlib.sha256(content).hexdigest()
|
||||
)
|
||||
|
||||
|
||||
def test_missing_optional_file_remains_optional_without_a_persistent_cache(tmp_path):
|
||||
path = tmp_path / "input"
|
||||
assert environment._environment_file_hash(path, 10) is None
|
||||
path.write_bytes(b"first")
|
||||
first = environment._environment_file_hash(path, 10)
|
||||
path.write_bytes(b"other")
|
||||
assert environment._environment_file_hash(path, 10) != first
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["fifo", "directory", "oversized", "dangling"])
|
||||
def test_present_unsafe_input_is_rejected_without_blocking(tmp_path, kind):
|
||||
path = tmp_path / "input"
|
||||
if kind == "fifo":
|
||||
os.mkfifo(path)
|
||||
elif kind == "directory":
|
||||
path.mkdir()
|
||||
elif kind == "oversized":
|
||||
path.write_bytes(b"too large")
|
||||
else:
|
||||
path.symlink_to(tmp_path / "missing")
|
||||
with pytest.raises(ValueError, match="Environment input"):
|
||||
environment._environment_file_hash(path, 2)
|
||||
|
||||
|
||||
def test_stable_venv_executable_symlink_keeps_original_path_and_identity(tmp_path):
|
||||
target = tmp_path / "real-python"
|
||||
target.write_bytes(b"fixture binary")
|
||||
executable = tmp_path / "venv" / "bin" / "python"
|
||||
executable.parent.mkdir(parents=True)
|
||||
executable.symlink_to(target)
|
||||
repo = tmp_path / "repo"
|
||||
metadata = repo / "node_modules" / ".package-lock.json"
|
||||
metadata.parent.mkdir(parents=True)
|
||||
metadata.write_bytes(b'{"fixture":true}')
|
||||
project = Project("Fixture", (Repository("repo", repo),), {})
|
||||
tools = {"python": str(executable)}
|
||||
env = {"PATH": "fixture", "PWD": "ignored"}
|
||||
distributions = b'[["fixture", "1"]]\n'
|
||||
with (
|
||||
patch.object(environment, "tool_version", return_value="fixture-version"),
|
||||
patch.object(
|
||||
environment,
|
||||
"require_capture",
|
||||
return_value=SimpleNamespace(returncode=0, stdout=distributions),
|
||||
) as capture,
|
||||
):
|
||||
actual = environment.environment_fingerprint(tmp_path, project, tools, env)
|
||||
assert actual == digest(
|
||||
{
|
||||
"environment": {"PATH": "fixture"},
|
||||
"tools": {
|
||||
"python": {
|
||||
"path": str(executable),
|
||||
"version": "fixture-version",
|
||||
"sha256": hashlib.sha256(target.read_bytes()).hexdigest(),
|
||||
}
|
||||
},
|
||||
"installed": {
|
||||
str(metadata): hashlib.sha256(metadata.read_bytes()).hexdigest(),
|
||||
"python_distributions": hashlib.sha256(distributions).hexdigest(),
|
||||
},
|
||||
}
|
||||
)
|
||||
assert capture.call_args.args[0][0] == str(executable)
|
||||
|
||||
|
||||
def test_symlinked_package_directory_is_allowed_when_stable(tmp_path):
|
||||
actual = tmp_path / "packages"
|
||||
actual.mkdir()
|
||||
(actual / ".package-lock.json").write_bytes(b"fixture")
|
||||
link = tmp_path / "node_modules"
|
||||
link.symlink_to(actual, target_is_directory=True)
|
||||
assert environment._environment_file_hash(link / ".package-lock.json", 10)
|
||||
|
||||
|
||||
def test_fifo_replacement_between_inspection_and_open_does_not_block(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
path = tmp_path / "input"
|
||||
path.write_bytes(b"fixture")
|
||||
real_open = os.open
|
||||
|
||||
def replace_before_open(target, flags):
|
||||
path.unlink()
|
||||
os.mkfifo(path)
|
||||
assert flags & os.O_NONBLOCK
|
||||
return real_open(target, flags)
|
||||
|
||||
monkeypatch.setattr(environment.os, "open", replace_before_open)
|
||||
with pytest.raises(ValueError, match="bounded regular"):
|
||||
environment._environment_file_hash(path, 10)
|
||||
|
||||
|
||||
def test_growth_between_inspection_and_open_cannot_bypass_size_bound(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
path = tmp_path / "input"
|
||||
path.write_bytes(b"a")
|
||||
real_open = os.open
|
||||
|
||||
def grow_before_open(target, flags):
|
||||
path.write_bytes(b"x" * 11)
|
||||
return real_open(target, flags)
|
||||
|
||||
monkeypatch.setattr(environment.os, "open", grow_before_open)
|
||||
with pytest.raises(ValueError, match="bounded regular"):
|
||||
environment._environment_file_hash(path, 10)
|
||||
|
||||
|
||||
def test_growth_during_read_cannot_bypass_size_bound(tmp_path, monkeypatch):
|
||||
path = tmp_path / "input"
|
||||
path.write_bytes(b"a")
|
||||
mutate_during_read(monkeypatch, lambda: path.write_bytes(b"x" * 11))
|
||||
with pytest.raises(ValueError, match="grew beyond"):
|
||||
environment._environment_file_hash(path, 10)
|
||||
|
||||
|
||||
def test_same_size_edit_with_restored_mtime_is_not_a_stable_identity(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
path = tmp_path / "input"
|
||||
path.write_bytes(b"first")
|
||||
metadata = path.stat()
|
||||
|
||||
def mutate():
|
||||
path.write_bytes(b"other")
|
||||
os.utime(path, ns=(metadata.st_atime_ns, metadata.st_mtime_ns))
|
||||
|
||||
mutate_during_read(monkeypatch, mutate)
|
||||
with pytest.raises(ValueError, match="changed during"):
|
||||
environment._environment_file_hash(path, 10)
|
||||
|
||||
|
||||
def test_replacement_with_identical_bytes_is_not_a_stable_identity(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
path = tmp_path / "input"
|
||||
path.write_bytes(b"fixture")
|
||||
replacement = tmp_path / "replacement"
|
||||
replacement.write_bytes(b"fixture")
|
||||
mutate_during_read(monkeypatch, lambda: replacement.replace(path))
|
||||
with pytest.raises(ValueError, match="changed during"):
|
||||
environment._environment_file_hash(path, 10)
|
||||
|
||||
|
||||
def test_symlink_retarget_with_identical_bytes_is_not_a_stable_identity(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
first, second, link = (tmp_path / name for name in ("first", "second", "python"))
|
||||
first.write_bytes(b"fixture")
|
||||
second.write_bytes(b"fixture")
|
||||
link.symlink_to(first)
|
||||
|
||||
def retarget():
|
||||
link.unlink()
|
||||
link.symlink_to(second)
|
||||
|
||||
mutate_during_read(monkeypatch, retarget)
|
||||
with pytest.raises(ValueError, match="changed during"):
|
||||
environment._environment_file_hash(link, 10)
|
||||
|
||||
|
||||
def test_source_disappearing_during_read_fails_closed(tmp_path, monkeypatch):
|
||||
path = tmp_path / "input"
|
||||
path.write_bytes(b"fixture")
|
||||
mutate_during_read(monkeypatch, path.unlink)
|
||||
with pytest.raises(ValueError, match="Environment input"):
|
||||
environment._environment_file_hash(path, 10)
|
||||
|
||||
|
||||
def test_parent_symlink_retarget_is_rejected_even_for_the_same_target_inode(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
first, second, link = (tmp_path / name for name in ("first", "second", "bin"))
|
||||
first.mkdir()
|
||||
second.mkdir()
|
||||
(first / "python").write_bytes(b"fixture")
|
||||
os.link(first / "python", second / "python")
|
||||
link.symlink_to(first, target_is_directory=True)
|
||||
|
||||
def retarget():
|
||||
link.unlink()
|
||||
link.symlink_to(second, target_is_directory=True)
|
||||
|
||||
mutate_during_read(monkeypatch, retarget)
|
||||
with pytest.raises(ValueError, match="changed during"):
|
||||
environment._environment_file_hash(link / "python", 10)
|
||||
|
||||
|
||||
def test_invalid_executable_is_rejected_before_any_version_process(tmp_path):
|
||||
executable = tmp_path / "python"
|
||||
os.mkfifo(executable)
|
||||
project = Project("Fixture", (), {})
|
||||
with patch.object(environment, "tool_version") as version:
|
||||
with pytest.raises(ValueError, match="bounded regular"):
|
||||
environment.environment_fingerprint(
|
||||
tmp_path, project, {"python": str(executable)}, {}
|
||||
)
|
||||
version.assert_not_called()
|
||||
|
||||
|
||||
def discovery_project(root, *, path="govoplan-backend"):
|
||||
repo = root / path
|
||||
(repo / "src").mkdir(parents=True)
|
||||
return Project(
|
||||
"GovOPlaN",
|
||||
(Repository("govoplan-backend", repo),),
|
||||
{"organization": "GovOPlaN"},
|
||||
)
|
||||
|
||||
|
||||
def test_native_shape_ignores_build_files_and_directory_timestamps(tmp_path):
|
||||
project = discovery_project(tmp_path)
|
||||
repo = project.repositories[0].path
|
||||
(repo / "webui").mkdir()
|
||||
before = environment._native_discovery_fingerprint(tmp_path, project)
|
||||
for directory in (repo, repo / "src", repo / "webui"):
|
||||
(directory / "temporary-build-output").write_text("changed")
|
||||
(directory / "temporary-build-directory").mkdir()
|
||||
assert environment._native_discovery_fingerprint(tmp_path, project) == before
|
||||
(repo / "webui" / "temporary-build-output").unlink()
|
||||
assert environment._native_discovery_fingerprint(tmp_path, project) == before
|
||||
|
||||
|
||||
def test_native_shape_detects_unknown_sibling_and_source_addition_removal(tmp_path):
|
||||
project = discovery_project(tmp_path)
|
||||
baseline = environment._native_discovery_fingerprint(tmp_path, project)
|
||||
sibling = tmp_path / "govoplan-unregistered"
|
||||
sibling.mkdir()
|
||||
empty = environment._native_discovery_fingerprint(tmp_path, project)
|
||||
assert empty != baseline
|
||||
for name in ("src", "webui"):
|
||||
(sibling / name).mkdir()
|
||||
assert environment._native_discovery_fingerprint(tmp_path, project) != empty
|
||||
(sibling / name).rmdir()
|
||||
assert environment._native_discovery_fingerprint(tmp_path, project) == empty
|
||||
sibling.rmdir()
|
||||
assert environment._native_discovery_fingerprint(tmp_path, project) == baseline
|
||||
|
||||
|
||||
@pytest.mark.parametrize("repo_path", ["govoplan-backend", "nonstandard-layout"])
|
||||
def test_native_shape_detects_registered_backend_gaining_webui(tmp_path, repo_path):
|
||||
project = discovery_project(tmp_path, path=repo_path)
|
||||
baseline = environment._native_discovery_fingerprint(tmp_path, project)
|
||||
webui = project.repositories[0].path / "webui"
|
||||
webui.mkdir()
|
||||
assert environment._native_discovery_fingerprint(tmp_path, project) != baseline
|
||||
webui.rmdir()
|
||||
assert environment._native_discovery_fingerprint(tmp_path, project) == baseline
|
||||
|
||||
|
||||
def test_native_shape_binds_registered_ownership_names(tmp_path):
|
||||
project = discovery_project(tmp_path, path="nonstandard-layout")
|
||||
renamed = Project(
|
||||
project.name,
|
||||
(Repository("govoplan-renamed", project.repositories[0].path),),
|
||||
project.config,
|
||||
)
|
||||
assert environment._native_discovery_fingerprint(
|
||||
tmp_path, project
|
||||
) != environment._native_discovery_fingerprint(tmp_path, renamed)
|
||||
|
||||
|
||||
def test_native_shape_detects_source_link_retarget_and_dangling_target(tmp_path):
|
||||
project = discovery_project(tmp_path)
|
||||
first, second = tmp_path / "first", tmp_path / "second"
|
||||
first.mkdir()
|
||||
second.mkdir()
|
||||
link = project.repositories[0].path / "webui"
|
||||
link.symlink_to(first, target_is_directory=True)
|
||||
baseline = environment._native_discovery_fingerprint(tmp_path, project)
|
||||
link.unlink()
|
||||
link.symlink_to(second, target_is_directory=True)
|
||||
changed = environment._native_discovery_fingerprint(tmp_path, project)
|
||||
assert changed != baseline
|
||||
second.rmdir()
|
||||
assert environment._native_discovery_fingerprint(tmp_path, project) != changed
|
||||
|
||||
|
||||
def test_native_shape_encodes_non_directory_source_entries(tmp_path):
|
||||
project = discovery_project(tmp_path)
|
||||
webui = project.repositories[0].path / "webui"
|
||||
baseline = environment._native_discovery_fingerprint(tmp_path, project)
|
||||
webui.write_text("not a directory")
|
||||
file_shape = environment._native_discovery_fingerprint(tmp_path, project)
|
||||
assert file_shape != baseline
|
||||
webui.unlink()
|
||||
webui.mkdir()
|
||||
assert environment._native_discovery_fingerprint(tmp_path, project) != file_shape
|
||||
|
||||
|
||||
def test_native_shape_audit_is_bounded_including_nonmatching_children(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
project = discovery_project(tmp_path)
|
||||
(tmp_path / "unrelated-one").mkdir()
|
||||
(tmp_path / "unrelated-two").mkdir()
|
||||
monkeypatch.setattr(environment, "MAX_DISCOVERY_CHILDREN", 2)
|
||||
with pytest.raises(ValueError, match="bounded ownership"):
|
||||
environment._native_discovery_fingerprint(tmp_path, project)
|
||||
|
||||
|
||||
def test_native_shape_resolution_loop_fails_closed(tmp_path):
|
||||
project = discovery_project(tmp_path)
|
||||
link = project.repositories[0].path / "webui"
|
||||
link.symlink_to(link)
|
||||
with pytest.raises(ValueError, match="discovery cannot be resolved"):
|
||||
environment._native_discovery_fingerprint(tmp_path, project)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("portable", [False, True])
|
||||
def test_only_native_environment_identity_binds_discovery_shape(tmp_path, portable):
|
||||
project = discovery_project(tmp_path)
|
||||
if portable:
|
||||
project.config["schema_version"] = 1
|
||||
executable = tmp_path / "fixture-python"
|
||||
executable.write_text("not executed")
|
||||
tools = {"python": str(executable)}
|
||||
with (
|
||||
patch.object(environment, "tool_version", return_value="fixture"),
|
||||
patch.object(
|
||||
environment,
|
||||
"require_capture",
|
||||
return_value=SimpleNamespace(returncode=0, stdout=b"[]"),
|
||||
),
|
||||
):
|
||||
baseline = environment.environment_fingerprint(tmp_path, project, tools, {})
|
||||
(tmp_path / "govoplan-new" / "src").mkdir(parents=True)
|
||||
changed = environment.environment_fingerprint(tmp_path, project, tools, {})
|
||||
assert (baseline == changed) is portable
|
||||
Executable
+601
@@ -0,0 +1,601 @@
|
||||
"""Incremental checkpoints use isolated local repositories, never product or remote state."""
|
||||
|
||||
from argparse import Namespace
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit"))
|
||||
from govoplan_devkit import runner
|
||||
from govoplan_devkit.checkpoints import Checkpoints
|
||||
from govoplan_devkit.common import atomic_json, read_json, resource_lock, state_root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "xdg"))
|
||||
root = tmp_path / "workspace"
|
||||
repos = {}
|
||||
for name in ("alpha", "beta"):
|
||||
repo = root / name
|
||||
repo.mkdir(parents=True)
|
||||
subprocess.run(["git", "init", "-q", str(repo)], check=True)
|
||||
(repo / "source.txt").write_text(name + " original\n")
|
||||
subprocess.run(["git", "-C", str(repo), "add", "source.txt"], check=True)
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(repo),
|
||||
"-c",
|
||||
"user.name=Fixture",
|
||||
"-c",
|
||||
"user.email=fixture@example.invalid",
|
||||
"commit",
|
||||
"-qm",
|
||||
"fixture",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
repos[name] = repo
|
||||
config = tmp_path / "project.json"
|
||||
config.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "Incremental fixture",
|
||||
"repositories": [{"name": name, "path": name} for name in repos],
|
||||
"checks": [],
|
||||
"profiles": {},
|
||||
}
|
||||
)
|
||||
)
|
||||
traces = tmp_path / "traces"
|
||||
traces.mkdir()
|
||||
args = Namespace(
|
||||
workspace_root=root,
|
||||
project=config,
|
||||
state_dir=tmp_path / "state",
|
||||
dry_run=False,
|
||||
jobs=2,
|
||||
profile="quick",
|
||||
resume=None,
|
||||
repo=[],
|
||||
changed=False,
|
||||
)
|
||||
return args, repos, traces
|
||||
|
||||
|
||||
def stage(workspace, identity, repo="alpha", *, inputs=True, body="", **extra):
|
||||
_, repos, traces = workspace
|
||||
counter = traces / identity
|
||||
code = (
|
||||
"from pathlib import Path; "
|
||||
f"counter=Path({str(counter)!r}); "
|
||||
"counter.write_text(str(int(counter.read_text())+1 if counter.exists() else 1)); "
|
||||
f"print({identity!r},flush=True); " + body
|
||||
)
|
||||
result = {
|
||||
"id": identity,
|
||||
"title": identity,
|
||||
"argv": [sys.executable, "-c", code],
|
||||
"cwd": str(repos[repo]),
|
||||
"timeout_seconds": 5,
|
||||
**extra,
|
||||
}
|
||||
if inputs:
|
||||
result["inputs"] = {"repos": [repo]}
|
||||
return result
|
||||
|
||||
|
||||
def run(workspace, stages, *, environment="e" * 64):
|
||||
args, _, _ = workspace
|
||||
with patch.object(runner, "environment_fingerprint", return_value=environment):
|
||||
return runner.run_checks(args, stages)
|
||||
|
||||
|
||||
def count(workspace, identity):
|
||||
path = workspace[2] / identity
|
||||
return int(path.read_text()) if path.exists() else 0
|
||||
|
||||
|
||||
def stages_by_id(result):
|
||||
return {item["id"]: item for item in result["stages"]}
|
||||
|
||||
|
||||
def test_checkpoint_probes_and_durable_persistence_hold_stage_resource_lock(workspace):
|
||||
args, _, _ = workspace
|
||||
locks = state_root(args.workspace_root) / "resource-locks"
|
||||
original_identity, original_write = Checkpoints.identity, runner.atomic_json
|
||||
probes, persisted = [], []
|
||||
|
||||
def assert_held():
|
||||
with pytest.raises(RuntimeError, match="busy"):
|
||||
with resource_lock(locks, "checkpoint-fixture"):
|
||||
pass
|
||||
|
||||
def identity(self, selected):
|
||||
assert_held()
|
||||
probes.append(selected["id"])
|
||||
return original_identity(self, selected)
|
||||
|
||||
def write(path, payload):
|
||||
original_write(path, payload)
|
||||
if (
|
||||
payload.get("phase") == "checking"
|
||||
and payload["stages"][0].get("checkpoint_verified") is True
|
||||
and not persisted
|
||||
):
|
||||
assert_held()
|
||||
assert read_json(path)["stages"][0]["checkpoint_verified"] is True
|
||||
persisted.append(path)
|
||||
|
||||
with (
|
||||
patch.object(Checkpoints, "identity", identity),
|
||||
patch.object(runner, "atomic_json", side_effect=write),
|
||||
):
|
||||
result = run(
|
||||
workspace, [stage(workspace, "a", resources=["checkpoint-fixture"])]
|
||||
)
|
||||
assert result["status"] == "passed"
|
||||
assert len(probes) >= 2 and len(persisted) == 1
|
||||
|
||||
|
||||
def test_checkpoint_save_failure_never_releases_dependent_execution(workspace):
|
||||
args, _, _ = workspace
|
||||
original_write, original_command, original_wait = (
|
||||
runner.atomic_json,
|
||||
runner._execute_stage_command,
|
||||
runner.wait,
|
||||
)
|
||||
scheduler_waiting = threading.Event()
|
||||
checkpoint_stalled = threading.Event()
|
||||
scheduler_rechecked = threading.Event()
|
||||
release_failure = threading.Event()
|
||||
failed_write_paths, results, errors = [], [], []
|
||||
|
||||
def command(selected, *values, **kwargs):
|
||||
if selected["id"] == "producer":
|
||||
# Let the scheduler finish its initial running-state save and enter
|
||||
# its wait loop before the command publishes a passing checkpoint.
|
||||
assert scheduler_waiting.wait(timeout=5)
|
||||
return original_command(selected, *values, **kwargs)
|
||||
|
||||
def wait(*values, **kwargs):
|
||||
scheduler_waiting.set()
|
||||
result = original_wait(*values, **kwargs)
|
||||
if checkpoint_stalled.is_set():
|
||||
scheduler_rechecked.set()
|
||||
return result
|
||||
|
||||
def write(path, payload):
|
||||
producer = next(
|
||||
(item for item in payload.get("stages", []) if item["id"] == "producer"),
|
||||
None,
|
||||
)
|
||||
if (
|
||||
producer
|
||||
and producer.get("checkpoint_verified") is True
|
||||
and not failed_write_paths
|
||||
):
|
||||
failed_write_paths.append(path)
|
||||
checkpoint_stalled.set()
|
||||
assert release_failure.wait(timeout=5)
|
||||
raise OSError("fixture checkpoint persistence failed")
|
||||
return original_write(path, payload)
|
||||
|
||||
def execute():
|
||||
try:
|
||||
results.append(
|
||||
run(
|
||||
workspace,
|
||||
[
|
||||
stage(workspace, "producer"),
|
||||
stage(workspace, "dependent", "beta", deps=["producer"]),
|
||||
],
|
||||
)
|
||||
)
|
||||
except BaseException as exc:
|
||||
errors.append(exc)
|
||||
|
||||
worker = threading.Thread(target=execute)
|
||||
with (
|
||||
patch.object(runner, "atomic_json", side_effect=write),
|
||||
patch.object(runner, "_execute_stage_command", side_effect=command),
|
||||
patch.object(runner, "wait", side_effect=wait),
|
||||
):
|
||||
worker.start()
|
||||
try:
|
||||
assert checkpoint_stalled.wait(timeout=5)
|
||||
assert scheduler_rechecked.wait(timeout=5)
|
||||
# Keep persistence blocked across a scheduling turn: the in-memory
|
||||
# producer status must not grant authority to start a consumer.
|
||||
time.sleep(0.15)
|
||||
assert count(workspace, "dependent") == 0
|
||||
durable = read_json(failed_write_paths[0])
|
||||
assert (
|
||||
stages_by_id(durable)["producer"].get("checkpoint_verified") is not True
|
||||
)
|
||||
finally:
|
||||
release_failure.set()
|
||||
worker.join(timeout=8)
|
||||
assert not worker.is_alive() and not errors
|
||||
assert len(results) == 1 and results[0]["status"] == "failed"
|
||||
assert count(workspace, "producer") == 1 and count(workspace, "dependent") == 0
|
||||
by_id = stages_by_id(results[0])
|
||||
assert by_id["producer"]["status"] == "failed"
|
||||
assert by_id["producer"]["checkpoint_verified"] is False
|
||||
assert "fixture checkpoint persistence failed" in by_id["producer"]["error"]
|
||||
assert by_id["dependent"]["status"] == "skipped"
|
||||
persisted = runner.read_receipt(
|
||||
args.workspace_root, args.state_dir, results[0]["run_id"]
|
||||
)
|
||||
assert stages_by_id(persisted)["producer"]["checkpoint_verified"] is False
|
||||
|
||||
|
||||
def test_verified_checkpoint_reuses_unchanged_stage_after_other_repo_changes(workspace):
|
||||
args, repos, _ = workspace
|
||||
plan = [stage(workspace, "a"), stage(workspace, "b", "beta")]
|
||||
first = run(workspace, plan)
|
||||
old_bytes = Path(first["receipt_path"]).read_bytes()
|
||||
(repos["beta"] / "source.txt").write_text("beta changed\n")
|
||||
args.resume = first["run_id"]
|
||||
second = run(workspace, plan)
|
||||
by_id = stages_by_id(second)
|
||||
assert second["status"] == "passed" and second["snapshot_verified"] is True
|
||||
assert count(workspace, "a") == 1 and count(workspace, "b") == 2
|
||||
assert by_id["a"]["reused_from"] == first["run_id"]
|
||||
assert "reused_from" not in by_id["b"]
|
||||
assert by_id["a"]["checkpoint_verified"] is True
|
||||
assert by_id["a"]["checkpoint_version"] == 1
|
||||
assert isinstance(by_id["a"]["cache_key"], str) and by_id["a"]["cache_key"]
|
||||
assert second["source_fingerprint"] != first["source_fingerprint"]
|
||||
assert Path(first["receipt_path"]).read_bytes() == old_bytes
|
||||
|
||||
|
||||
def test_failed_post_execution_probe_preserves_actual_log_without_certifying_it(
|
||||
workspace,
|
||||
):
|
||||
from govoplan_devkit.checkpoints import Checkpoints
|
||||
|
||||
identity = Checkpoints.identity
|
||||
calls = 0
|
||||
|
||||
def probe(self, selected):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 2:
|
||||
raise ValueError("fixture input became unreadable")
|
||||
return identity(self, selected)
|
||||
|
||||
with patch.object(Checkpoints, "identity", probe):
|
||||
result = run(workspace, [stage(workspace, "a")])
|
||||
selected = result["stages"][0]
|
||||
assert result["status"] == "stale"
|
||||
assert selected["status"] == "stale" and selected["exit_code"] == 0
|
||||
assert selected["checkpoint_verified"] is False
|
||||
assert Path(selected["log_path"]).read_text() == "a\n"
|
||||
assert "fixture input became unreadable" in selected["error"]
|
||||
|
||||
|
||||
def test_changed_and_new_commands_run_without_discarding_unrelated_checkpoint(
|
||||
workspace,
|
||||
):
|
||||
args, _, _ = workspace
|
||||
first = run(workspace, [stage(workspace, "a"), stage(workspace, "b", "beta")])
|
||||
args.resume = first["run_id"]
|
||||
second = run(
|
||||
workspace,
|
||||
[
|
||||
stage(workspace, "a"),
|
||||
stage(workspace, "b", "beta", body="print('changed command')"),
|
||||
stage(workspace, "new", "beta"),
|
||||
],
|
||||
)
|
||||
assert second["status"] == "passed"
|
||||
assert count(workspace, "a") == 1
|
||||
assert count(workspace, "b") == 2
|
||||
assert count(workspace, "new") == 1
|
||||
assert first["plan_fingerprint"] != second["plan_fingerprint"]
|
||||
|
||||
|
||||
def test_unrelated_profile_edit_does_not_change_existing_stage_execution_identity(
|
||||
workspace,
|
||||
):
|
||||
args, _, _ = workspace
|
||||
plan = [stage(workspace, "a")]
|
||||
first = run(workspace, plan)
|
||||
config = json.loads(args.project.read_text())
|
||||
config["checks"] = [
|
||||
{
|
||||
"id": "extra",
|
||||
"argv": [sys.executable, "-c", "print('extra')"],
|
||||
"cwd": "beta",
|
||||
"repos": ["beta"],
|
||||
}
|
||||
]
|
||||
config["profiles"] = {"backend": ["extra"]}
|
||||
args.project.write_text(json.dumps(config))
|
||||
args.resume = first["run_id"]
|
||||
second = run(workspace, plan)
|
||||
assert second["status"] == "passed"
|
||||
assert count(workspace, "a") == 1
|
||||
|
||||
|
||||
def test_added_dependency_edge_invalidates_consumer_even_when_both_repo_bytes_match(
|
||||
workspace,
|
||||
):
|
||||
args, _, _ = workspace
|
||||
a, b = stage(workspace, "a"), stage(workspace, "b", "beta")
|
||||
first = run(workspace, [a, b])
|
||||
args.resume = first["run_id"]
|
||||
second = run(workspace, [a, {**b, "deps": ["a"]}])
|
||||
assert second["status"] == "passed"
|
||||
assert count(workspace, "a") == 1 and count(workspace, "b") == 2
|
||||
|
||||
|
||||
def test_failed_run_reuses_successful_phase_but_retries_failed_phase(workspace):
|
||||
args, _, traces = workspace
|
||||
ready = traces / "ready"
|
||||
plan = [
|
||||
stage(workspace, "a"),
|
||||
stage(
|
||||
workspace,
|
||||
"b",
|
||||
"beta",
|
||||
after=["a"],
|
||||
body=f"raise SystemExit(0 if Path({str(ready)!r}).exists() else 3)",
|
||||
),
|
||||
]
|
||||
first = run(workspace, plan)
|
||||
assert first["status"] == "failed"
|
||||
assert stages_by_id(first)["a"]["checkpoint_verified"] is True
|
||||
ready.touch()
|
||||
args.resume = first["run_id"]
|
||||
second = run(workspace, plan)
|
||||
assert second["status"] == "passed"
|
||||
assert count(workspace, "a") == 1 and count(workspace, "b") == 2
|
||||
|
||||
|
||||
def test_changed_dependency_invalidates_transitive_consumers_but_not_independent_stage(
|
||||
workspace,
|
||||
):
|
||||
args, repos, _ = workspace
|
||||
plan = [
|
||||
stage(workspace, "a"),
|
||||
stage(workspace, "b", "beta", deps=["a"]),
|
||||
stage(workspace, "c", "beta", deps=["b"]),
|
||||
stage(workspace, "independent", "beta"),
|
||||
]
|
||||
first = run(workspace, plan)
|
||||
(repos["alpha"] / "source.txt").write_text("alpha changed\n")
|
||||
args.resume = first["run_id"]
|
||||
second = run(workspace, plan)
|
||||
assert second["status"] == "passed"
|
||||
assert {
|
||||
identity: count(workspace, identity)
|
||||
for identity in ("a", "b", "c", "independent")
|
||||
} == {"a": 2, "b": 2, "c": 2, "independent": 1}
|
||||
|
||||
|
||||
def test_order_only_predecessor_change_does_not_invalidate_independent_stage(workspace):
|
||||
args, repos, _ = workspace
|
||||
plan = [stage(workspace, "a"), stage(workspace, "b", "beta", after=["a"])]
|
||||
first = run(workspace, plan)
|
||||
(repos["alpha"] / "source.txt").write_text("alpha changed\n")
|
||||
args.resume = first["run_id"]
|
||||
second = run(workspace, plan)
|
||||
assert second["status"] == "passed"
|
||||
assert count(workspace, "a") == 2 and count(workspace, "b") == 1
|
||||
assert stages_by_id(second)["b"]["reused_from"] == first["run_id"]
|
||||
|
||||
|
||||
def test_order_only_failure_prevents_new_downstream_execution(workspace):
|
||||
plan = [
|
||||
stage(workspace, "a", body="raise SystemExit(8)"),
|
||||
stage(workspace, "b", "beta", after=["a"]),
|
||||
]
|
||||
result = run(workspace, plan)
|
||||
assert result["status"] == "failed"
|
||||
assert count(workspace, "a") == 1 and count(workspace, "b") == 0
|
||||
assert stages_by_id(result)["b"]["status"] == "skipped"
|
||||
|
||||
|
||||
def test_never_reused_stage_and_actual_consumers_rerun_but_order_only_stage_can_reuse(
|
||||
workspace,
|
||||
):
|
||||
args, _, _ = workspace
|
||||
plan = [
|
||||
stage(workspace, "producer", reuse="never"),
|
||||
stage(workspace, "consumer", "beta", deps=["producer"]),
|
||||
stage(workspace, "independent", "beta", after=["producer"]),
|
||||
]
|
||||
first = run(workspace, plan)
|
||||
args.resume = first["run_id"]
|
||||
second = run(workspace, plan)
|
||||
assert second["status"] == "passed"
|
||||
assert count(workspace, "producer") == 2
|
||||
assert count(workspace, "consumer") == 2
|
||||
assert count(workspace, "independent") == 1
|
||||
|
||||
|
||||
def test_unspecified_input_scope_stays_conservatively_workspace_wide(workspace):
|
||||
args, repos, _ = workspace
|
||||
plan = [stage(workspace, "broad", "beta", inputs=False)]
|
||||
first = run(workspace, plan)
|
||||
(repos["alpha"] / "source.txt").write_text("alpha changed\n")
|
||||
args.resume = first["run_id"]
|
||||
second = run(workspace, plan)
|
||||
assert second["status"] == "passed"
|
||||
assert count(workspace, "broad") == 2
|
||||
|
||||
|
||||
def test_global_environment_change_invalidates_all_scoped_checkpoints(workspace):
|
||||
args, _, _ = workspace
|
||||
plan = [stage(workspace, "a"), stage(workspace, "b", "beta")]
|
||||
first = run(workspace, plan)
|
||||
args.resume = first["run_id"]
|
||||
second = run(workspace, plan, environment="f" * 64)
|
||||
assert second["status"] == "passed"
|
||||
assert count(workspace, "a") == 2 and count(workspace, "b") == 2
|
||||
assert second["environment_fingerprint"] != first["environment_fingerprint"]
|
||||
|
||||
|
||||
def test_changed_declared_scope_prevents_same_id_reuse(workspace):
|
||||
args, _, _ = workspace
|
||||
selected = stage(workspace, "a")
|
||||
first = run(workspace, [selected])
|
||||
args.resume = first["run_id"]
|
||||
selected = {**selected, "inputs": {"repos": ["alpha", "beta"]}}
|
||||
second = run(workspace, [selected])
|
||||
assert second["status"] == "passed" and count(workspace, "a") == 2
|
||||
|
||||
|
||||
def test_input_mutation_during_passing_command_never_creates_reusable_checkpoint(
|
||||
workspace,
|
||||
):
|
||||
args, repos, _ = workspace
|
||||
plan = [
|
||||
stage(
|
||||
workspace,
|
||||
"mutates",
|
||||
body="Path('source.txt').write_text('mutated during stage\\n')",
|
||||
)
|
||||
]
|
||||
first = run(workspace, plan)
|
||||
assert first["status"] != "passed"
|
||||
assert stages_by_id(first)["mutates"].get("checkpoint_verified") is not True
|
||||
args.resume = first["run_id"]
|
||||
# The same command is now stable against the already-mutated current bytes;
|
||||
# its unverified first result still cannot be skipped.
|
||||
second = run(workspace, plan)
|
||||
assert count(workspace, "mutates") == 2
|
||||
assert second["status"] == "passed"
|
||||
assert (repos["alpha"] / "source.txt").read_text() == "mutated during stage\n"
|
||||
|
||||
|
||||
def test_later_restoration_of_bytes_cannot_turn_invalid_phase_into_overall_pass(
|
||||
workspace,
|
||||
):
|
||||
_, repos, _ = workspace
|
||||
source = repos["alpha"] / "source.txt"
|
||||
original = source.read_text()
|
||||
plan = [
|
||||
stage(
|
||||
workspace,
|
||||
"mutates",
|
||||
body="Path('source.txt').write_text('temporary change\\n')",
|
||||
),
|
||||
stage(
|
||||
workspace,
|
||||
"restores",
|
||||
"beta",
|
||||
after=["mutates"],
|
||||
body=f"Path({str(source)!r}).write_text({original!r})",
|
||||
),
|
||||
]
|
||||
result = run(workspace, plan)
|
||||
assert result["status"] != "passed"
|
||||
assert stages_by_id(result)["mutates"].get("checkpoint_verified") is not True
|
||||
|
||||
|
||||
def test_recovered_interruption_reuses_only_verified_durable_checkpoint(workspace):
|
||||
args, _, _ = workspace
|
||||
plan = [stage(workspace, "a"), stage(workspace, "b", "beta", after=["a"])]
|
||||
first = run(workspace, plan)
|
||||
receipt = read_json(Path(first["receipt_path"]))
|
||||
receipt.update(
|
||||
status="running", phase="checking", snapshot_verified=False, finished_at=None
|
||||
)
|
||||
unfinished = stages_by_id(receipt)["b"]
|
||||
unfinished.update(status="running", exit_code=None, checkpoint_verified=False)
|
||||
atomic_json(Path(first["receipt_path"]), runner._seal(receipt))
|
||||
parser = argparse.ArgumentParser()
|
||||
runner.register(parser.add_subparsers(dest="command", required=True))
|
||||
recovered = parser.parse_args(
|
||||
["recover", first["run_id"], "--apply", "--confirm-processes-stopped"],
|
||||
namespace=Namespace(**vars(args)),
|
||||
)
|
||||
recovery = recovered.handler(recovered)
|
||||
assert recovery["status"] == "interrupted"
|
||||
args.resume = first["run_id"]
|
||||
second = run(workspace, plan)
|
||||
assert second["status"] == "passed"
|
||||
assert count(workspace, "a") == 1 and count(workspace, "b") == 2
|
||||
assert stages_by_id(second)["a"]["reused_from"] == first["run_id"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["tampered", "missing"])
|
||||
def test_cached_log_tamper_or_loss_is_not_accepted_as_verified_evidence(
|
||||
workspace, change
|
||||
):
|
||||
args, _, _ = workspace
|
||||
plan = [stage(workspace, "a")]
|
||||
first = run(workspace, plan)
|
||||
path = Path(first["stages"][0]["log_path"])
|
||||
if change == "missing":
|
||||
path.unlink()
|
||||
else:
|
||||
path.write_text("tampered evidence\n")
|
||||
args.resume = first["run_id"]
|
||||
result = run(workspace, plan)
|
||||
assert result["status"] == "failed"
|
||||
assert (
|
||||
result["snapshot_verified"] is not True
|
||||
or result["stages"][0]["status"] == "failed"
|
||||
)
|
||||
assert result["stages"][0].get("checkpoint_verified") is not True
|
||||
assert "reused_from" not in result["stages"][0]
|
||||
assert "error" in result["stages"][0]
|
||||
assert count(workspace, "a") == 1
|
||||
|
||||
|
||||
def test_stale_run_donates_only_checkpoints_matching_final_current_inputs(workspace):
|
||||
args, repos, _ = workspace
|
||||
source = repos["alpha"] / "source.txt"
|
||||
plan = [
|
||||
stage(workspace, "a"),
|
||||
stage(
|
||||
workspace,
|
||||
"changes-alpha",
|
||||
"beta",
|
||||
after=["a"],
|
||||
body=f"Path({str(source)!r}).write_text('new alpha bytes\\n')",
|
||||
),
|
||||
]
|
||||
first = run(workspace, plan)
|
||||
assert first["status"] != "passed"
|
||||
args.resume = first["run_id"]
|
||||
second = run(workspace, plan)
|
||||
assert second["status"] == "passed"
|
||||
assert count(workspace, "a") == 2
|
||||
# This order-only phase did not consume alpha and its own scoped inputs are
|
||||
# still identical; it may retain its independently verified checkpoint.
|
||||
assert count(workspace, "changes-alpha") == 1
|
||||
|
||||
|
||||
def test_legacy_checkpointless_receipt_does_not_gain_incremental_authority(workspace):
|
||||
args, repos, _ = workspace
|
||||
plan = [stage(workspace, "a")]
|
||||
first = run(workspace, plan)
|
||||
receipt = read_json(Path(first["receipt_path"]))
|
||||
receipt.pop("fingerprint_version", None)
|
||||
for item in receipt["stages"]:
|
||||
for field in ("checkpoint_version", "checkpoint_verified", "cache_key"):
|
||||
item.pop(field, None)
|
||||
atomic_json(Path(first["receipt_path"]), runner._seal(receipt))
|
||||
(repos["beta"] / "source.txt").write_text("unrelated changed source\n")
|
||||
args.resume = first["run_id"]
|
||||
try:
|
||||
second = run(workspace, plan)
|
||||
except ValueError:
|
||||
return # Rejecting legacy incremental reuse is also safely fail-closed.
|
||||
assert second["status"] == "passed"
|
||||
assert count(workspace, "a") == 2
|
||||
Executable
+393
@@ -0,0 +1,393 @@
|
||||
"""Repository input identities use disposable Git fixtures, never remote effects."""
|
||||
|
||||
from copy import deepcopy
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit"))
|
||||
from govoplan_devkit import inputs
|
||||
from govoplan_devkit.inputs import InputSnapshotter, validate_input_declaration
|
||||
from govoplan_devkit.workspace import Project, Repository, source_fingerprint
|
||||
|
||||
|
||||
def git(repo, *arguments):
|
||||
return subprocess.run(
|
||||
["git", "-C", str(repo), *arguments], capture_output=True, check=True
|
||||
).stdout
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixture(tmp_path, monkeypatch):
|
||||
repositories = []
|
||||
for name in ("alpha", "beta"):
|
||||
path = tmp_path / name
|
||||
path.mkdir()
|
||||
git(path, "init", "-q")
|
||||
(path / "source.txt").write_text("initial\n")
|
||||
git(path, "add", "source.txt")
|
||||
git(
|
||||
path,
|
||||
"-c",
|
||||
"user.name=Fixture",
|
||||
"-c",
|
||||
"user.email=fixture@example.invalid",
|
||||
"commit",
|
||||
"-qm",
|
||||
"fixture",
|
||||
)
|
||||
repositories.append(Repository(name, path, ("alias-" + name,)))
|
||||
project = Project(
|
||||
"Fixture",
|
||||
tuple(repositories),
|
||||
{"tools": {}, "profiles": {"quick": ["one"]}, "checks": []},
|
||||
)
|
||||
# Avoid incidental edits by other agents affecting these source-input tests.
|
||||
# The actual tool-source hashing implementation is checked separately.
|
||||
monkeypatch.setattr(
|
||||
InputSnapshotter, "_tooling_identity", lambda *_: "fixture-devkit-source"
|
||||
)
|
||||
return tmp_path, project, InputSnapshotter(project, workspace_root=tmp_path)
|
||||
|
||||
|
||||
def stage(identity="one", repos=None, **extra):
|
||||
return {
|
||||
"id": identity,
|
||||
"argv": ["true"],
|
||||
**({"inputs": {"repos": repos}} if repos is not None else {}),
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def fingerprint(snapshot, name="one"):
|
||||
return snapshot["stages"][name]["fingerprint"]
|
||||
|
||||
|
||||
def test_undeclared_inputs_remain_whole_workspace(fixture):
|
||||
root, _, engine = fixture
|
||||
result = engine.snapshot([stage()])
|
||||
assert result["complete_workspace"] is True
|
||||
assert result["observed_scope"]["repos"] == ["alpha", "beta"]
|
||||
assert result["stages"]["one"]["scope"] == {
|
||||
"version": 1,
|
||||
"kind": "workspace",
|
||||
"declared": False,
|
||||
"repos": ["alpha", "beta"],
|
||||
}
|
||||
(root / "beta/source.txt").write_text("changed\n")
|
||||
assert fingerprint(engine.snapshot([stage()])) != fingerprint(result)
|
||||
|
||||
|
||||
def test_scoped_input_does_not_read_unrelated_repository_bytes(fixture, monkeypatch):
|
||||
root, _, engine = fixture
|
||||
original = inputs.os.open
|
||||
|
||||
def guarded(path, *args, **kwargs):
|
||||
if Path(path).is_relative_to(root / "beta"):
|
||||
raise AssertionError("Unrelated repository input was opened")
|
||||
return original(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(inputs.os, "open", guarded)
|
||||
first = engine.snapshot([stage(repos=["alpha"])])
|
||||
(root / "beta/source.txt").write_text("unrelated change")
|
||||
second = engine.snapshot([stage(repos=["alpha"])])
|
||||
assert fingerprint(first) == fingerprint(second)
|
||||
assert second["scan_stats"]["repositories"] == 1
|
||||
assert second["scan_stats"]["git_calls"] == 2
|
||||
assert second["complete_workspace"] is False
|
||||
|
||||
|
||||
def test_repository_union_scans_once_per_snapshot(fixture):
|
||||
_, _, engine = fixture
|
||||
result = engine.snapshot(
|
||||
[
|
||||
stage("one", ["alpha"]),
|
||||
stage("two", ["alpha"]),
|
||||
stage("three", ["beta", "alpha"]),
|
||||
]
|
||||
)
|
||||
assert result["scan_stats"]["repositories"] == 2
|
||||
assert result["scan_stats"]["git_calls"] == 4
|
||||
assert result["stages"]["three"]["scope"]["repos"] == ["alpha", "beta"]
|
||||
|
||||
|
||||
def test_session_rechecks_metadata_but_reuses_stable_file_content(fixture):
|
||||
_, _, engine = fixture
|
||||
plan = [stage(repos=["alpha"])]
|
||||
before = engine.snapshot(plan)
|
||||
after = engine.snapshot(plan)
|
||||
assert fingerprint(before) == fingerprint(after)
|
||||
assert before["scan_stats"]["bytes"] == len("initial\n")
|
||||
assert after["scan_stats"]["bytes"] == 0
|
||||
assert after["scan_stats"]["cache_hits"] == 1
|
||||
assert after["scan_stats"]["git_calls"] == 2
|
||||
|
||||
|
||||
def test_changed_bytes_with_restored_mtime_are_not_reused(fixture):
|
||||
root, _, engine = fixture
|
||||
plan = [stage(repos=["alpha"])]
|
||||
before = engine.snapshot(plan)
|
||||
path = root / "alpha/source.txt"
|
||||
metadata = path.stat()
|
||||
path.write_text("changed\n")
|
||||
os.utime(path, ns=(metadata.st_atime_ns, metadata.st_mtime_ns))
|
||||
after = engine.snapshot(plan)
|
||||
assert fingerprint(after) != fingerprint(before)
|
||||
assert after["scan_stats"]["bytes"] == len("changed\n")
|
||||
|
||||
|
||||
def test_inode_replacement_with_same_size_and_mtime_is_not_reused(fixture):
|
||||
root, _, engine = fixture
|
||||
plan = [stage(repos=["alpha"])]
|
||||
before = engine.snapshot(plan)
|
||||
path = root / "alpha/source.txt"
|
||||
metadata = path.stat()
|
||||
replacement = root / "replacement.txt"
|
||||
replacement.write_text("changed\n")
|
||||
os.utime(replacement, ns=(metadata.st_atime_ns, metadata.st_mtime_ns))
|
||||
replacement.replace(path)
|
||||
after = engine.snapshot(plan)
|
||||
assert fingerprint(before) != fingerprint(after)
|
||||
assert after["scan_stats"]["cache_hits"] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", ["assume-unchanged", "skip-worktree"])
|
||||
def test_hidden_index_flags_do_not_hide_worktree_changes(fixture, flag):
|
||||
root, _, engine = fixture
|
||||
git(root / "alpha", "update-index", "--" + flag, "source.txt")
|
||||
before = engine.source_snapshot(["alpha"])
|
||||
(root / "alpha/source.txt").write_text("hidden change\n")
|
||||
after = engine.source_snapshot(["alpha"])
|
||||
assert before["observed_source_fingerprint"] != after["observed_source_fingerprint"]
|
||||
|
||||
|
||||
def test_index_head_and_new_deleted_files_are_bound(fixture):
|
||||
root, _, engine = fixture
|
||||
repo = root / "alpha"
|
||||
identities = []
|
||||
|
||||
def record():
|
||||
identities.append(
|
||||
engine.source_snapshot(["alpha"])["observed_source_fingerprint"]
|
||||
)
|
||||
|
||||
record()
|
||||
(repo / "source.txt").write_text("changed\n")
|
||||
record()
|
||||
git(repo, "add", "source.txt")
|
||||
record()
|
||||
git(
|
||||
repo,
|
||||
"-c",
|
||||
"user.name=Fixture",
|
||||
"-c",
|
||||
"user.email=fixture@example.invalid",
|
||||
"commit",
|
||||
"-qm",
|
||||
"change",
|
||||
)
|
||||
record()
|
||||
(repo / "new.txt").write_text("new")
|
||||
record()
|
||||
(repo / "source.txt").unlink()
|
||||
record()
|
||||
assert len(set(identities)) == len(identities)
|
||||
|
||||
|
||||
def test_missing_registered_repository_becoming_present_invalidates(fixture):
|
||||
root, project, _ = fixture
|
||||
extra = Repository("missing", root / "missing")
|
||||
extended = Project("Fixture", (*project.repositories, extra), project.config)
|
||||
engine = InputSnapshotter(extended, workspace_root=root)
|
||||
before = engine.source_snapshot(["missing"])
|
||||
extra.path.mkdir()
|
||||
git(extra.path, "init", "-q")
|
||||
after = engine.source_snapshot(["missing"])
|
||||
assert before["observed_source_fingerprint"] != after["observed_source_fingerprint"]
|
||||
|
||||
|
||||
def test_unrelated_plan_config_edits_do_not_invalidate_scoped_stage(fixture):
|
||||
root, project, engine = fixture
|
||||
plan = [stage(repos=["alpha"])]
|
||||
before = engine.snapshot(plan)
|
||||
updated = deepcopy(project.config)
|
||||
updated["profiles"]["full"] = ["unrelated"]
|
||||
updated["checks"].append({"id": "unrelated", "argv": ["false"]})
|
||||
other = InputSnapshotter(
|
||||
Project("Changed label", project.repositories, updated), workspace_root=root
|
||||
)
|
||||
assert fingerprint(other.snapshot(plan)) == fingerprint(before)
|
||||
|
||||
|
||||
def test_scope_command_tools_and_tooling_change_invalidate(fixture):
|
||||
root, project, engine = fixture
|
||||
first = engine.snapshot([stage(repos=["alpha"])])
|
||||
assert fingerprint(engine.snapshot([stage(repos=["beta"])])) != fingerprint(first)
|
||||
assert fingerprint(
|
||||
engine.snapshot([stage(repos=["alpha"], argv=["false"])])
|
||||
) != fingerprint(first)
|
||||
assert fingerprint(
|
||||
engine.snapshot([stage(repos=["alpha"])], tooling_fingerprint="new-env")
|
||||
) != fingerprint(first)
|
||||
changed = deepcopy(project.config)
|
||||
changed["tools"] = {"node": "/another/node"}
|
||||
other = InputSnapshotter(
|
||||
Project("Fixture", project.repositories, changed), workspace_root=root
|
||||
)
|
||||
assert fingerprint(other.snapshot([stage(repos=["alpha"])])) != fingerprint(first)
|
||||
|
||||
|
||||
def test_source_only_attestation_is_independent_of_plan_and_tooling(fixture):
|
||||
_, _, engine = fixture
|
||||
one = engine.snapshot([stage(repos=["alpha"])], tooling_fingerprint="env-one")
|
||||
two = engine.snapshot(
|
||||
[stage("different", ["alpha"], argv=["false"])], tooling_fingerprint="env-two"
|
||||
)
|
||||
source = engine.source_snapshot(["alpha"])
|
||||
assert (
|
||||
one["observed_source_fingerprint"]
|
||||
== two["observed_source_fingerprint"]
|
||||
== source["observed_source_fingerprint"]
|
||||
)
|
||||
assert (
|
||||
one["stages"]["one"]["source_fingerprint"]
|
||||
== source["observed_source_fingerprint"]
|
||||
)
|
||||
assert source["fingerprint_version"] == inputs.FINGERPRINT_VERSION
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
None,
|
||||
{},
|
||||
{"paths": ["src/**"]},
|
||||
{"repos": []},
|
||||
{"repos": "alpha"},
|
||||
{"repos": ["unknown"]},
|
||||
{"repos": ["alias-alpha"]},
|
||||
{"repos": ["alpha", "alpha"]},
|
||||
{"repos": [""]},
|
||||
{"repos": ["alpha"], "extra": True},
|
||||
],
|
||||
)
|
||||
def test_synthetic_stage_scopes_fail_closed(fixture, value):
|
||||
_, _, engine = fixture
|
||||
with pytest.raises(ValueError):
|
||||
engine.snapshot([stage(inputs=value)])
|
||||
|
||||
|
||||
def test_duplicate_stage_ids_and_escaped_repository_paths_fail(fixture):
|
||||
root, project, engine = fixture
|
||||
with pytest.raises(ValueError, match="Duplicate"):
|
||||
engine.snapshot([stage(), stage()])
|
||||
bad = Project("Bad", (Repository("outside", root.parent),), project.config)
|
||||
with pytest.raises(ValueError, match="escapes"):
|
||||
InputSnapshotter(bad, workspace_root=root)
|
||||
|
||||
|
||||
def test_declaration_validation_is_pure_before_dry_run(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
inputs,
|
||||
"git_bytes",
|
||||
lambda *_a, **_k: pytest.fail("Planning validation ran Git"),
|
||||
)
|
||||
assert validate_input_declaration(
|
||||
{"repos": ["beta", "alpha"]}, {"alpha", "beta"}
|
||||
) == {"repos": ["alpha", "beta"]}
|
||||
with pytest.raises(ValueError):
|
||||
validate_input_declaration({"repos": ["unknown"]}, {"alpha", "beta"})
|
||||
|
||||
|
||||
def test_runtime_records_are_not_hashed_as_execution_plans(fixture):
|
||||
_, _, engine = fixture
|
||||
with pytest.raises(ValueError, match="freshly planned"):
|
||||
engine.snapshot([stage(repos=["alpha"], status="passed")])
|
||||
|
||||
|
||||
def test_membership_count_is_bounded_before_entry_hashing(fixture, monkeypatch):
|
||||
root, _, engine = fixture
|
||||
(root / "alpha/new.txt").write_text("extra")
|
||||
monkeypatch.setattr(inputs, "MAX_REPOSITORY_ENTRIES", 1)
|
||||
with pytest.raises(ValueError, match="entry count"):
|
||||
engine.source_snapshot(["alpha"])
|
||||
|
||||
|
||||
def test_symlink_to_ignored_file_inside_repository_binds_target(fixture):
|
||||
root, _, engine = fixture
|
||||
repo = root / "alpha"
|
||||
(repo / ".gitignore").write_text("ignored.txt\n")
|
||||
(repo / "ignored.txt").write_text("first")
|
||||
(repo / "linked.txt").symlink_to("ignored.txt")
|
||||
before = engine.source_snapshot(["alpha"])
|
||||
(repo / "ignored.txt").write_text("other")
|
||||
assert (
|
||||
engine.source_snapshot(["alpha"])["observed_source_fingerprint"]
|
||||
!= before["observed_source_fingerprint"]
|
||||
)
|
||||
|
||||
|
||||
def test_dangling_symlink_target_creation_is_observed(fixture):
|
||||
root, _, engine = fixture
|
||||
repo = root / "alpha"
|
||||
(repo / "linked.txt").symlink_to("future.txt")
|
||||
before = engine.source_snapshot(["alpha"])
|
||||
(repo / "future.txt").write_text("created")
|
||||
assert (
|
||||
engine.source_snapshot(["alpha"])["observed_source_fingerprint"]
|
||||
!= before["observed_source_fingerprint"]
|
||||
)
|
||||
|
||||
|
||||
def test_cross_repository_and_directory_symlinks_are_not_silently_reused(fixture):
|
||||
root, _, engine = fixture
|
||||
link = root / "alpha/linked.txt"
|
||||
link.symlink_to(root / "beta/source.txt")
|
||||
with pytest.raises(ValueError, match="escapes"):
|
||||
engine.source_snapshot(["alpha"])
|
||||
link.unlink()
|
||||
link.symlink_to(".")
|
||||
with pytest.raises(ValueError, match="regular file"):
|
||||
engine.source_snapshot(["alpha"])
|
||||
|
||||
|
||||
def test_file_change_during_hash_is_rejected(fixture, monkeypatch):
|
||||
root, _, engine = fixture
|
||||
path = root / "alpha/source.txt"
|
||||
original = inputs.os.fstat
|
||||
changed = False
|
||||
|
||||
def mutate(descriptor):
|
||||
nonlocal changed
|
||||
metadata = original(descriptor)
|
||||
if not changed:
|
||||
changed = True
|
||||
path.write_text("changed during read")
|
||||
return metadata
|
||||
|
||||
monkeypatch.setattr(inputs.os, "fstat", mutate)
|
||||
with pytest.raises(ValueError, match="changed"):
|
||||
engine._file_hash(path, inputs._stats())
|
||||
|
||||
|
||||
def test_legacy_whole_project_api_remains_independent(fixture):
|
||||
_, project, engine = fixture
|
||||
legacy = source_fingerprint(project)
|
||||
engine.snapshot([stage(repos=["alpha"])])
|
||||
assert source_fingerprint(project) == legacy
|
||||
|
||||
|
||||
def test_real_tooling_inventory_is_bound_and_memoized(tmp_path):
|
||||
repo = Repository("example", tmp_path)
|
||||
project = Project("Fixture", (repo,), {"tools": {}})
|
||||
engine = InputSnapshotter(project, workspace_root=tmp_path)
|
||||
before_stats, after_stats = inputs._stats(), inputs._stats()
|
||||
before = engine._tooling_identity(before_stats)
|
||||
after = engine._tooling_identity(after_stats)
|
||||
assert before == after
|
||||
assert before_stats["tooling_files"] > 15 and before_stats["tooling_bytes"] > 0
|
||||
assert after_stats["tooling_files"] == after_stats["tooling_cache_hits"]
|
||||
assert after_stats["tooling_bytes"] == 0
|
||||
Executable
+387
@@ -0,0 +1,387 @@
|
||||
import argparse
|
||||
from copy import deepcopy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit"))
|
||||
from govoplan_devkit import issues
|
||||
from govoplan_devkit.common import atomic_json, digest, state_root
|
||||
from govoplan_devkit.workspace import load_project, source_fingerprint
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def args(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("GITEA_TOKEN", raising=False)
|
||||
root = tmp_path / "repo"
|
||||
root.mkdir()
|
||||
subprocess.run(["git", "init", "-q", str(root)], check=True)
|
||||
subprocess.run(["git", "-C", str(root), "remote", "add", "origin", "https://gitea.invalid/team/repo.git"], check=True)
|
||||
project = tmp_path / "project.json"
|
||||
project.write_text(json.dumps({"schema_version": 1, "name": "Fixture", "repositories": [{"name": "repo", "path": "repo"}]}))
|
||||
env_file = tmp_path / "private.env"
|
||||
env_file.write_text("GITEA_TOKEN=fixture-private-token\nGITEA_OWNER=wrong-owner\n")
|
||||
return argparse.Namespace(workspace_root=tmp_path, state_dir=tmp_path / "state", project=project,
|
||||
root=root, issue=7, target_plan=None, remote="origin", env_file=env_file, evidence=None,
|
||||
key="verification", note_summary=["Verified scoped changes."], next_steps=["Manual review remains."],
|
||||
body_file=None, note_file=None, apply=False, retry_uncertain=False)
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, target):
|
||||
self.target = target
|
||||
self.calls = []
|
||||
self.comments = []
|
||||
self.issue_id = 700
|
||||
self.post_mode = "normal"
|
||||
self.bad_binding = False
|
||||
self.repeat_pages = False
|
||||
self.bad_issue = False
|
||||
self.fail = False
|
||||
self.page_size = 2 # A server may enforce a cap smaller than the requested limit.
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def request_json(self, method, path, body=None, query=None):
|
||||
self.calls.append((method, path, deepcopy(body), deepcopy(query)))
|
||||
if self.fail:
|
||||
raise RuntimeError("Remote echoed fixture-private-token")
|
||||
if method == "GET" and path == self.target.path:
|
||||
return {"id": self.issue_id, "number": self.target.issue, "html_url": self.target.url + ("wrong" if self.bad_issue else ""),
|
||||
"state": "closed", "body": "- [ ] Preserve this checklist"}
|
||||
if method == "GET" and path == self.target.path + "/comments":
|
||||
page = 1 if self.repeat_pages else query["page"]
|
||||
return deepcopy(self.comments[(page - 1) * self.page_size:page * self.page_size])
|
||||
if method == "GET" and "/issues/comments/" in path:
|
||||
comment = deepcopy(next(item for item in self.comments if str(item["id"]) == path.rsplit("/", 1)[1]))
|
||||
comment["html_url"] = self.target.url + ("-other" if self.bad_binding else "") + "#issuecomment-" + str(comment["id"])
|
||||
return comment
|
||||
if method == "POST" and path == self.target.path + "/comments":
|
||||
if self.post_mode == "timeout-before-commit":
|
||||
raise TimeoutError("fixture-private-token")
|
||||
comment = {"id": 1000 + len(self.comments), "body": body["body"]}
|
||||
self.comments.append(comment)
|
||||
if self.post_mode == "timeout-after-commit":
|
||||
raise TimeoutError("fixture-private-token")
|
||||
return deepcopy(comment)
|
||||
raise AssertionError((method, path))
|
||||
|
||||
@property
|
||||
def posts(self):
|
||||
return [call for call in self.calls if call[0] == "POST"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(args, monkeypatch):
|
||||
client = FakeClient(issues.resolve_target(args.root, args.issue, args.workspace_root))
|
||||
monkeypatch.setattr(issues, "make_client", lambda target, token: client)
|
||||
return client
|
||||
|
||||
|
||||
def test_default_dry_run_is_offline_and_ignores_ambient_target_overrides(args, monkeypatch):
|
||||
monkeypatch.setenv("GITEA_OWNER", "wrong")
|
||||
monkeypatch.setenv("GITEA_REPO", "wrong")
|
||||
monkeypatch.setenv("GITEA_URL", "https://wrong.invalid")
|
||||
monkeypatch.setattr(issues, "make_client", lambda *_: pytest.fail("No network in preview"))
|
||||
args.env_file = args.workspace_root / "does-not-exist.env"
|
||||
result = issues.handle_note(args)
|
||||
assert result["targets"][0]["url"] == "https://gitea.invalid/team/repo/issues/7"
|
||||
assert result["targets"][0]["status"] == "would-post"
|
||||
assert not args.state_dir.exists()
|
||||
|
||||
|
||||
def test_scoped_checkpoint_evidence_compares_only_recorded_sources(args, monkeypatch):
|
||||
from govoplan_devkit import runner
|
||||
|
||||
other = args.workspace_root / "other"
|
||||
other.mkdir()
|
||||
subprocess.run(["git", "init", "-q", str(other)], check=True)
|
||||
declaration = json.loads(args.project.read_text())
|
||||
declaration["repositories"].append({"name": "other", "path": "other"})
|
||||
args.project.write_text(json.dumps(declaration))
|
||||
monkeypatch.setattr(runner, "environment_fingerprint", lambda *_: "fixture-env")
|
||||
run_args = argparse.Namespace(**{**vars(args), "jobs": 1, "profile": "quick", "dry_run": False})
|
||||
result = runner.run_checks(run_args, [{"id": "scoped", "argv": [sys.executable, "-c", "print('ok')"],
|
||||
"cwd": str(args.root), "inputs": {"repos": ["repo"]}}])
|
||||
assert result["status"] == "passed"
|
||||
evidence = issues.evidence_record(result["run_id"], args)
|
||||
assert evidence["source_state"] == "matches-current"
|
||||
assert evidence["source_scope"]["repos"] == ["repo"]
|
||||
assert any("recorded repository input scope" in note for note in evidence["coverage_notes"])
|
||||
(other / "unrelated.txt").write_text("Outside the recorded scope")
|
||||
assert issues.evidence_record(result["run_id"], args)["source_state"] == "matches-current"
|
||||
(args.root / "changed.txt").write_text("Inside the recorded scope")
|
||||
assert issues.evidence_record(result["run_id"], args)["source_state"] == "historical-source-differs"
|
||||
|
||||
|
||||
def test_passing_aggregate_cannot_hide_skipped_checks(args):
|
||||
payload = receipt(args)
|
||||
payload["stages"].append({"id": "skipped", "status": "skipped", "exit_code": None})
|
||||
with pytest.raises(ValueError, match="inconsistent"):
|
||||
issues.validate_receipt(payload, args.workspace_root)
|
||||
|
||||
|
||||
def test_apply_is_append_only_read_back_and_idempotent(args, client):
|
||||
args.apply = True
|
||||
result = issues.handle_note(args)
|
||||
assert result["targets"][0]["status"] == "posted-verified"
|
||||
assert len(client.posts) == 1
|
||||
again = issues.handle_note(args)
|
||||
assert again["targets"][0]["status"] == "existing-verified"
|
||||
assert len(client.posts) == 1
|
||||
assert {call[0] for call in client.calls} == {"GET", "POST"}
|
||||
assert all(call[1].endswith("/comments") for call in client.posts)
|
||||
|
||||
|
||||
def test_complete_pagination_continues_past_short_pages(args, client):
|
||||
body = issues.handle_note(args)["targets"][0]["body"]
|
||||
client.comments = [{"id": n, "body": "unrelated"} for n in range(1, 6)] + [{"id": 6, "body": body}]
|
||||
args.apply = True
|
||||
result = issues.handle_note(args)
|
||||
assert result["targets"][0]["status"] == "existing-verified"
|
||||
assert not client.posts
|
||||
assert max(call[3]["page"] for call in client.calls if call[3]) == 4
|
||||
|
||||
|
||||
@pytest.mark.parametrize("collision", ["different-body", "duplicate", "repeating-pagination"])
|
||||
def test_collisions_and_incomplete_pagination_refuse_post(args, client, collision):
|
||||
body = issues.handle_note(args)["targets"][0]["body"]
|
||||
client.comments = [{"id": 1, "body": body}]
|
||||
if collision == "different-body":
|
||||
client.comments[0]["body"] += "changed"
|
||||
elif collision == "duplicate":
|
||||
client.comments.append({"id": 2, "body": body})
|
||||
else:
|
||||
client.repeat_pages = True
|
||||
args.apply = True
|
||||
assert issues.handle_note(args)["_exit_code"] == 2
|
||||
assert not client.posts
|
||||
|
||||
|
||||
def test_timeout_after_commit_reconciles_without_replaying_post(args, client):
|
||||
args.apply = True
|
||||
client.post_mode = "timeout-after-commit"
|
||||
result = issues.handle_note(args)
|
||||
assert result["targets"][0]["status"] == "reconciled-verified"
|
||||
assert len(client.posts) == 1
|
||||
assert "fixture-private-token" not in json.dumps(result)
|
||||
|
||||
|
||||
def test_uncertain_post_requires_explicit_retry_after_reconciliation(args, client):
|
||||
args.apply = True
|
||||
client.post_mode = "timeout-before-commit"
|
||||
assert issues.handle_note(args)["targets"][0]["status"] == "uncertain"
|
||||
client.post_mode = "normal"
|
||||
assert issues.handle_note(args)["targets"][0]["status"] == "uncertain-retry-required"
|
||||
assert len(client.posts) == 1
|
||||
args.retry_uncertain = True
|
||||
assert issues.handle_note(args)["targets"][0]["status"] == "posted-verified"
|
||||
assert len(client.posts) == 2
|
||||
journals = list(state_root(args.workspace_root, args.state_dir).glob("issue-notes/*.json"))
|
||||
assert journals and all("fixture-private-token" not in path.read_text() for path in journals)
|
||||
|
||||
|
||||
def test_uncertain_readback_is_not_reported_verified_and_later_reconciles(args, client):
|
||||
args.apply = True
|
||||
client.bad_binding = True
|
||||
assert issues.handle_note(args)["targets"][0]["status"] == "uncertain"
|
||||
client.bad_binding = False
|
||||
assert issues.handle_note(args)["targets"][0]["status"] == "existing-verified"
|
||||
assert len(client.posts) == 1
|
||||
|
||||
|
||||
def test_journal_binds_immutable_issue_id(args, client):
|
||||
args.apply = True
|
||||
assert issues.handle_note(args)["targets"][0]["status"] == "posted-verified"
|
||||
client.issue_id += 1
|
||||
client.comments.clear()
|
||||
args.retry_uncertain = True
|
||||
assert issues.handle_note(args)["_exit_code"] == 2
|
||||
assert len(client.posts) == 1
|
||||
|
||||
|
||||
def _plan(args, rows):
|
||||
args.root = args.issue = None
|
||||
args.target_plan = args.workspace_root / "targets.json"
|
||||
args.target_plan.write_text(json.dumps({"schema_version": 1, "targets": rows}))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["wrong-url", "duplicate", "outside", "mixed-origin", "credentials"])
|
||||
def test_target_plans_require_exact_unique_workspace_bindings(args, change):
|
||||
row = {"root": "repo", "issue": 7, "url": "https://gitea.invalid/team/repo/issues/7"}
|
||||
if change == "wrong-url":
|
||||
row["url"] = "https://gitea.invalid/team/other/issues/7"
|
||||
elif change == "outside":
|
||||
row["root"] = "../other"
|
||||
elif change == "credentials":
|
||||
subprocess.run(["git", "-C", str(args.root), "remote", "set-url", "origin", "https://user:private@gitea.invalid/team/repo.git"], check=True)
|
||||
rows = [row, deepcopy(row)] if change == "duplicate" else [row]
|
||||
if change == "mixed-origin":
|
||||
other = args.workspace_root / "other"
|
||||
other.mkdir()
|
||||
subprocess.run(["git", "init", "-q", str(other)], check=True)
|
||||
subprocess.run(["git", "-C", str(other), "remote", "add", "origin", "http://gitea.invalid/team/other.git"], check=True)
|
||||
rows.append({"root": "other", "issue": 8, "url": "http://gitea.invalid/team/other/issues/8"})
|
||||
_plan(args, rows)
|
||||
with pytest.raises(ValueError):
|
||||
issues.handle_note(args)
|
||||
|
||||
|
||||
def test_all_targets_preflight_before_first_post_and_posts_are_serial(args, client, monkeypatch):
|
||||
first = client.target.record()
|
||||
second = {**first, "issue": 8, "url": first["url"].rsplit("/", 1)[0] + "/8"}
|
||||
_plan(args, [first, second])
|
||||
second_client = FakeClient(issues.resolve_target(Path(first["root"]), 8, args.workspace_root))
|
||||
second_client.bad_issue = True
|
||||
monkeypatch.setattr(issues, "make_client", lambda target, token: client if target.issue == 7 else second_client)
|
||||
args.apply = True
|
||||
assert issues.handle_note(args)["_exit_code"] == 2
|
||||
assert not client.posts and not second_client.posts
|
||||
second_client.bad_issue = False
|
||||
result = issues.handle_note(args)
|
||||
assert [row["status"] for row in result["targets"]] == ["posted-verified", "posted-verified"]
|
||||
assert len(client.posts) == len(second_client.posts) == 1
|
||||
|
||||
|
||||
def test_credentials_and_response_errors_are_not_returned(args, client):
|
||||
args.apply = True
|
||||
client.fail = True
|
||||
result = issues.handle_note(args)
|
||||
assert result["_exit_code"] == 2
|
||||
assert "fixture-private-token" not in json.dumps(result)
|
||||
args.note_summary = ["fixture-private-token"]
|
||||
with pytest.raises(ValueError, match="credential"):
|
||||
issues.handle_note(args)
|
||||
|
||||
|
||||
def test_marker_injection_and_symlink_inputs_are_rejected(args):
|
||||
args.note_summary = [issues.MARKER_PREFIX + "fake -->"]
|
||||
with pytest.raises(ValueError, match="reserved"):
|
||||
issues.handle_note(args)
|
||||
args.note_summary = ["safe"]
|
||||
args.body_file = args.workspace_root / "alias.md"
|
||||
args.body_file.symlink_to(args.env_file)
|
||||
with pytest.raises(ValueError, match="symlink"):
|
||||
issues.handle_note(args)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("remote", ["https://gitea.invalid/team/repo.git?token=secret", "https://gitea.invalid/team/repo.git#other", "https://gitea.invalid:bad/team/repo.git"])
|
||||
def test_ambiguous_git_remote_is_not_silently_reinterpreted(args, remote):
|
||||
subprocess.run(["git", "-C", str(args.root), "remote", "set-url", "origin", remote], check=True)
|
||||
with pytest.raises(ValueError):
|
||||
issues.handle_note(args)
|
||||
|
||||
|
||||
def test_structured_inputs_are_merged_as_data_not_executed(args):
|
||||
args.note_file = args.workspace_root / "note.json"
|
||||
args.note_file.write_text(json.dumps({"summary": ["Recorded earlier"], "next": ["Still pending"], "body": "$(never-execute)"}))
|
||||
args.body_file = args.workspace_root / "body.md"
|
||||
args.body_file.write_text("`never-run-this-either`")
|
||||
body = issues.handle_note(args)["targets"][0]["body"]
|
||||
assert "Recorded earlier" in body and "Verified scoped changes" in body
|
||||
assert "$(never-execute)" in body and "`never-run-this-either`" in body
|
||||
|
||||
|
||||
def receipt(args):
|
||||
return {"schema_version": 1, "run_id": "fixture-run", "workspace_root": str(args.workspace_root),
|
||||
"project_file": str(args.project), "source_fingerprint": source_fingerprint(load_project(args.workspace_root, args.project)),
|
||||
"status": "passed", "snapshot_verified": True, "generated_at": "2026-09-08T12:00:00Z", "finished_at": "2026-09-08T12:00:01Z",
|
||||
"stages": [{"id": "fixture", "status": "passed", "exit_code": 0, "duration_seconds": 1,
|
||||
"log_path": "/private/log-not-opened", "argv": ["never-execute-this"]}]}
|
||||
|
||||
|
||||
def test_external_receipts_are_unverified_metadata_with_source_comparison(args):
|
||||
path = args.workspace_root / "external.json"
|
||||
path.write_text(json.dumps(receipt(args)))
|
||||
args.evidence = str(path)
|
||||
result = issues.handle_note(args)
|
||||
evidence = result["evidence"]
|
||||
assert evidence["origin"] == "external-unverified"
|
||||
assert evidence["source_state"] == "matches-current"
|
||||
assert "argv" not in evidence["stages"][0]
|
||||
(args.root / "dirty.txt").write_text("changed")
|
||||
assert issues.handle_note(args)["evidence"]["source_state"] == "historical-source-differs"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation", ["foreign", "bad-fingerprint", "false-pass", "duplicate-stage", "unknown-status", "noninteger-exit", "invalid-state-shape", "invalid-stage-state-shape", "bool-schema"])
|
||||
def test_invalid_receipt_cannot_supply_evidence(args, mutation):
|
||||
payload = receipt(args)
|
||||
if mutation == "foreign":
|
||||
payload["workspace_root"] = str(args.workspace_root.parent)
|
||||
elif mutation == "bad-fingerprint":
|
||||
payload["source_fingerprint"] = "claimed-green"
|
||||
elif mutation == "false-pass":
|
||||
payload["stages"][0]["exit_code"] = 1
|
||||
elif mutation == "duplicate-stage":
|
||||
payload["stages"] *= 2
|
||||
elif mutation == "unknown-status":
|
||||
payload["status"] = "complete-review"
|
||||
elif mutation == "invalid-state-shape":
|
||||
payload["status"] = {}
|
||||
elif mutation == "invalid-stage-state-shape":
|
||||
payload["stages"][0]["status"] = []
|
||||
elif mutation == "bool-schema":
|
||||
payload["schema_version"] = True
|
||||
else:
|
||||
payload["stages"][0]["exit_code"] = False
|
||||
with pytest.raises(ValueError):
|
||||
issues.validate_receipt(payload, args.workspace_root)
|
||||
|
||||
|
||||
def test_local_receipt_integrity_is_checked_but_not_an_attestation(args):
|
||||
payload = receipt(args)
|
||||
payload["integrity_sha256"] = digest(payload)
|
||||
path = state_root(args.workspace_root, args.state_dir) / "runs/fixture-run/receipt.json"
|
||||
atomic_json(path, payload)
|
||||
args.evidence = "fixture-run"
|
||||
result = issues.handle_note(args)
|
||||
assert result["evidence"]["origin"] == "local-integrity-checked"
|
||||
assert "not an independent attestation" in result["evidence"]["attestation"]
|
||||
payload["status"] = "failed"
|
||||
atomic_json(path, payload)
|
||||
with pytest.raises(ValueError, match="integrity"):
|
||||
issues.handle_note(args)
|
||||
|
||||
|
||||
def test_timed_out_stage_is_reportable_without_claiming_success(args):
|
||||
payload = receipt(args)
|
||||
payload["status"] = "failed"
|
||||
payload["stages"][0].update(status="timed_out", exit_code=-15)
|
||||
assert issues.validate_receipt(payload, args.workspace_root)["status"] == "failed"
|
||||
|
||||
|
||||
def test_scoped_coverage_limits_are_preserved_in_evidence_and_rendered_note(args, monkeypatch):
|
||||
payload = receipt(args)
|
||||
monkeypatch.setenv("FIXTURE_SECRET", "fixture-secret-value")
|
||||
payload["stages"][0]["coverage_notes"] = ["Compiler/chained suite not run.", "Manual <script>not executed</script> fixture-secret-value"]
|
||||
path = args.workspace_root / "coverage.json"
|
||||
path.write_text(json.dumps(payload))
|
||||
args.evidence = str(path)
|
||||
result = issues.handle_note(args)
|
||||
evidence = result["evidence"]
|
||||
assert evidence["coverage_notes"] == evidence["stages"][0]["coverage_notes"]
|
||||
assert "Compiler/chained suite not run." in result["targets"][0]["body"]
|
||||
assert "omitted checks ran" in result["targets"][0]["body"]
|
||||
assert "<script>" not in result["targets"][0]["body"]
|
||||
assert "fixture-secret-value" not in json.dumps(result)
|
||||
assert any("coverage limitation" in line for line in result["summary"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("notes", ["not-a-list", [None], [""], ["x" * 4097], ["limit"] * 2049, [issues.MARKER_PREFIX + "injection"]])
|
||||
def test_malformed_coverage_metadata_is_rejected(args, notes):
|
||||
payload = receipt(args)
|
||||
payload["stages"][0]["coverage_notes"] = notes
|
||||
with pytest.raises(ValueError):
|
||||
issues.validate_receipt(payload, args.workspace_root)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("snapshot_verified", [None, False, "true", 1])
|
||||
def test_external_pass_requires_verified_snapshot_flag(args, snapshot_verified):
|
||||
payload = receipt(args)
|
||||
payload["snapshot_verified"] = snapshot_verified
|
||||
with pytest.raises(ValueError, match="verified source snapshot"):
|
||||
issues.validate_receipt(payload, args.workspace_root)
|
||||
Executable
+539
@@ -0,0 +1,539 @@
|
||||
"""Git maintenance runs only in disposable local repositories/bare fixtures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from contextlib import contextmanager
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit"))
|
||||
from govoplan_devkit import maintenance
|
||||
|
||||
|
||||
class MaintenanceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(prefix="govoplan-devkit-git-")
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.repo = self.root / "repo"
|
||||
self.repo.mkdir()
|
||||
self.git("init", "-b", "main")
|
||||
self.git("config", "user.name", "Fixture")
|
||||
self.git("config", "user.email", "fixture@example.invalid")
|
||||
self.git("config", "commit.gpgsign", "false")
|
||||
self.write("selected.txt", "base selected\n")
|
||||
self.write("unrelated.txt", "base unrelated\n")
|
||||
self.git("add", "--", "selected.txt", "unrelated.txt")
|
||||
self.git("commit", "-m", "fixture base")
|
||||
self.base = self.git("rev-parse", "HEAD").strip()
|
||||
self.project = self.root / "project.json"
|
||||
self.project.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "fixture",
|
||||
"repositories": [{"name": "repo", "path": "repo"}],
|
||||
}
|
||||
)
|
||||
)
|
||||
self.args = argparse.Namespace(
|
||||
workspace_root=self.root,
|
||||
state_dir=self.root / "state",
|
||||
project=self.project,
|
||||
repo="repo",
|
||||
path=["selected.txt"],
|
||||
message="selected change",
|
||||
apply=False,
|
||||
)
|
||||
|
||||
def git(self, *args):
|
||||
return subprocess.run(
|
||||
["git", "-C", str(self.repo), *args],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
).stdout
|
||||
|
||||
def write(self, name, value):
|
||||
(self.repo / name).write_text(value)
|
||||
|
||||
def save_plan(self):
|
||||
self.args.apply = True
|
||||
result = maintenance.plan(self.args)
|
||||
self.args.plan_id = result["plan"]["plan_id"]
|
||||
return result
|
||||
|
||||
def test_preview_does_not_write_a_plan_or_stage(self):
|
||||
self.write("selected.txt", "changed\n")
|
||||
before = self.git("ls-files", "--stage")
|
||||
result = maintenance.plan(self.args)
|
||||
self.assertEqual(result["state"]["status"], "preview")
|
||||
self.assertFalse((self.root / "state").exists())
|
||||
self.assertEqual(self.git("ls-files", "--stage"), before)
|
||||
self.assertEqual(self.git("rev-parse", "HEAD").strip(), self.base)
|
||||
|
||||
def test_selected_commit_preserves_unrelated_staged_and_dirty_changes(self):
|
||||
self.write("selected.txt", "selected final\n")
|
||||
self.write("unrelated.txt", "unrelated staged\n")
|
||||
self.git("add", "--", "unrelated.txt")
|
||||
self.write("unrelated.txt", "unrelated unstaged\n")
|
||||
staged = self.git("show", ":unrelated.txt")
|
||||
self.save_plan()
|
||||
before_plan = (
|
||||
maintenance._receipt_path(self.args, self.args.plan_id)
|
||||
.joinpath("plan.json")
|
||||
.read_bytes()
|
||||
)
|
||||
self.args.apply = False
|
||||
self.assertEqual(maintenance.commit(self.args)["state"]["status"], "preview")
|
||||
self.args.apply = True
|
||||
result = maintenance.commit(self.args)
|
||||
self.assertEqual(result["state"]["status"], "committed")
|
||||
self.assertEqual(
|
||||
self.git(
|
||||
"diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"
|
||||
).strip(),
|
||||
"selected.txt",
|
||||
)
|
||||
self.assertEqual(self.git("show", ":unrelated.txt"), staged)
|
||||
self.assertEqual(
|
||||
(self.repo / "unrelated.txt").read_text(), "unrelated unstaged\n"
|
||||
)
|
||||
self.assertEqual(
|
||||
maintenance.commit(self.args)["state"]["commit"], result["state"]["commit"]
|
||||
)
|
||||
self.assertEqual(
|
||||
maintenance._receipt_path(self.args, self.args.plan_id)
|
||||
.joinpath("plan.json")
|
||||
.read_bytes(),
|
||||
before_plan,
|
||||
)
|
||||
|
||||
def test_new_selected_file_and_deletion_are_supported(self):
|
||||
self.write("new file.txt", "new\n")
|
||||
(self.repo / "selected.txt").unlink()
|
||||
self.git("add", "--", "selected.txt")
|
||||
self.args.path = ["new file.txt", "selected.txt"]
|
||||
self.save_plan()
|
||||
result = maintenance.commit(self.args)
|
||||
self.assertEqual(result["state"]["status"], "committed")
|
||||
self.assertEqual(
|
||||
set(
|
||||
self.git(
|
||||
"diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"
|
||||
).splitlines()
|
||||
),
|
||||
{"new file.txt", "selected.txt"},
|
||||
)
|
||||
|
||||
def test_selected_partial_staging_is_not_overwritten(self):
|
||||
self.write("selected.txt", "staged\n")
|
||||
self.git("add", "--", "selected.txt")
|
||||
self.write("selected.txt", "unstaged\n")
|
||||
with self.assertRaisesRegex(ValueError, "different staged"):
|
||||
self.save_plan()
|
||||
self.assertEqual(self.git("show", ":selected.txt"), "staged\n")
|
||||
|
||||
def test_stale_content_index_and_origin_block_commit(self):
|
||||
for change in ("content", "index", "origin"):
|
||||
with self.subTest(change=change):
|
||||
self.write("selected.txt", f"selected {change}\n")
|
||||
self.save_plan()
|
||||
if change == "content":
|
||||
self.write("selected.txt", "different content\n")
|
||||
elif change == "index":
|
||||
self.write("unrelated.txt", "changed index\n")
|
||||
self.git("add", "--", "unrelated.txt")
|
||||
else:
|
||||
self.git("remote", "add", "origin", str(self.root / "other.git"))
|
||||
with self.assertRaisesRegex(ValueError, "changed; prepare a new plan"):
|
||||
maintenance.commit(self.args)
|
||||
self.assertEqual(self.git("rev-parse", "HEAD").strip(), self.base)
|
||||
|
||||
def test_active_hooks_and_filters_are_refused_not_bypassed(self):
|
||||
self.write("selected.txt", "changed\n")
|
||||
hook = self.repo / ".git/hooks/pre-commit"
|
||||
hook.write_text("#!/bin/sh\nexit 99\n")
|
||||
hook.chmod(0o700)
|
||||
with self.assertRaisesRegex(ValueError, "Active Git hooks"):
|
||||
self.save_plan()
|
||||
hook.unlink()
|
||||
self.git("config", "filter.example.clean", "some-external-command")
|
||||
self.write(".gitattributes", "*.txt filter=example\n")
|
||||
with self.assertRaisesRegex(ValueError, "Active Git filter"):
|
||||
self.save_plan()
|
||||
|
||||
def test_directory_traversal_and_symlinks_are_refused(self):
|
||||
self.write("selected.txt", "changed\n")
|
||||
(self.repo / "linked.txt").symlink_to(self.repo / "selected.txt")
|
||||
for name in (".", "../outside", ".git/config", "linked.txt"):
|
||||
with self.subTest(name=name):
|
||||
self.args.path = [name]
|
||||
with self.assertRaises(ValueError):
|
||||
self.save_plan()
|
||||
|
||||
def test_post_index_change_hook_is_refused_before_it_can_run(self):
|
||||
self.write("selected.txt", "changed\n")
|
||||
marker = self.root / "hook-executed"
|
||||
hook = self.repo / ".git/hooks/post-index-change"
|
||||
hook.write_text(f"#!/bin/sh\ntouch '{marker}'\n")
|
||||
hook.chmod(0o700)
|
||||
with self.assertRaisesRegex(ValueError, "Active Git hooks"):
|
||||
self.save_plan()
|
||||
self.assertFalse(marker.exists())
|
||||
|
||||
def test_remote_helpers_and_recursive_operations_are_refused(self):
|
||||
self.write("selected.txt", "changed\n")
|
||||
for setting in (
|
||||
"remote.origin.receivepack",
|
||||
"remote.origin.uploadpack",
|
||||
"remote.origin.vcs",
|
||||
"core.gitProxy",
|
||||
"core.alternateRefsCommand",
|
||||
"push.recurseSubmodules",
|
||||
"submodule.recurse",
|
||||
"remote.origin.promisor",
|
||||
):
|
||||
with self.subTest(setting=setting):
|
||||
self.git("config", setting, "true")
|
||||
with self.assertRaises(ValueError):
|
||||
self.save_plan()
|
||||
self.git("config", "--unset", setting)
|
||||
self.assertEqual(self.git("rev-parse", "HEAD").strip(), self.base)
|
||||
|
||||
def test_git_namespace_identity_and_repository_overrides_are_refused(self):
|
||||
self.write("selected.txt", "changed\n")
|
||||
for key in (
|
||||
"GIT_NAMESPACE",
|
||||
"GIT_COMMON_DIR",
|
||||
"GIT_QUARANTINE_PATH",
|
||||
"GIT_SHALLOW_FILE",
|
||||
"GIT_REPLACE_REF_BASE",
|
||||
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||
"GIT_AUTHOR_NAME",
|
||||
"GIT_CONFIG_COUNT",
|
||||
"GIT_UNKNOWN_OVERRIDE",
|
||||
):
|
||||
with self.subTest(key=key), patch.dict(os.environ, {key: "unexpected"}):
|
||||
with self.assertRaisesRegex(ValueError, "environment overrides"):
|
||||
self.save_plan()
|
||||
self.assertFalse((self.root / "state").exists())
|
||||
|
||||
def test_noncanonical_paths_are_rejected_before_commit(self):
|
||||
(self.repo / "dir").mkdir()
|
||||
self.write("dir/file.txt", "new\n")
|
||||
self.args.path = ["dir//file.txt"]
|
||||
with self.assertRaisesRegex(ValueError, "explicit relative"):
|
||||
self.save_plan()
|
||||
self.assertEqual(self.git("rev-parse", "HEAD").strip(), self.base)
|
||||
|
||||
def test_replacement_history_is_not_treated_as_the_real_frozen_tree(self):
|
||||
self.write("selected.txt", "changed\n")
|
||||
tree = self.git("rev-parse", "HEAD^{tree}").strip()
|
||||
replacement = self.git("commit-tree", tree, "-m", "replacement fixture").strip()
|
||||
self.git("replace", self.base, replacement)
|
||||
with self.assertRaisesRegex(ValueError, "replacement/graft"):
|
||||
self.save_plan()
|
||||
|
||||
def test_normal_push_is_explicit_and_verified_against_a_local_bare_fixture(self):
|
||||
remote = self.root / "origin.git"
|
||||
subprocess.run(
|
||||
["git", "init", "--bare", str(remote)], check=True, capture_output=True
|
||||
)
|
||||
self.git("remote", "add", "origin", str(remote))
|
||||
self.write("selected.txt", "selected final\n")
|
||||
frozen = self.save_plan()
|
||||
self.assertNotIn(str(remote), json.dumps(frozen))
|
||||
result = maintenance.commit(self.args)
|
||||
self.args.apply = False
|
||||
self.assertEqual(maintenance.push(self.args)["state"]["status"], "preview")
|
||||
self.assertEqual(
|
||||
self.git("ls-remote", "--refs", "origin", "refs/heads/main"), ""
|
||||
)
|
||||
self.args.apply = True
|
||||
pushed = maintenance.push(self.args)
|
||||
self.assertEqual(pushed["state"]["status"], "pushed")
|
||||
self.assertTrue(
|
||||
self.git("ls-remote", "--refs", "origin", "refs/heads/main").startswith(
|
||||
result["state"]["commit"]
|
||||
)
|
||||
)
|
||||
|
||||
def test_interrupted_commit_is_reconciled_without_creating_another_commit(self):
|
||||
self.write("selected.txt", "selected final\n")
|
||||
self.save_plan()
|
||||
original = maintenance._write_state
|
||||
|
||||
def fail_receipt(directory, state, **updates):
|
||||
if updates.get("status") == "committed":
|
||||
raise OSError("fixture interruption after commit")
|
||||
return original(directory, state, **updates)
|
||||
|
||||
with patch.object(maintenance, "_write_state", side_effect=fail_receipt):
|
||||
with self.assertRaises(OSError):
|
||||
maintenance.commit(self.args)
|
||||
head = self.git("rev-parse", "HEAD").strip()
|
||||
self.assertNotEqual(head, self.base)
|
||||
with self.assertRaisesRegex(ValueError, "reconcile"):
|
||||
maintenance.commit(self.args)
|
||||
result = maintenance.reconcile(self.args)
|
||||
self.assertEqual(result["state"]["status"], "committed")
|
||||
self.assertEqual(self.git("rev-parse", "HEAD").strip(), head)
|
||||
|
||||
def test_lost_push_receipt_is_reconciled_without_repeating_push(self):
|
||||
remote = self.root / "origin.git"
|
||||
subprocess.run(
|
||||
["git", "init", "--bare", str(remote)], check=True, capture_output=True
|
||||
)
|
||||
self.git("remote", "add", "origin", str(remote))
|
||||
self.write("selected.txt", "selected final\n")
|
||||
self.save_plan()
|
||||
maintenance.commit(self.args)
|
||||
original = maintenance._git
|
||||
pushes = []
|
||||
|
||||
def lose_response(repo, *argv, **kwargs):
|
||||
result = original(repo, *argv, **kwargs)
|
||||
if argv[0] == "push":
|
||||
pushes.append(argv)
|
||||
raise ValueError("fixture response lost after remote update")
|
||||
return result
|
||||
|
||||
with patch.object(maintenance, "_git", side_effect=lose_response):
|
||||
with self.assertRaises(ValueError):
|
||||
maintenance.push(self.args)
|
||||
with self.assertRaisesRegex(ValueError, "reconcile"):
|
||||
maintenance.push(self.args)
|
||||
reconciled = maintenance.reconcile(self.args)
|
||||
self.assertEqual(reconciled["state"]["status"], "pushed")
|
||||
self.assertEqual(len(pushes), 1)
|
||||
|
||||
def test_reconciliation_rechecks_state_after_acquiring_lock(self):
|
||||
self.write("selected.txt", "selected final\n")
|
||||
self.save_plan()
|
||||
maintenance.commit(self.args)
|
||||
payload, state, directory = maintenance._read(self.args, self.args.plan_id)
|
||||
maintenance._write_state(
|
||||
directory, state, status="needs_reconcile", operation="commit"
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def another_reconciliation(_args, _repo):
|
||||
maintenance._write_state(directory, state, status="committed")
|
||||
yield
|
||||
|
||||
with patch.object(maintenance, "_locked", another_reconciliation):
|
||||
result = maintenance.reconcile(self.args)
|
||||
self.assertEqual(result["state"]["status"], "committed")
|
||||
self.assertTrue(
|
||||
any("no replay or downgrade" in line for line in result["summary"])
|
||||
)
|
||||
|
||||
def test_worktree_edit_racing_with_commit_remains_uncommitted(self):
|
||||
self.write("selected.txt", "approved bytes\n")
|
||||
self.save_plan()
|
||||
original = maintenance._git
|
||||
|
||||
def race(repo, *argv, **kwargs):
|
||||
if argv[0] == "commit-tree":
|
||||
self.write("selected.txt", "new editor bytes\n")
|
||||
return original(repo, *argv, **kwargs)
|
||||
|
||||
with patch.object(maintenance, "_git", side_effect=race):
|
||||
result = maintenance.commit(self.args)
|
||||
self.assertEqual(result["state"]["status"], "committed")
|
||||
self.assertEqual(self.git("show", "HEAD:selected.txt"), "approved bytes\n")
|
||||
self.assertEqual(self.git("show", ":selected.txt"), "approved bytes\n")
|
||||
self.assertEqual((self.repo / "selected.txt").read_text(), "new editor bytes\n")
|
||||
|
||||
def test_branch_compare_and_swap_never_overwrites_a_racing_commit(self):
|
||||
self.write("selected.txt", "approved bytes\n")
|
||||
self.save_plan()
|
||||
original = maintenance._git
|
||||
raced = []
|
||||
|
||||
def race(repo, *argv, **kwargs):
|
||||
if argv[0] == "update-ref" and not raced:
|
||||
tree = self.git("rev-parse", "HEAD^{tree}").strip()
|
||||
other = self.git(
|
||||
"commit-tree", tree, "-p", self.base, "-m", "other writer"
|
||||
).strip()
|
||||
self.git("update-ref", "refs/heads/main", other, self.base)
|
||||
raced.append(other)
|
||||
return original(repo, *argv, **kwargs)
|
||||
|
||||
with patch.object(maintenance, "_git", side_effect=race):
|
||||
with self.assertRaises(ValueError):
|
||||
maintenance.commit(self.args)
|
||||
self.assertEqual(self.git("rev-parse", "HEAD").strip(), raced[0])
|
||||
self.assertEqual(self.git("show", ":selected.txt"), "base selected\n")
|
||||
|
||||
def test_interrupted_index_publication_reconciles_without_another_commit(self):
|
||||
self.write("selected.txt", "approved bytes\n")
|
||||
self.save_plan()
|
||||
with patch.object(
|
||||
maintenance, "_publish_index", side_effect=OSError("fixture interruption")
|
||||
):
|
||||
with self.assertRaises(OSError):
|
||||
maintenance.commit(self.args)
|
||||
head = self.git("rev-parse", "HEAD").strip()
|
||||
self.assertNotEqual(head, self.base)
|
||||
self.assertEqual(self.git("show", ":selected.txt"), "base selected\n")
|
||||
result = maintenance.reconcile(self.args)
|
||||
self.assertEqual(result["state"]["status"], "committed")
|
||||
self.assertEqual(self.git("show", ":selected.txt"), "approved bytes\n")
|
||||
self.assertEqual(self.git("rev-parse", "HEAD").strip(), head)
|
||||
|
||||
def test_concurrent_index_changes_are_preserved_instead_of_overwritten(self):
|
||||
self.write("selected.txt", "approved bytes\n")
|
||||
self.save_plan()
|
||||
original = maintenance._publish_index
|
||||
|
||||
def race(repo, locked, prepared, expected):
|
||||
# Model an external writer that does not honor Git's index.lock.
|
||||
outside = self.root / "outside.index"
|
||||
outside.write_bytes((self.repo / ".git/index").read_bytes())
|
||||
self.write("unrelated.txt", "new independent staging\n")
|
||||
subprocess.run(
|
||||
["git", "-C", str(self.repo), "add", "--", "unrelated.txt"],
|
||||
env={**os.environ, "GIT_INDEX_FILE": str(outside)},
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
(self.repo / ".git/index").write_bytes(outside.read_bytes())
|
||||
return original(repo, locked, prepared, expected)
|
||||
|
||||
with patch.object(maintenance, "_publish_index", side_effect=race):
|
||||
with self.assertRaisesRegex(ValueError, "index changed concurrently"):
|
||||
maintenance.commit(self.args)
|
||||
self.assertEqual(
|
||||
self.git("show", ":unrelated.txt"), "new independent staging\n"
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
maintenance.reconcile(self.args)
|
||||
self.assertEqual(
|
||||
self.git("show", ":unrelated.txt"), "new independent staging\n"
|
||||
)
|
||||
|
||||
def test_selected_fifo_swap_cannot_block_capture(self):
|
||||
self.write("selected.txt", "approved bytes\n")
|
||||
self.save_plan()
|
||||
original = maintenance._capture_blob
|
||||
|
||||
def race(repo, item):
|
||||
(self.repo / "selected.txt").unlink()
|
||||
os.mkfifo(self.repo / "selected.txt")
|
||||
return original(repo, item)
|
||||
|
||||
started = time.monotonic()
|
||||
with patch.object(maintenance, "_capture_blob", side_effect=race):
|
||||
with self.assertRaises(ValueError):
|
||||
maintenance.commit(self.args)
|
||||
self.assertLess(time.monotonic() - started, 3)
|
||||
self.assertEqual(self.git("rev-parse", "HEAD").strip(), self.base)
|
||||
|
||||
def test_reconcile_never_adopts_a_head_that_races_with_index_preparation(self):
|
||||
self.write("selected.txt", "approved bytes\n")
|
||||
self.save_plan()
|
||||
with patch.object(
|
||||
maintenance, "_publish_index", side_effect=OSError("fixture interruption")
|
||||
):
|
||||
with self.assertRaises(OSError):
|
||||
maintenance.commit(self.args)
|
||||
candidate = self.git("rev-parse", "HEAD").strip()
|
||||
original = maintenance._selected_index
|
||||
raced = []
|
||||
|
||||
def race(repo, source, target, files):
|
||||
result = original(repo, source, target, files)
|
||||
tree = self.git("rev-parse", f"{self.base}^{{tree}}").strip()
|
||||
other = self.git(
|
||||
"commit-tree", tree, "-p", candidate, "-m", "independent writer"
|
||||
).strip()
|
||||
self.git("update-ref", "refs/heads/main", other, candidate)
|
||||
raced.append(other)
|
||||
return result
|
||||
|
||||
with patch.object(maintenance, "_selected_index", side_effect=race):
|
||||
with self.assertRaisesRegex(
|
||||
ValueError, "changed during index reconciliation"
|
||||
):
|
||||
maintenance.reconcile(self.args)
|
||||
recorded = maintenance.status(self.args)["state"]
|
||||
self.assertEqual(recorded["status"], "needs_reconcile")
|
||||
self.assertEqual(recorded["commit"], candidate)
|
||||
self.assertNotEqual(recorded["commit"], raced[0])
|
||||
self.assertEqual(self.git("show", ":selected.txt"), "base selected\n")
|
||||
|
||||
def test_final_commit_receipt_cannot_bind_to_a_later_head(self):
|
||||
self.write("selected.txt", "approved bytes\n")
|
||||
self.save_plan()
|
||||
original = maintenance._publish_index
|
||||
raced = []
|
||||
|
||||
def race(repo, locked, prepared, expected):
|
||||
original(repo, locked, prepared, expected)
|
||||
candidate = self.git("rev-parse", "HEAD").strip()
|
||||
tree = self.git("rev-parse", f"{self.base}^{{tree}}").strip()
|
||||
other = self.git(
|
||||
"commit-tree", tree, "-p", candidate, "-m", "independent writer"
|
||||
).strip()
|
||||
self.git("update-ref", "refs/heads/main", other, candidate)
|
||||
raced.append(other)
|
||||
|
||||
with patch.object(maintenance, "_publish_index", side_effect=race):
|
||||
with self.assertRaisesRegex(
|
||||
ValueError, "changed before the exact commit result"
|
||||
):
|
||||
maintenance.commit(self.args)
|
||||
recorded = maintenance.status(self.args)["state"]
|
||||
self.assertEqual(recorded["status"], "needs_reconcile")
|
||||
self.assertNotEqual(recorded["commit"], raced[0])
|
||||
|
||||
def test_git_output_is_bounded_during_execution(self):
|
||||
executable = self.root / "git"
|
||||
executable.write_text(f"#!{sys.executable}\nprint('x' * 100000)\n")
|
||||
executable.chmod(0o700)
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ, {"PATH": str(self.root) + os.pathsep + os.environ["PATH"]}
|
||||
),
|
||||
patch.object(maintenance, "MAX_GIT_OUTPUT_BYTES", 256),
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "output_limit"):
|
||||
maintenance._git(self.repo, "fixture")
|
||||
|
||||
def test_timeout_terminates_git_helper_process_group(self):
|
||||
executable = self.root / "git"
|
||||
marker = self.root / "helper-ran"
|
||||
child = (
|
||||
"import signal,time,pathlib; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(1); pathlib.Path("
|
||||
+ repr(str(marker))
|
||||
+ ").write_text('unexpected')"
|
||||
)
|
||||
executable.write_text(
|
||||
f"#!{sys.executable}\nimport subprocess,sys,time\nsubprocess.Popen([sys.executable, '-c', {child!r}])\ntime.sleep(20)\n"
|
||||
)
|
||||
executable.chmod(0o700)
|
||||
with patch.dict(
|
||||
os.environ, {"PATH": str(self.root) + os.pathsep + os.environ["PATH"]}
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "timed_out"):
|
||||
maintenance._git(self.repo, "fixture", timeout=0.2)
|
||||
time.sleep(1.1)
|
||||
self.assertFalse(
|
||||
marker.exists(),
|
||||
"An orphaned helper must not finish the operation after timeout",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+319
@@ -0,0 +1,319 @@
|
||||
"""Live/provisional monitoring and discovery never substitute for verified evidence."""
|
||||
|
||||
from argparse import Namespace
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from test_devkit_runner import example as example, run, stage
|
||||
from govoplan_devkit import catalog, cli, monitoring, runner
|
||||
from govoplan_devkit.checkpoints import Checkpoints
|
||||
from govoplan_devkit.common import atomic_json, read_json, state_root
|
||||
from govoplan_devkit.process import OutputSnapshot
|
||||
|
||||
|
||||
def command(args, *argv):
|
||||
parser = argparse.ArgumentParser()
|
||||
runner.register(parser.add_subparsers(dest="command", required=True))
|
||||
selected = parser.parse_args(argv, namespace=Namespace(**vars(args)))
|
||||
return selected.handler(selected)
|
||||
|
||||
|
||||
def test_preparing_receipt_and_run_id_exist_before_source_fingerprinting(example):
|
||||
args, repo = example
|
||||
events = []
|
||||
args.on_progress = events.append
|
||||
original = Checkpoints.source
|
||||
|
||||
def fingerprint(self, *values, **kwargs):
|
||||
assert events and events[0]["phase"] == "preparing"
|
||||
first = events[0]
|
||||
assert Path(first["receipt_path"]).is_file()
|
||||
record = runner.read_receipt(
|
||||
args.workspace_root, args.state_dir, first["run_id"]
|
||||
)
|
||||
if record["phase"] == "preparing":
|
||||
assert record["status"] == "running"
|
||||
assert record["snapshot_verified"] is False
|
||||
assert record["source_fingerprint"] is None
|
||||
return original(self, *values, **kwargs)
|
||||
|
||||
with patch.object(Checkpoints, "source", fingerprint):
|
||||
result = run(args, [stage(repo)])
|
||||
assert result["status"] == "passed"
|
||||
assert events[-1]["phase"] == "finished"
|
||||
assert events[-1]["run_id"] == events[0]["run_id"] == result["run_id"]
|
||||
|
||||
|
||||
def test_preflight_failure_persists_discoverable_nonpassing_receipt(example):
|
||||
args, repo = example
|
||||
events = []
|
||||
args.on_progress = events.append
|
||||
with patch.object(
|
||||
Checkpoints,
|
||||
"source",
|
||||
side_effect=ValueError("fixture fingerprint unavailable"),
|
||||
):
|
||||
with pytest.raises(ValueError, match="fixture fingerprint unavailable"):
|
||||
run(args, [stage(repo)])
|
||||
assert events[0]["phase"] == "preparing"
|
||||
record = runner.read_receipt(
|
||||
args.workspace_root, args.state_dir, events[0]["run_id"]
|
||||
)
|
||||
assert record["status"] == "failed"
|
||||
assert record["phase"] == "finished"
|
||||
assert record["snapshot_verified"] is False
|
||||
assert all(item["status"] != "passed" for item in record["stages"])
|
||||
assert "fixture fingerprint unavailable" in record["error"]
|
||||
assert monitoring.latest_run(args)["status"] == "failed"
|
||||
|
||||
|
||||
def test_run_history_is_read_only_and_cursor_pagination_has_no_duplicates(example):
|
||||
args, repo = example
|
||||
created = [run(args, [stage(repo)])["run_id"] for _ in range(3)]
|
||||
base = state_root(args.workspace_root, args.state_dir)
|
||||
before = {
|
||||
path: (path.stat().st_mtime_ns, path.read_bytes())
|
||||
for path in base.rglob("*")
|
||||
if path.is_file()
|
||||
}
|
||||
page = monitoring.list_runs(Namespace(**vars(args), limit=2, before=None))
|
||||
assert [item["run_id"] for item in page["runs"]] == sorted(created, reverse=True)[
|
||||
:2
|
||||
]
|
||||
assert page["next_cursor"]
|
||||
next_page = monitoring.list_runs(
|
||||
Namespace(**vars(args), limit=2, before=page["next_cursor"])
|
||||
)
|
||||
assert [item["run_id"] for item in next_page["runs"]] == sorted(
|
||||
created, reverse=True
|
||||
)[2:]
|
||||
assert next_page["next_cursor"] is None
|
||||
assert monitoring.latest_run(args)["run_id"] == max(created)
|
||||
assert before == {
|
||||
path: (path.stat().st_mtime_ns, path.read_bytes())
|
||||
for path in base.rglob("*")
|
||||
if path.is_file()
|
||||
}
|
||||
|
||||
|
||||
def test_empty_run_history_does_not_create_a_state_directory(example):
|
||||
args, _ = example
|
||||
assert not args.state_dir.exists()
|
||||
result = monitoring.list_runs(args)
|
||||
assert result["runs"] == []
|
||||
assert monitoring.latest_run(args)["status"] == "not_found"
|
||||
assert not args.state_dir.exists()
|
||||
|
||||
|
||||
def test_invalid_newest_run_is_visible_and_never_replaced_with_older_pass(example):
|
||||
args, repo = example
|
||||
older = run(args, [stage(repo)])["run_id"]
|
||||
invalid = "zz-invalid-newest"
|
||||
path = (
|
||||
state_root(args.workspace_root, args.state_dir)
|
||||
/ "runs"
|
||||
/ invalid
|
||||
/ "receipt.json"
|
||||
)
|
||||
atomic_json(path, {"malformed": True})
|
||||
rows = monitoring.list_runs(args)
|
||||
assert rows["runs"][0]["run_id"] == invalid
|
||||
assert rows["runs"][0]["status"] == "invalid"
|
||||
assert any(row["run_id"] == older for row in rows["runs"])
|
||||
assert rows["_exit_code"] == 1
|
||||
with pytest.raises(ValueError, match="Latest run is invalid"):
|
||||
monitoring.latest_run(args)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"changes", [{"limit": 0}, {"limit": 101}, {"limit": True}, {"before": "../escape"}]
|
||||
)
|
||||
def test_history_rejects_invalid_bounds_and_cursor(example, changes):
|
||||
args, _ = example
|
||||
with pytest.raises(ValueError):
|
||||
monitoring.list_runs(Namespace(**{**vars(args), **changes}))
|
||||
|
||||
|
||||
def test_live_logs_are_available_before_completion_but_final_only_refuses_them(example):
|
||||
args, repo = example
|
||||
events, results, errors = [], [], []
|
||||
args.on_progress = events.append
|
||||
|
||||
def execute():
|
||||
try:
|
||||
results.append(
|
||||
run(
|
||||
args,
|
||||
[
|
||||
stage(
|
||||
repo,
|
||||
code="import time; print('live ready',flush=True); time.sleep(1.5); print('finished')",
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
except BaseException as exc:
|
||||
errors.append(exc)
|
||||
|
||||
worker = threading.Thread(target=execute)
|
||||
worker.start()
|
||||
try:
|
||||
deadline = time.monotonic() + 4
|
||||
live = None
|
||||
while time.monotonic() < deadline:
|
||||
if events:
|
||||
live = command(args, "logs", events[0]["run_id"], "--stage", "one")
|
||||
if "live ready" in live["excerpt"]:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert live is not None and "live ready" in live["excerpt"]
|
||||
assert live["provisional"] is True
|
||||
assert live["log_verified"] is False
|
||||
assert live["snapshot_verified"] is False
|
||||
assert live["run_status"] == "running"
|
||||
with pytest.raises(ValueError, match="No finalized stage log"):
|
||||
command(args, "logs", events[0]["run_id"], "--stage", "one", "--final-only")
|
||||
finally:
|
||||
worker.join(timeout=5)
|
||||
assert not worker.is_alive() and not errors
|
||||
final = command(
|
||||
args, "logs", results[0]["run_id"], "--stage", "one", "--final-only"
|
||||
)
|
||||
assert final["provisional"] is False and final["log_verified"] is True
|
||||
assert final["snapshot_verified"] is True
|
||||
assert "finished" in final["excerpt"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("quiet", [False, True])
|
||||
def test_cli_keeps_json_stdout_clean_and_progress_on_stderr_or_quiet(
|
||||
tmp_path, capsys, quiet
|
||||
):
|
||||
event = {
|
||||
"event": "check_progress",
|
||||
"run_id": "fixture",
|
||||
"phase": "preparing",
|
||||
"status": "running",
|
||||
"counts": {"pending": 1},
|
||||
"total_stages": 1,
|
||||
"elapsed_seconds": 0,
|
||||
"active_stages": [],
|
||||
"receipt_path": str(tmp_path / "receipt.json"),
|
||||
}
|
||||
|
||||
def check(args, _stages):
|
||||
args.on_progress(event)
|
||||
return {"status": "passed", "summary": ["fixture complete"]}
|
||||
|
||||
with (
|
||||
patch.object(catalog, "build_stages", return_value=[]),
|
||||
patch.object(runner, "run_checks", side_effect=check),
|
||||
):
|
||||
assert (
|
||||
cli.main(
|
||||
[
|
||||
"check",
|
||||
"--workspace-root",
|
||||
str(tmp_path),
|
||||
"--json",
|
||||
*(["--quiet"] if quiet else []),
|
||||
]
|
||||
)
|
||||
== 0
|
||||
)
|
||||
output = capsys.readouterr()
|
||||
assert json.loads(output.out)["status"] == "passed"
|
||||
if quiet:
|
||||
assert output.err == ""
|
||||
else:
|
||||
assert json.loads(output.err)["event"] == "check_progress"
|
||||
|
||||
|
||||
def snapshot(data, *, split=0, omitted=0, final=False):
|
||||
return OutputSnapshot(data, b"", bool(omitted), omitted, 0, split, 0, final)
|
||||
|
||||
|
||||
def test_provisional_output_withholds_incomplete_secret_line(monkeypatch):
|
||||
monkeypatch.setenv("FIXTURE_SECRET", "credential-known-to-redaction")
|
||||
data = b"public line\nAPI_KEY=credential-known-to-"
|
||||
live = monitoring.capture_text(snapshot(data), provisional=True)
|
||||
assert live == "public line\n"
|
||||
final = monitoring.capture_text(
|
||||
snapshot(b"public line\nAPI_KEY=credential-known-to-redaction", final=True)
|
||||
)
|
||||
assert "credential-known" not in final
|
||||
assert "[redacted]" in final
|
||||
|
||||
|
||||
def test_truncated_boundaries_cannot_expose_cut_authorization_or_secret_fragments():
|
||||
head = b"safe head\nAuthorization: Bearer partial-secret-head"
|
||||
tail = b"partial-secret-tail\nsafe final line\n"
|
||||
for provisional in (False, True):
|
||||
text = monitoring.capture_text(
|
||||
snapshot(head + tail, split=len(head), omitted=90), provisional=provisional
|
||||
)
|
||||
assert "safe head" in text and "safe final line" in text
|
||||
assert "partial-secret" not in text
|
||||
assert "90 bytes omitted" in text
|
||||
|
||||
|
||||
def test_final_failure_tail_survives_retention_and_stays_redacted(example, monkeypatch):
|
||||
args, repo = example
|
||||
monkeypatch.setenv("FIXTURE_SECRET", "sensitive-final-credential")
|
||||
with patch.object(runner, "MAX_LOG_BYTES", 512):
|
||||
result = run(
|
||||
args,
|
||||
[
|
||||
stage(
|
||||
repo,
|
||||
code="import os; print('begin'); print('x'*20000); print(os.environ['FIXTURE_SECRET']); print('FINAL IMPORTANT ERROR'); raise SystemExit(9)",
|
||||
)
|
||||
],
|
||||
)
|
||||
final = command(
|
||||
args, "logs", result["run_id"], "--stage", "one", "--final-only"
|
||||
)
|
||||
assert result["status"] == "failed"
|
||||
assert final["log_verified"] is True
|
||||
assert "FINAL IMPORTANT ERROR" in final["excerpt"]
|
||||
assert "sensitive-final-credential" not in final["excerpt"]
|
||||
assert "[redacted]" in final["excerpt"]
|
||||
assert result["stages"][0]["omitted_output_bytes"] > 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("maximum", [1, 2, 3, 7, 75, 76, 77, 80, 81, 82, 255])
|
||||
@pytest.mark.parametrize("tail_only", [False, True])
|
||||
def test_tiny_display_bounds_preserve_valid_utf8_and_byte_limit(maximum, tail_only):
|
||||
value = "Ä😊 " * 200 + "FINAL"
|
||||
text = monitoring.bounded_display(value, maximum, tail_only=tail_only)
|
||||
assert len(text.encode("utf-8", errors="strict")) <= maximum
|
||||
assert text.endswith("L")
|
||||
if maximum >= 5 and (tail_only or maximum >= 255):
|
||||
assert text.endswith("FINAL")
|
||||
|
||||
|
||||
def test_live_record_is_bounded_provisional_and_rejects_mismatched_identity(example):
|
||||
args, _ = example
|
||||
run_id, stage_id = "fixture-run", "fixture-stage"
|
||||
path = (
|
||||
state_root(args.workspace_root, args.state_dir)
|
||||
/ "runs"
|
||||
/ run_id
|
||||
/ (stage_id + ".log")
|
||||
)
|
||||
monitoring.write_live(path, stage_id, snapshot(b"line\n"), time.monotonic())
|
||||
live = monitoring.read_live(args.workspace_root, args.state_dir, run_id, stage_id)
|
||||
assert live["provisional"] is True and live["excerpt"] == "line\n"
|
||||
record = read_json(path.with_suffix(".live.json"))
|
||||
record["run_id"] = "different-run"
|
||||
atomic_json(path.with_suffix(".live.json"), record)
|
||||
with pytest.raises(ValueError, match="Invalid provisional log"):
|
||||
monitoring.read_live(args.workspace_root, args.state_dir, run_id, stage_id)
|
||||
Executable
+249
@@ -0,0 +1,249 @@
|
||||
"""Bounded fixture processes only; no project servers or external transports."""
|
||||
|
||||
from pathlib import Path
|
||||
from dataclasses import FrozenInstanceError
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit"))
|
||||
from govoplan_devkit.process import _OutputBuffer, require_capture, run_captured
|
||||
|
||||
|
||||
def python(code, **kwargs):
|
||||
return run_captured([sys.executable, "-c", code], **kwargs)
|
||||
|
||||
|
||||
def test_large_stdin_and_separate_outputs_are_multiplexed():
|
||||
result = python(
|
||||
"import sys; sys.stderr.write('diagnostic'); print(len(sys.stdin.buffer.read()))",
|
||||
input_bytes=b"x" * 400000,
|
||||
)
|
||||
assert result.status == "passed"
|
||||
assert result.stdout == b"400000\n"
|
||||
assert result.stderr == b"diagnostic"
|
||||
|
||||
|
||||
def test_live_output_limit_terminates_unbounded_producer():
|
||||
result = python(
|
||||
"import os;\nwhile True: os.write(1, b'x'*65536)", max_stdout=2048, timeout=3
|
||||
)
|
||||
assert result.status == "output_limit"
|
||||
assert result.truncated
|
||||
assert len(result.stdout) == 2048
|
||||
|
||||
|
||||
def test_drain_mode_caps_memory_without_turning_success_into_failure():
|
||||
result = python("print('x'*100000)", max_stdout=1024, terminate_on_limit=False)
|
||||
assert result.status == "passed"
|
||||
assert result.truncated and len(result.stdout) == 1024
|
||||
|
||||
|
||||
def test_deadline_applies_after_command_closes_both_output_streams():
|
||||
started = time.monotonic()
|
||||
result = python(
|
||||
"import os,time; os.close(1); os.close(2); time.sleep(30)", timeout=0.15
|
||||
)
|
||||
assert result.status == "timed_out"
|
||||
assert time.monotonic() - started < 5
|
||||
|
||||
|
||||
def test_cancellation_stops_owned_process():
|
||||
cancelled = threading.Event()
|
||||
timer = threading.Timer(0.1, cancelled.set)
|
||||
timer.start()
|
||||
try:
|
||||
result = python("import time; time.sleep(30)", cancelled=cancelled)
|
||||
finally:
|
||||
timer.cancel()
|
||||
timer.join()
|
||||
assert result.status == "interrupted"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("redirected", [False, True])
|
||||
def test_outliving_child_is_not_success_and_cannot_keep_running(redirected):
|
||||
code = (
|
||||
"import subprocess,sys; child=subprocess.Popen([sys.executable,'-c','import time; time.sleep(30)']"
|
||||
+ (",stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL" if redirected else "")
|
||||
+ "); print(child.pid,flush=True)"
|
||||
)
|
||||
result = python(code, timeout=4)
|
||||
assert result.status == "leaked_process"
|
||||
child_pid = int(result.stdout.strip())
|
||||
# A terminated adopted child may remain a zombie until the host's init reaps it.
|
||||
proc = Path(f"/proc/{child_pid}/stat")
|
||||
if proc.exists():
|
||||
assert proc.read_text().split(")", 1)[1].strip().split()[0] == "Z"
|
||||
|
||||
|
||||
def test_required_capture_rejects_nonordinary_completion():
|
||||
with pytest.raises(ValueError, match="output_limit"):
|
||||
require_capture([sys.executable, "-c", "print('x'*10000)"], max_stdout=32)
|
||||
|
||||
|
||||
def test_ordinary_nonzero_exit_retains_diagnostics():
|
||||
result = python("import sys; print('problem',file=sys.stderr); sys.exit(7)")
|
||||
assert (result.status, result.returncode, result.stderr) == (
|
||||
"failed",
|
||||
7,
|
||||
b"problem\n",
|
||||
)
|
||||
|
||||
|
||||
def test_selector_setup_failure_still_terminates_spawned_process():
|
||||
from govoplan_devkit import process
|
||||
|
||||
original = process.subprocess.Popen
|
||||
spawned = []
|
||||
|
||||
def capture(*args, **kwargs):
|
||||
child = original(*args, **kwargs)
|
||||
spawned.append(child)
|
||||
return child
|
||||
|
||||
with (
|
||||
patch.object(process.subprocess, "Popen", side_effect=capture),
|
||||
patch.object(
|
||||
process.selectors,
|
||||
"DefaultSelector",
|
||||
side_effect=OSError("fixture selector unavailable"),
|
||||
),
|
||||
):
|
||||
with pytest.raises(OSError, match="selector unavailable"):
|
||||
python("import time; time.sleep(30)")
|
||||
assert len(spawned) == 1 and spawned[0].poll() is not None
|
||||
|
||||
|
||||
def test_head_tail_keeps_the_actual_failure_tail_within_original_bound():
|
||||
output = b"BEGIN" + b"middle" * 100 + b"FINAL ERROR"
|
||||
result = python(
|
||||
f"import os; os.write(1, {output!r}); raise SystemExit(7)",
|
||||
max_stdout=32,
|
||||
capture_mode="head_tail",
|
||||
terminate_on_limit=False,
|
||||
)
|
||||
assert (result.status, result.returncode) == ("failed", 7)
|
||||
assert result.stdout == output[:16] + output[-16:]
|
||||
assert result.stdout_head_bytes == 16
|
||||
assert result.omitted_stdout_bytes == len(output) - 32
|
||||
assert result.snapshot().final
|
||||
assert "FINAL ERROR" in result.snapshot().text()
|
||||
assert str(len(output) - 32) + " output bytes omitted" in result.snapshot().text()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("limit", [0, 1, 2, 3, 31, 1024])
|
||||
def test_rolling_buffers_remain_bounded_for_every_chunk(limit):
|
||||
buffer = _OutputBuffer(limit, "head_tail")
|
||||
original = b""
|
||||
for data in (b"a", b"bcdef", b"x" * 65536, b"last error"):
|
||||
original += data
|
||||
buffer.append(data)
|
||||
assert len(buffer.head) + len(buffer.tail) <= limit
|
||||
assert buffer.omitted == max(0, len(original) - limit)
|
||||
if len(original) > limit:
|
||||
head = limit // 2
|
||||
tail = limit - head
|
||||
expected = original[:head] + (original[-tail:] if tail else b"")
|
||||
assert buffer.value() == expected
|
||||
|
||||
|
||||
def test_prefix_capture_remains_exact_and_counts_omitted_bytes():
|
||||
result = python(
|
||||
"import os; os.write(1,b'0123456789')", max_stdout=4, terminate_on_limit=False
|
||||
)
|
||||
assert result.stdout == b"0123"
|
||||
assert result.stdout_head_bytes == 4
|
||||
assert result.omitted_stdout_bytes == 6
|
||||
|
||||
|
||||
def test_callback_exposes_bounded_immutable_initial_and_final_snapshots():
|
||||
snapshots = []
|
||||
result = python(
|
||||
"import os,time; os.write(1,b'first\\n'); time.sleep(.15); "
|
||||
"os.write(1,b'x'*10000+b'FINAL\\n'); time.sleep(.1)",
|
||||
max_stdout=32,
|
||||
capture_mode="head_tail",
|
||||
terminate_on_limit=False,
|
||||
on_output=snapshots.append,
|
||||
)
|
||||
assert not snapshots[0].final
|
||||
assert snapshots[-1] == result.snapshot()
|
||||
assert snapshots[0].stdout == b"first\n"
|
||||
assert snapshots[-1].stdout.endswith(b"FINAL\n")
|
||||
assert all(len(item.stdout) <= 32 for item in snapshots)
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
snapshots[0].final = True
|
||||
|
||||
|
||||
def test_callback_updates_dirty_output_while_child_becomes_quiet():
|
||||
snapshots = []
|
||||
python(
|
||||
"import os,time; os.write(1,b'first\\n'); time.sleep(.15); "
|
||||
"os.write(1,b'second\\n'); time.sleep(1.15)",
|
||||
on_output=lambda item: snapshots.append((time.monotonic(), item)),
|
||||
)
|
||||
assert len(snapshots) >= 3
|
||||
assert snapshots[0][1].stdout == b"first\n"
|
||||
assert not snapshots[1][1].final
|
||||
assert snapshots[1][1].stdout.endswith(b"second\n")
|
||||
assert snapshots[1][0] - snapshots[0][0] >= 0.95
|
||||
assert snapshots[-1][1].final
|
||||
|
||||
|
||||
def test_quiet_process_has_one_final_empty_snapshot():
|
||||
snapshots = []
|
||||
python("pass", on_output=snapshots.append)
|
||||
assert len(snapshots) == 1
|
||||
assert snapshots[0].final and snapshots[0].stdout == b""
|
||||
|
||||
|
||||
def test_snapshot_text_handles_cut_and_invalid_utf8_without_malformed_strings():
|
||||
result = python(
|
||||
"import os; os.write(1, 'Ä😊Z'.encode()*20+b'\\xff\\xfe')",
|
||||
max_stdout=9,
|
||||
capture_mode="head_tail",
|
||||
terminate_on_limit=False,
|
||||
)
|
||||
text = result.snapshot().text()
|
||||
text.encode("utf-8", errors="strict")
|
||||
assert "output bytes omitted" in text
|
||||
assert len(result.stdout) == 9
|
||||
with pytest.raises(ValueError, match="stream"):
|
||||
result.snapshot().text("other")
|
||||
|
||||
|
||||
def test_callback_failure_terminates_owned_process_and_propagates():
|
||||
from govoplan_devkit import process
|
||||
|
||||
original = process.subprocess.Popen
|
||||
spawned = []
|
||||
|
||||
def capture(*args, **kwargs):
|
||||
child = original(*args, **kwargs)
|
||||
spawned.append(child)
|
||||
return child
|
||||
|
||||
def fail(_snapshot):
|
||||
raise ValueError("fixture callback failed")
|
||||
|
||||
with patch.object(process.subprocess, "Popen", side_effect=capture):
|
||||
with pytest.raises(ValueError, match="fixture callback failed"):
|
||||
python(
|
||||
"import time; print('ready',flush=True); time.sleep(30)", on_output=fail
|
||||
)
|
||||
assert len(spawned) == 1 and spawned[0].poll() is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs", [{"capture_mode": "other"}, {"max_stdout": -1}, {"max_stderr": True}]
|
||||
)
|
||||
def test_invalid_capture_configuration_fails_before_starting_a_process(kwargs):
|
||||
from govoplan_devkit import process
|
||||
|
||||
with patch.object(process.subprocess, "Popen") as popen:
|
||||
with pytest.raises(ValueError):
|
||||
python("pass", **kwargs)
|
||||
popen.assert_not_called()
|
||||
Executable
+524
@@ -0,0 +1,524 @@
|
||||
"""Fixture-only devkit coverage of the real durable release API/store boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from copy import deepcopy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
for path in (ROOT / "tools/devkit", ROOT / "tools/release"):
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
from govoplan_devkit import release # noqa: E402
|
||||
from govoplan_release.release_execution import ReleaseExecutionAmbiguous, ReleaseExecutionBlocked # noqa: E402
|
||||
from govoplan_release.release_run import ReleaseRunCorrupt, ReleaseRunStore # noqa: E402
|
||||
from govoplan_release.candidate_artifact import ( # noqa: E402
|
||||
candidate_output_path, harden_private_candidate_tree, issue_candidate_receipt,
|
||||
)
|
||||
from server import app as api # noqa: E402
|
||||
|
||||
|
||||
def receipt(repo="govoplan-core", *, target_tag="v1.2.3"):
|
||||
return {
|
||||
"kind": "repository_state", "repo": repo, "head": "a" * 40,
|
||||
"branch": "main", "remote": "origin", "remote_sha256": "b" * 64,
|
||||
"worktree_clean": True, "target_tag": target_tag, "tag_object": None,
|
||||
}
|
||||
|
||||
|
||||
def plan(*, catalog=False):
|
||||
steps = [
|
||||
("core:preflight", "govoplan-core", False),
|
||||
("core:tag", "govoplan-core", True),
|
||||
] if not catalog else [
|
||||
("catalog:selective-generator", None, True),
|
||||
("catalog:validate-sign-publish", None, True),
|
||||
]
|
||||
return {
|
||||
"generated_at": "2026-09-08T00:00:00Z", "target_channel": "stable",
|
||||
"status": "attention", "units": [{"repo": "govoplan-core", "target_version": "1.2.3"}],
|
||||
"compatibility": [], "gate_findings": [], "recommended_action": {},
|
||||
"source_preflight_ready": True, "notes": [],
|
||||
"dry_run_steps": [
|
||||
{"id": identity, "repo": repo, "mutating": mutating, "title": identity,
|
||||
"detail": "Fixture-only operation.", "command": "fixture never executed",
|
||||
"cwd": "/fixture", "status": "planned"}
|
||||
for identity, repo, mutating in steps
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def namespace(workspace, state, *arguments):
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.set_defaults(workspace_root=workspace, state_dir=state, format="json", project=None)
|
||||
release.register(parser.add_subparsers(required=True))
|
||||
return parser.parse_args(["release", *arguments])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def environment(tmp_path, monkeypatch):
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
state = tmp_path / "private-state"
|
||||
plans = [plan()]
|
||||
dashboard = Mock(return_value={"summary": {"status": "ready"}, "repositories": []})
|
||||
planner = Mock(side_effect=lambda *args, **kwargs: deepcopy(plans[0]))
|
||||
execute = Mock(return_value=({"status": "inspected"}, receipt()))
|
||||
|
||||
def bind(**kwargs):
|
||||
result = kwargs["plan"]
|
||||
for step in result["dry_run_steps"]:
|
||||
if step.get("repo"):
|
||||
step["source_binding"] = receipt(step["repo"])
|
||||
elif step["id"] == "catalog:validate-sign-publish":
|
||||
step["source_binding"] = receipt("addideas-govoplan-website", target_tag="")
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(api, "build_dashboard", dashboard)
|
||||
monkeypatch.setattr(api, "build_selective_release_plan", planner)
|
||||
monkeypatch.setattr(api, "require_trusted_release_runtime", Mock())
|
||||
monkeypatch.setattr(api, "verify_release_runtime_binding", Mock())
|
||||
monkeypatch.setattr(api, "bind_plan_source_states", bind)
|
||||
monkeypatch.setattr(api, "verify_repository_preflight_binding", Mock(return_value=receipt()))
|
||||
monkeypatch.setattr(api, "verify_repository_step_precondition", Mock(return_value=receipt()))
|
||||
monkeypatch.setattr(api, "execute_repository_step", execute)
|
||||
monkeypatch.setattr(api, "default_signing_keys", lambda: ())
|
||||
|
||||
def forbidden(*args, **kwargs):
|
||||
raise AssertionError("Fixture test attempted a real subprocess or network connection")
|
||||
monkeypatch.setattr(subprocess, "run", forbidden)
|
||||
monkeypatch.setattr(socket, "create_connection", forbidden)
|
||||
|
||||
def call(*args, state_dir=state, workspace_root=workspace):
|
||||
values = namespace(workspace_root, state_dir, *args)
|
||||
return values.handler(values)
|
||||
|
||||
def create(request="devkit-create-request-0001"):
|
||||
result = call("create", "--repo-version", "govoplan-core=1.2.3", "--request-id", request, "--apply")
|
||||
assert result["_exit_code"] == 0, result
|
||||
return result
|
||||
|
||||
return SimpleNamespace(
|
||||
workspace=workspace, state=state, plans=plans, dashboard=dashboard,
|
||||
planner=planner, executor=execute, call=call, create=create,
|
||||
)
|
||||
|
||||
|
||||
def test_registration_does_not_import_heavy_dependencies():
|
||||
script = """
|
||||
import argparse, builtins, sys
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
original = builtins.__import__
|
||||
def guarded(name, *args, **kwargs):
|
||||
if name.split('.')[0] in {'httpx', 'fastapi', 'govoplan_core', 'govoplan_release'}:
|
||||
raise AssertionError('Heavy import during help registration: ' + name)
|
||||
return original(name, *args, **kwargs)
|
||||
builtins.__import__ = guarded
|
||||
from govoplan_devkit.release import register
|
||||
parser = argparse.ArgumentParser()
|
||||
register(parser.add_subparsers())
|
||||
assert 'release' in parser.format_help()
|
||||
"""
|
||||
result = subprocess.run([sys.executable, "-c", script, str(ROOT / "tools/devkit")], capture_output=True, text=True, check=False)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_plan_is_selective_offline_and_does_not_create_run(environment):
|
||||
result = environment.call("plan", "--repo-version", "govoplan-core=1.2.3")
|
||||
assert result["_exit_code"] == 0
|
||||
assert result["dry_run"] is True
|
||||
assert environment.planner.call_args.kwargs["selected_repos"] == ("govoplan-core",)
|
||||
arguments = environment.dashboard.call_args.kwargs
|
||||
assert arguments["online"] is False
|
||||
assert arguments["check_remote_tags"] is False
|
||||
assert arguments["check_public_catalog"] is False
|
||||
assert arguments["include_migrations"] is False
|
||||
assert not list(environment.state.rglob("rr-*.json"))
|
||||
assert "release-console/workspace-" in result["state_location"]
|
||||
environment.executor.assert_not_called()
|
||||
|
||||
|
||||
def test_status_reports_blocked_exit_and_explicit_check_flags(environment):
|
||||
environment.dashboard.return_value = {"summary": {"status": "blocked"}}
|
||||
result = environment.call("status", "--online", "--include-migrations", "--include-website")
|
||||
assert result["_exit_code"] == 1
|
||||
assert environment.dashboard.call_args.kwargs["check_remote_tags"] is True
|
||||
assert environment.dashboard.call_args.kwargs["check_public_catalog"] is True
|
||||
assert environment.dashboard.call_args.kwargs["include_migrations"] is True
|
||||
|
||||
|
||||
def test_plan_summary_exposes_attention_readiness_gates_and_next_action(environment):
|
||||
fixture_plan = environment.plans[0]
|
||||
fixture_plan["source_preflight_ready"] = False
|
||||
fixture_plan["units"][0]["status"] = "attention"
|
||||
fixture_plan["gate_findings"] = [{"code": "worktree_dirty", "repo": "govoplan-core", "message": "Uncommitted source changes need review."}]
|
||||
fixture_plan["recommended_action"] = {"id": "prepare_changes", "title": "Prepare source changes", "remediation": "Review and commit the selected changes before preparing the release."}
|
||||
result = environment.call("plan", "--repo-version", "govoplan-core=1.2.3")
|
||||
summary = "\n".join(result["summary"])
|
||||
assert "Release plan: attention." in summary
|
||||
assert "Source preflight ready: false." in summary
|
||||
assert "govoplan-core: attention; target 1.2.3." in summary
|
||||
assert "Gate worktree_dirty (govoplan-core)" in summary
|
||||
assert "Next: prepare_changes" in summary
|
||||
assert "no run was created" in summary
|
||||
assert "fixture never executed" not in summary
|
||||
|
||||
|
||||
def test_summary_is_bounded_and_redacts_display_only_fields(monkeypatch):
|
||||
monkeypatch.setenv("DEVKIT_FIXTURE_SECRET", "fixture-value-not-for-display")
|
||||
fixture_plan = plan()
|
||||
fixture_plan["gate_findings"] = [{"code": "fixture", "message": "fixture-value-not-for-display " + "x" * 1000}] * 20
|
||||
fixture_plan["units"] = [{"repo": f"repo-{index}", "target_version": "1.2.3", "status": "ready"} for index in range(50)]
|
||||
summary = "\n".join(release._summary_lines("plan", fixture_plan))
|
||||
assert "38 more selected repositories" in summary
|
||||
assert "16 more gate findings" in summary
|
||||
assert "fixture-value-not-for-display" not in summary
|
||||
assert "[redacted]" in summary
|
||||
assert len(summary) < 3000
|
||||
|
||||
|
||||
def test_run_and_preview_summary_exposes_steps_and_next_action(environment):
|
||||
run = environment.create()["result"]
|
||||
shown = environment.call("show", run["run_id"])
|
||||
summary = "\n".join(shown["summary"])
|
||||
assert "Steps: 2 pending." in summary
|
||||
assert "Step core:preflight: pending." in summary
|
||||
assert "Next: execute_step [core:preflight]" in summary
|
||||
preview = environment.call("preview", run["run_id"], "core:tag")
|
||||
assert "Release preview: pending." in preview["summary"]
|
||||
assert any("Complete core:preflight first." in line for line in preview["summary"])
|
||||
|
||||
|
||||
def test_status_summary_exposes_repository_counts(environment):
|
||||
environment.dashboard.return_value = {"summary": {"status": "attention", "repository_count": 77, "dirty_count": 42, "ahead_count": 1, "behind_count": 0, "error_count": 0}}
|
||||
result = environment.call("status")
|
||||
assert "Release status: attention." in result["summary"]
|
||||
assert any("77 repositories, 42 dirty, 1 ahead, 0 behind, 0 errors" in line for line in result["summary"])
|
||||
|
||||
|
||||
def test_display_redaction_cannot_change_semantic_failure_exit(environment, monkeypatch):
|
||||
monkeypatch.setenv("DEVKIT_FIXTURE_SECRET", "blocked")
|
||||
environment.dashboard.return_value = {"summary": {"status": "blocked"}}
|
||||
result = environment.call("status")
|
||||
assert result["_exit_code"] == 1
|
||||
assert "Release status: [redacted]." in result["summary"]
|
||||
|
||||
|
||||
def test_create_defaults_to_preview_without_run_record(environment):
|
||||
result = environment.call("create", "--repo", "govoplan-core", "--target-version", "1.2.3", "--request-id", "preview-create-request-0001")
|
||||
assert result["_exit_code"] == 0 and result["dry_run"]
|
||||
assert not list(environment.state.rglob("rr-*.json"))
|
||||
environment.executor.assert_not_called()
|
||||
|
||||
|
||||
def test_create_show_and_same_id_replay_use_durable_service(environment):
|
||||
created = environment.create()
|
||||
run_id = created["result"]["run_id"]
|
||||
repeated = environment.create()
|
||||
assert repeated["result"]["run_id"] == run_id
|
||||
environment.planner.assert_called_once()
|
||||
shown = environment.call("show", run_id)
|
||||
assert shown["result"]["immutable"] == created["result"]["immutable"]
|
||||
assert shown["result"]["state"]["steps"][0]["executor"]["confirmation"] == ""
|
||||
assert len(list(environment.state.rglob("rr-*.json"))) == 1
|
||||
|
||||
|
||||
def test_create_replay_rejects_changed_inputs(environment):
|
||||
environment.create()
|
||||
result = environment.call("create", "--repo-version", "govoplan-core=1.2.4", "--request-id", "devkit-create-request-0001", "--apply")
|
||||
assert result["http_status"] == 409
|
||||
environment.planner.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arguments", [
|
||||
("plan",),
|
||||
("create", "--repo", "govoplan-core", "--request-id", "missing-version-request"),
|
||||
("plan", "--repo-version", "govoplan-core=1.2.3", "--repo-version", "govoplan-core=1.2.4"),
|
||||
("plan", "--repo-version", "govoplan-core=not-a-version"),
|
||||
])
|
||||
def test_invalid_selection_is_rejected_before_service_collection(environment, arguments):
|
||||
result = environment.call(*arguments)
|
||||
assert result["_exit_code"] == 2
|
||||
environment.dashboard.assert_not_called()
|
||||
environment.executor.assert_not_called()
|
||||
|
||||
|
||||
def test_runtime_trust_guard_cannot_be_bypassed_by_cli(environment, monkeypatch):
|
||||
monkeypatch.setattr(api, "require_trusted_release_runtime", Mock(side_effect=ReleaseExecutionBlocked("Fixture untrusted runtime")))
|
||||
result = environment.call("create", "--repo-version", "govoplan-core=1.2.3", "--request-id", "trust-create-request-0001", "--apply")
|
||||
assert result["http_status"] == 409
|
||||
assert not list(environment.state.rglob("rr-*.json"))
|
||||
environment.planner.assert_not_called()
|
||||
|
||||
|
||||
def test_dry_execute_and_generic_preview_never_claim_or_execute(environment):
|
||||
run = environment.create()["result"]
|
||||
dry = environment.call("execute", run["run_id"], "core:preflight", "--request-id", "dry-execute-request-0001")
|
||||
preview = environment.call("preview", run["run_id"], "core:tag")
|
||||
assert dry["dry_run"] and preview["dry_run"]
|
||||
assert preview["result"]["state_step"]["executor"]["confirmation"] == "TAG"
|
||||
assert preview["result"]["state_step"]["available"] is False
|
||||
environment.executor.assert_not_called()
|
||||
assert environment.call("show", run["run_id"])["result"]["state"]["steps"][0]["attempt_count"] == 0
|
||||
|
||||
|
||||
def test_prerequisite_and_confirmation_guards_remain_enforced(environment):
|
||||
run_id = environment.create()["result"]["run_id"]
|
||||
blocked = environment.call("execute", run_id, "core:tag", "--request-id", "ordered-tag-request-0001", "--confirm", "TAG", "--apply")
|
||||
assert blocked["http_status"] == 409
|
||||
environment.executor.assert_not_called()
|
||||
assert environment.call("execute", run_id, "core:preflight", "--request-id", "preflight-request-0001", "--apply")["_exit_code"] == 0
|
||||
missing = environment.call("execute", run_id, "core:tag", "--request-id", "confirmed-tag-request-0001", "--apply")
|
||||
assert missing["http_status"] == 409
|
||||
assert environment.executor.call_count == 1
|
||||
|
||||
|
||||
def test_execute_same_attempt_replays_without_repeating_effect(environment):
|
||||
run_id = environment.create()["result"]["run_id"]
|
||||
arguments = ("execute", run_id, "core:preflight", "--request-id", "exact-attempt-request-0001", "--apply")
|
||||
first = environment.call(*arguments)
|
||||
second = environment.call(*arguments)
|
||||
assert first["_exit_code"] == second["_exit_code"] == 0
|
||||
assert second["result"]["execution_result"]["status"] == "replayed"
|
||||
environment.executor.assert_called_once()
|
||||
assert environment.executor.call_args.kwargs["remote"] == "origin"
|
||||
|
||||
|
||||
def test_lost_finish_write_is_interrupted_without_reexecuting_effect(environment, monkeypatch):
|
||||
run_id = environment.create()["result"]["run_id"]
|
||||
finish = ReleaseRunStore.finish_step
|
||||
monkeypatch.setattr(ReleaseRunStore, "finish_step", Mock(side_effect=ReleaseRunCorrupt("Fixture durable write failure")))
|
||||
arguments = ("execute", run_id, "core:preflight", "--request-id", "lost-finish-request-0001", "--apply")
|
||||
interrupted = environment.call(*arguments)
|
||||
assert interrupted["http_status"] == 409
|
||||
monkeypatch.setattr(ReleaseRunStore, "finish_step", finish)
|
||||
assert environment.call(*arguments)["http_status"] == 409
|
||||
environment.executor.assert_called_once()
|
||||
shown = environment.call("show", run_id)["result"]
|
||||
assert shown["state"]["steps"][0]["state"] == "interrupted"
|
||||
|
||||
|
||||
def test_source_guard_failure_does_not_call_executor(environment, monkeypatch):
|
||||
run_id = environment.create()["result"]["run_id"]
|
||||
monkeypatch.setattr(api, "verify_repository_preflight_binding", Mock(side_effect=ReleaseExecutionBlocked("Frozen HEAD/remote changed")))
|
||||
result = environment.call("execute", run_id, "core:preflight", "--request-id", "changed-source-request-0001", "--apply")
|
||||
assert result["_exit_code"] == 1
|
||||
environment.executor.assert_not_called()
|
||||
|
||||
|
||||
def test_interrupted_write_requires_reconciliation_not_retry(environment, monkeypatch):
|
||||
environment.plans[0]["dry_run_steps"] = [environment.plans[0]["dry_run_steps"][1]]
|
||||
environment.executor.side_effect = ReleaseExecutionAmbiguous("Fixture remote outcome uncertain")
|
||||
run_id = environment.create()["result"]["run_id"]
|
||||
execute = ("execute", run_id, "core:tag", "--request-id", "uncertain-tag-request-0001", "--confirm", "TAG", "--apply")
|
||||
uncertain = environment.call(*execute)
|
||||
assert uncertain["http_status"] == 409
|
||||
assert environment.call(*execute)["http_status"] == 409
|
||||
retry = environment.call("retry", run_id, "core:tag", "--request-id", "unsafe-retry-request-0001", "--apply")
|
||||
assert retry["http_status"] == 409
|
||||
environment.executor.assert_called_once()
|
||||
invalid = environment.call("reconcile", run_id, "core:tag", "--request-id", "bad-reconcile-request-0001", "--outcome", "effect_absent", "--apply")
|
||||
assert invalid["http_status"] == 409
|
||||
reconciled = environment.call("reconcile", run_id, "core:tag", "--request-id", "reconcile-absent-request-0001", "--outcome", "effect_absent", "--confirm", "RECONCILE", "--apply")
|
||||
assert reconciled["_exit_code"] == 0
|
||||
assert reconciled["result"]["state"]["steps"][0]["state"] == "pending"
|
||||
|
||||
|
||||
def test_effect_succeeded_reconciliation_keeps_independent_receipt_guard(environment, monkeypatch):
|
||||
environment.plans[0]["dry_run_steps"] = [environment.plans[0]["dry_run_steps"][1]]
|
||||
environment.executor.side_effect = ReleaseExecutionAmbiguous("Fixture interruption")
|
||||
run_id = environment.create()["result"]["run_id"]
|
||||
environment.call("execute", run_id, "core:tag", "--request-id", "receipt-tag-request-0001", "--confirm", "TAG", "--apply")
|
||||
guard = Mock(side_effect=ReleaseExecutionBlocked("Remote annotation mismatch"))
|
||||
monkeypatch.setattr(api, "reconciled_repository_receipt", guard)
|
||||
result = environment.call("reconcile", run_id, "core:tag", "--request-id", "receipt-success-request-0001", "--outcome", "effect_succeeded", "--confirm", "RECONCILE", "--apply")
|
||||
assert result["http_status"] == 409
|
||||
guard.assert_called_once()
|
||||
environment.executor.assert_called_once()
|
||||
|
||||
|
||||
def test_resume_and_retry_reuse_the_existing_running_attempt_rules(environment):
|
||||
created = environment.create()
|
||||
run_id = created["result"]["run_id"]
|
||||
store = ReleaseRunStore(Path(created["state_location"]), expected_workspace_fingerprint=api.release_workspace_fingerprint(environment.workspace))
|
||||
store.claim_step(run_id, "core:preflight", attempt_id="lost-process-request-0001")
|
||||
dry = environment.call("resume", run_id, "--request-id", "resume-process-request-0001")
|
||||
assert dry["dry_run"]
|
||||
assert store.get(run_id)["state"]["steps"][0]["state"] == "running"
|
||||
resumed = environment.call("resume", run_id, "--request-id", "resume-process-request-0001", "--apply")
|
||||
assert resumed["result"]["state"]["steps"][0]["state"] == "interrupted"
|
||||
retried = environment.call("retry", run_id, "core:preflight", "--request-id", "retry-readonly-request-0001", "--apply")
|
||||
assert retried["result"]["state"]["steps"][0]["state"] == "pending"
|
||||
environment.executor.assert_not_called()
|
||||
|
||||
|
||||
def test_workspace_scoping_and_corrupt_records_fail_closed(environment, tmp_path):
|
||||
created = environment.create()
|
||||
run_id = created["result"]["run_id"]
|
||||
another = tmp_path / "another-workspace"
|
||||
another.mkdir()
|
||||
foreign = environment.call("show", run_id, workspace_root=another)
|
||||
assert foreign["http_status"] == 404
|
||||
path = Path(created["state_location"]) / f"{run_id}.json"
|
||||
record = json.loads(path.read_text())
|
||||
record["immutable"]["input"]["repo_versions"]["govoplan-core"] = "9.9.9"
|
||||
path.write_text(json.dumps(record))
|
||||
assert environment.call("show", run_id)["http_status"] == 409
|
||||
|
||||
|
||||
def test_run_list_keeps_cursor_pagination(environment):
|
||||
environment.create("page-create-request-0001")
|
||||
environment.create("page-create-request-0002")
|
||||
first = environment.call("list", "--limit", "1")["result"]
|
||||
second = environment.call("list", "--limit", "1", "--cursor", first["next_cursor"])["result"]
|
||||
assert len(first["runs"]) == len(second["runs"]) == 1
|
||||
assert first["runs"][0]["run_id"] != second["runs"][0]["run_id"]
|
||||
assert second["next_cursor"] is None
|
||||
|
||||
|
||||
def test_catalog_preview_calls_only_existing_receipt_bound_preview(environment, monkeypatch, tmp_path):
|
||||
environment.plans[0] = plan(catalog=True)
|
||||
run_id = environment.create()["result"]["run_id"]
|
||||
candidate = tmp_path / "candidate"
|
||||
verify = Mock(return_value=candidate)
|
||||
publish = Mock(return_value={"status": "planned", "apply": False})
|
||||
monkeypatch.setattr(api, "verified_run_candidate", verify)
|
||||
monkeypatch.setattr(api, "publish_catalog_candidate", publish)
|
||||
result = environment.call("preview", run_id, "catalog:validate-sign-publish")
|
||||
assert result["_exit_code"] == 0
|
||||
verify.assert_called_once()
|
||||
assert publish.call_args.kwargs["apply"] is False
|
||||
assert publish.call_args.kwargs["candidate_dir"] == candidate
|
||||
assert publish.call_args.kwargs["remote"] == "origin"
|
||||
environment.executor.assert_not_called()
|
||||
|
||||
|
||||
def test_catalog_generation_and_publication_use_exact_durable_receipts(environment, monkeypatch):
|
||||
environment.plans[0] = plan(catalog=True)
|
||||
created = environment.create()
|
||||
run_id = created["result"]["run_id"]
|
||||
candidate_root = Path(created["candidate_location"])
|
||||
|
||||
def generated(**kwargs):
|
||||
candidate_id = kwargs["candidate_id"]
|
||||
candidate = candidate_output_path(candidate_root, candidate_id)
|
||||
channels = candidate / "channels"
|
||||
channels.mkdir(parents=True)
|
||||
(channels / "stable.json").write_text(json.dumps({"channel": "stable", "signatures": [{}]}))
|
||||
harden_private_candidate_tree(candidate)
|
||||
return {"status": "ready"}, issue_candidate_receipt(root=candidate_root, candidate_id=candidate_id, channel="stable")
|
||||
|
||||
def published(**kwargs):
|
||||
candidate = kwargs["candidate_receipt"]
|
||||
website = kwargs["expected_website_receipt"]
|
||||
return {"status": "published"}, {
|
||||
"kind": "catalog_publication", "candidate_id": candidate["candidate_id"],
|
||||
"catalog_sha256": candidate["catalog_sha256"], "keyring_sha256": "c" * 64,
|
||||
"publication_commit_sha": "d" * 40, "publication_tag_object_sha": "e" * 40,
|
||||
"publication_tag_commit_sha": "d" * 40, "branch": website["branch"],
|
||||
"tag_name": "catalog-stable-1", "remote": "origin", "remote_sha256": website["remote_sha256"],
|
||||
}
|
||||
|
||||
generator = Mock(side_effect=generated)
|
||||
publisher = Mock(side_effect=published)
|
||||
website = receipt("addideas-govoplan-website", target_tag="")
|
||||
monkeypatch.setattr(api, "generate_catalog_candidate", generator)
|
||||
monkeypatch.setattr(api, "publish_received_candidate", publisher)
|
||||
monkeypatch.setattr(api, "verify_catalog_publication_precondition", Mock(return_value=website))
|
||||
generate = ("execute", run_id, "catalog:selective-generator", "--request-id", "generate-candidate-request-0001", "--confirm", "GENERATE", "--apply")
|
||||
first = environment.call(*generate, "--signing-key", "fixture-key=/private/fixture-key.pem")
|
||||
assert first["_exit_code"] == 0, first
|
||||
replayed = environment.call(*generate)
|
||||
assert replayed["result"]["execution_result"]["status"] == "replayed"
|
||||
generator.assert_called_once()
|
||||
assert generator.call_args.kwargs["signing_keys"] == ("fixture-key=/private/fixture-key.pem",)
|
||||
assert "/private/fixture-key.pem" not in json.dumps(first)
|
||||
record_text = next(environment.state.rglob("rr-*.json")).read_text()
|
||||
assert "/private/fixture-key.pem" not in record_text
|
||||
missing_confirmation = environment.call("execute", run_id, "catalog:validate-sign-publish", "--request-id", "publish-candidate-request-0001", "--apply")
|
||||
assert missing_confirmation["http_status"] == 409
|
||||
publisher.assert_not_called()
|
||||
publish = ("execute", run_id, "catalog:validate-sign-publish", "--request-id", "publish-candidate-request-0001", "--confirm", "PUSH", "--apply")
|
||||
complete = environment.call(*publish)
|
||||
assert complete["_exit_code"] == 0, complete
|
||||
assert complete["result"]["state"]["status"] == "completed"
|
||||
frozen = first["result"]["state"]["steps"][0]["result_receipt"]
|
||||
assert publisher.call_args.kwargs["candidate_path"] == candidate_root / frozen["candidate_id"]
|
||||
assert publisher.call_args.kwargs["candidate_receipt"] == frozen
|
||||
assert publisher.call_args.kwargs["expected_website_receipt"] == website
|
||||
assert publisher.call_args.kwargs["remote"] == "origin"
|
||||
assert environment.call(*publish)["result"]["execution_result"]["status"] == "replayed"
|
||||
publisher.assert_called_once()
|
||||
|
||||
|
||||
def test_portable_project_cannot_override_release_catalog(environment):
|
||||
args = namespace(environment.workspace, environment.state, "plan", "--repo-version", "govoplan-core=1.2.3")
|
||||
args.project = environment.workspace / "custom-project.json"
|
||||
result = release.handle(args)
|
||||
assert result["_exit_code"] == 2
|
||||
assert "does not accept --project" in result["summary"][0]
|
||||
environment.dashboard.assert_not_called()
|
||||
|
||||
|
||||
def test_foreign_cached_release_package_is_rejected_before_import(environment, monkeypatch, tmp_path):
|
||||
monkeypatch.setitem(sys.modules, "server.app", SimpleNamespace(__file__=str(tmp_path / "foreign/app.py")))
|
||||
importer = Mock(side_effect=AssertionError("Foreign module import must not occur"))
|
||||
monkeypatch.setattr(release.importlib, "import_module", importer)
|
||||
result = environment.call("plan", "--repo-version", "govoplan-core=1.2.3")
|
||||
assert result["_exit_code"] == 2
|
||||
assert "foreign module" in result["summary"][0]
|
||||
importer.assert_not_called()
|
||||
environment.dashboard.assert_not_called()
|
||||
|
||||
|
||||
def test_no_arbitrary_remote_or_legacy_publication_flags():
|
||||
run_id = "rr-request-" + "a" * 64
|
||||
with pytest.raises(SystemExit):
|
||||
namespace(Path("/fixture"), None, "execute", run_id, "core:tag", "--request-id", "arbitrary-remote-request", "--remote", "untrusted")
|
||||
with pytest.raises(SystemExit):
|
||||
namespace(Path("/fixture"), None, "publish-candidate", "--candidate-dir", "/untrusted")
|
||||
|
||||
|
||||
def test_unknown_step_and_malformed_paths_do_not_become_other_routes(environment):
|
||||
run_id = environment.create()["result"]["run_id"]
|
||||
assert environment.call("preview", run_id, "unknown:step")["http_status"] == 404
|
||||
with pytest.raises(SystemExit):
|
||||
namespace(environment.workspace, environment.state, "preview", run_id, "../repositories/push")
|
||||
|
||||
|
||||
def test_signing_material_is_not_echoed_by_validation(environment):
|
||||
run_id = environment.create()["result"]["run_id"]
|
||||
secret = "PRIVATE-KEY-MATERIAL\nDO-NOT-ECHO"
|
||||
result = environment.call("execute", run_id, "core:preflight", "--request-id", "secret-input-request-0001", "--signing-key", secret, "--apply")
|
||||
assert result["_exit_code"] == 2
|
||||
assert secret not in json.dumps(result)
|
||||
assert "SECRET" not in release._error_detail({"detail": [{"loc": ["body", "signing_keys"], "msg": "Invalid input", "input": "SECRET"}]})
|
||||
environment.executor.assert_not_called()
|
||||
|
||||
|
||||
def test_default_state_matches_console_and_token_never_appears_in_output(environment, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "xdg-state"))
|
||||
monkeypatch.setattr(release.secrets, "token_urlsafe", lambda size: "ephemeral-do-not-print-token")
|
||||
result = environment.call("plan", "--repo-version", "govoplan-core=1.2.3", state_dir=None)
|
||||
assert result["state_location"] == str(api.default_release_run_root(environment.workspace))
|
||||
assert "ephemeral-do-not-print-token" not in json.dumps(result)
|
||||
|
||||
|
||||
def test_documentation_keeps_disabled_generic_mutations_and_recovery_explicit():
|
||||
console = (ROOT / "docs/operations/RELEASE_CONSOLE.md").read_text()
|
||||
usage = (ROOT / "docs/operations/DEVKIT_RELEASE.md").read_text()
|
||||
assert "generic push, sync and prepare\nmutation endpoints are disabled" in console
|
||||
assert "ASGI application inside" in usage
|
||||
assert "effect_absent" in usage and "effect_succeeded" in usage and "unresolved" in usage
|
||||
assert "does **not** stage arbitrary source" in usage
|
||||
Executable
+175
@@ -0,0 +1,175 @@
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit"))
|
||||
from govoplan_devkit import review
|
||||
from govoplan_devkit.cli import main
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def args(tmp_path):
|
||||
repo = tmp_path / "optional-feature"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init", "-q", str(repo)], check=True)
|
||||
paths = ["webui/src/pages/CampaignPage.tsx", "webui/src/dialogs/SettingsDialog.tsx",
|
||||
"webui/src/settings/AdminSettings.tsx", "webui/src/widgets/SummaryWidget.tsx", "webui/src/module.ts"]
|
||||
for relative in paths:
|
||||
path = repo / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("// fixture only\n")
|
||||
manifest = repo / "src/optional_feature/backend/manifest.py"
|
||||
manifest.parent.mkdir(parents=True)
|
||||
manifest.write_text("raise RuntimeError('MUST NEVER IMPORT MODULE CODE')\n")
|
||||
(tmp_path / "principles.md").write_text("# Principles\nRevision **UI-2026-09-08**\n\n## UI-01 — Help beside text\n\n## UI-02 — Display first; edit deliberately\n")
|
||||
inventory = {"schema_version": 1, "epic": {"repository": "meta", "number": 4, "url": "https://gitea.invalid/team/meta/issues/4"},
|
||||
"issues": [{"scope_id": "campaigns", "name": "Campaign", "kind": "manifest", "repository": "optional-feature", "number": 8,
|
||||
"url": "https://gitea.invalid/team/optional-feature/issues/8", "state_at_verification": "closed", "operation": "created"}]}
|
||||
(tmp_path / "issues.json").write_text(json.dumps(inventory))
|
||||
project = tmp_path / "project.json"
|
||||
project.write_text(json.dumps({"schema_version": 1, "name": "Portable project", "repositories": [
|
||||
{"name": "optional-feature", "path": "optional-feature", "aliases": ["campaign"]},
|
||||
{"name": "absent-optional", "path": "absent-optional", "aliases": ["absent"]}],
|
||||
"review": {"issue_inventory": "issues.json", "principles": "principles.md"},
|
||||
"checks": [{"id": "ui-check", "argv": ["never-execute-this"], "repos": ["optional-feature"], "cwd": "optional-feature"}],
|
||||
"profiles": {"ui": ["ui-check"]}}))
|
||||
return argparse.Namespace(workspace_root=tmp_path, project=project, state_dir=tmp_path / "state",
|
||||
module="campaign", bundle_module=None, profile="ui", evidence=None, output=None)
|
||||
|
||||
|
||||
def test_bundle_contains_guidance_links_revision_and_unexecuted_check_plan(args):
|
||||
result = review.handle_review(args)
|
||||
assert result["module"] == "optional-feature"
|
||||
assert result["inventory"]["ui_source_count"] == 5
|
||||
assert not result["inventory"]["module_code_imported"]
|
||||
assert result["inventory"]["manifest_paths"] == ["src/optional_feature/backend/manifest.py"]
|
||||
assert result["principles"]["revision"] == "UI-2026-09-08"
|
||||
assert [rule["id"] for rule in result["principles"]["rules"]] == ["UI-01", "UI-02"]
|
||||
assert result["check_plan"]["stages"][0]["argv"] == ["never-execute-this"]
|
||||
assert not result["check_plan"]["executed"]
|
||||
assert result["review_completion"].startswith("Not assessed")
|
||||
assert len(result["manual_checklist"]) >= 9
|
||||
assert all("checked" not in item for item in result["manual_checklist"])
|
||||
assert "state_at_verification" not in json.dumps(result)
|
||||
assert "operation" not in result["issue_links"][0]
|
||||
assert result["central_issue"]["number"] == 4
|
||||
assert not args.state_dir.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["campaign", "campaigns", "optional-feature"])
|
||||
def test_alias_scope_id_and_repository_resolve_to_same_registered_module(args, name):
|
||||
args.module = name
|
||||
assert review.handle_review(args)["module"] == "optional-feature"
|
||||
|
||||
|
||||
def test_bundle_alias_and_direct_cli_work_with_global_options_anywhere(args, capsys):
|
||||
assert main(["--workspace-root", str(args.workspace_root), "review", "campaign", "--project", str(args.project), "--json"]) == 0
|
||||
direct = json.loads(capsys.readouterr().out)
|
||||
assert direct["module"] == "optional-feature"
|
||||
assert main(["review", "bundle", "campaigns", "--workspace-root", str(args.workspace_root), "--project", str(args.project), "--format", "json"]) == 0
|
||||
assert json.loads(capsys.readouterr().out)["module"] == direct["module"]
|
||||
|
||||
|
||||
def test_absent_optional_repository_and_no_ui_do_not_complete_review(args):
|
||||
args.module = "absent"
|
||||
result = review.handle_review(args)
|
||||
assert not result["repository"]["exists"]
|
||||
assert result["repository"]["errors"]
|
||||
assert result["inventory"]["ui_source_count"] == 0
|
||||
assert result["check_plan"]["stages"] == []
|
||||
assert result["review_completion"].startswith("Not assessed")
|
||||
assert any("not automatic N/A" in warning for warning in result["warnings"])
|
||||
assert any("not a passing" in warning for warning in result["warnings"])
|
||||
|
||||
|
||||
def test_portable_project_does_not_inherit_govoplan_links_or_principles(args):
|
||||
payload = json.loads(args.project.read_text())
|
||||
payload.pop("review")
|
||||
args.project.write_text(json.dumps(payload))
|
||||
result = review.handle_review(args)
|
||||
assert result["issue_links"] == [] and result["central_issue"] is None
|
||||
assert not result["principles"]["available"]
|
||||
assert "git.add-ideas.de" not in json.dumps(result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["symlink", "outside", "bad-url", "duplicate-scope", "foreign-repository"])
|
||||
def test_review_inputs_cannot_escape_or_silently_misbind_scope(args, kind):
|
||||
if kind in {"symlink", "outside"}:
|
||||
payload = json.loads(args.project.read_text())
|
||||
if kind == "symlink":
|
||||
alias = args.workspace_root / "alias.json"
|
||||
alias.symlink_to(args.workspace_root / "issues.json")
|
||||
payload["review"]["issue_inventory"] = "alias.json"
|
||||
else:
|
||||
payload["review"]["principles"] = "../outside.md"
|
||||
args.project.write_text(json.dumps(payload))
|
||||
else:
|
||||
path = args.workspace_root / "issues.json"
|
||||
payload = json.loads(path.read_text())
|
||||
if kind == "bad-url":
|
||||
payload["issues"][0]["url"] = "https://gitea.invalid/team/wrong/issues/8"
|
||||
elif kind == "duplicate-scope":
|
||||
payload["issues"].append(payload["issues"][0])
|
||||
else:
|
||||
payload["issues"][0].update(repository="foreign", url="https://gitea.invalid/team/foreign/issues/8")
|
||||
args.module = "campaigns"
|
||||
path.write_text(json.dumps(payload))
|
||||
with pytest.raises(ValueError):
|
||||
review.handle_review(args)
|
||||
|
||||
|
||||
def test_source_inventory_never_follows_symlinked_ancestors(args):
|
||||
root = args.workspace_root / "indirect"
|
||||
root.mkdir()
|
||||
(root / "webui").symlink_to(args.workspace_root / "optional-feature/webui", target_is_directory=True)
|
||||
result = review.source_inventory(root)
|
||||
assert result["ui_source_count"] == 0
|
||||
assert result["skipped_symlinks"] == ["webui/src"]
|
||||
|
||||
|
||||
def test_explicit_artifact_is_only_local_and_preserves_issue_discovery_snapshot(args):
|
||||
inventory = args.workspace_root / "issues.json"
|
||||
original = inventory.read_bytes()
|
||||
args.output = args.workspace_root / "artifacts/review.json"
|
||||
result = review.handle_review(args)
|
||||
assert json.loads(args.output.read_text()) == result
|
||||
assert inventory.read_bytes() == original
|
||||
assert args.output.stat().st_mode & 0o777 == 0o600
|
||||
assert not args.state_dir.exists()
|
||||
|
||||
|
||||
def test_attached_historical_receipt_stays_separate_from_current_review(args):
|
||||
path = args.workspace_root / "historical.json"
|
||||
path.write_text(json.dumps({"schema_version": 1, "run_id": "historical", "workspace_root": str(args.workspace_root),
|
||||
"project_file": str(args.project), "source_fingerprint": "a" * 64, "status": "passed", "snapshot_verified": True, "generated_at": "2026-09-08",
|
||||
"finished_at": "2026-09-08", "stages": [{"id": "old-check", "status": "passed", "exit_code": 0, "duration_seconds": 1, "log_path": "/not-read"}]}))
|
||||
args.evidence = str(path)
|
||||
result = review.handle_review(args)
|
||||
assert result["evidence"]["origin"] == "external-unverified"
|
||||
assert result["evidence"]["source_state"] == "historical-source-differs"
|
||||
assert result["review_completion"].startswith("Not assessed")
|
||||
assert not result["check_plan"]["executed"]
|
||||
|
||||
|
||||
def test_unknown_module_and_extra_positionals_are_errors(args):
|
||||
args.module = "typo"
|
||||
with pytest.raises(ValueError, match="Unknown repository"):
|
||||
review.handle_review(args)
|
||||
args.module, args.bundle_module = "campaign", "extra"
|
||||
with pytest.raises(ValueError, match="one module"):
|
||||
review.handle_review(args)
|
||||
|
||||
|
||||
def test_compact_review_exposes_scoped_coverage_limits_without_hiding_full_list(args, monkeypatch):
|
||||
notes = [f"Separate suite {number} was not included." for number in range(12)]
|
||||
monkeypatch.setattr("govoplan_devkit.catalog.build_stages", lambda *_args, **_kwargs: [
|
||||
{"id": "source-only", "argv": ["never-execute"], "coverage_notes": notes}])
|
||||
result = review.handle_review(args)
|
||||
assert result["coverage_notes"] == notes
|
||||
assert sum(line.startswith("Coverage limitation:") for line in result["summary"]) == 8
|
||||
assert any("4 additional coverage limitations" in line for line in result["summary"])
|
||||
assert not result["check_plan"]["executed"]
|
||||
Executable
+466
@@ -0,0 +1,466 @@
|
||||
"""Real fixture processes and Git worktrees; never invoke product/remote mutations."""
|
||||
|
||||
from argparse import Namespace
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit"))
|
||||
from govoplan_devkit import runner
|
||||
from govoplan_devkit.checkpoints import Checkpoints
|
||||
from govoplan_devkit.common import (
|
||||
atomic_json,
|
||||
digest,
|
||||
read_json,
|
||||
resource_lock,
|
||||
state_root,
|
||||
)
|
||||
from govoplan_devkit.workspace import load_project, source_fingerprint
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def example(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "xdg-state"))
|
||||
workspace = tmp_path / "workspace"
|
||||
repo = workspace / "example"
|
||||
repo.mkdir(parents=True)
|
||||
subprocess.run(["git", "init", "-q", str(repo)], check=True)
|
||||
(repo / "source.txt").write_text("first\n")
|
||||
subprocess.run(["git", "-C", str(repo), "add", "source.txt"], check=True)
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(repo),
|
||||
"-c",
|
||||
"user.name=Fixture",
|
||||
"-c",
|
||||
"user.email=fixture@example.invalid",
|
||||
"commit",
|
||||
"-qm",
|
||||
"fixture",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
project = tmp_path / "project.json"
|
||||
project.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "Example",
|
||||
"repositories": [{"name": "example", "path": "example"}],
|
||||
"checks": [],
|
||||
"profiles": {},
|
||||
}
|
||||
)
|
||||
)
|
||||
args = Namespace(
|
||||
workspace_root=workspace,
|
||||
project=project,
|
||||
state_dir=tmp_path / "state",
|
||||
dry_run=False,
|
||||
jobs=2,
|
||||
profile="quick",
|
||||
resume=None,
|
||||
)
|
||||
return args, repo
|
||||
|
||||
|
||||
def stage(repo, name="one", code="print('ok')", **extra):
|
||||
return {
|
||||
"id": name,
|
||||
"title": name,
|
||||
"argv": [sys.executable, "-c", code],
|
||||
"cwd": str(repo),
|
||||
"timeout_seconds": 10,
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def run(args, stages):
|
||||
# Environment inspection is tested separately; keep fixture executions cheap/deterministic.
|
||||
with patch.object(runner, "environment_fingerprint", return_value="fixture-env"):
|
||||
return runner.run_checks(args, stages)
|
||||
|
||||
|
||||
def test_success_receipt_and_compact_summary(example):
|
||||
args, repo = example
|
||||
result = run(args, [stage(repo)])
|
||||
assert result["status"] == "passed"
|
||||
receipt = runner.read_receipt(args.workspace_root, args.state_dir, result["run_id"])
|
||||
assert receipt["stages"][0]["exit_code"] == 0
|
||||
assert Path(receipt["stages"][0]["log_path"]).read_text() == "ok\n"
|
||||
assert runner.summarize(receipt)["counts"] == {"passed": 1}
|
||||
assert Path(result["receipt_path"]).stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
def test_native_environment_binds_selected_checkout(tmp_path, monkeypatch):
|
||||
from govoplan_devkit.environment import execution_environment
|
||||
|
||||
monkeypatch.setenv("GOVOPLAN_WORKSPACE_ROOT", "/another/workspace")
|
||||
monkeypatch.setenv("GOVOPLAN_CORE_ROOT", "/another/core")
|
||||
monkeypatch.setenv("GOVOPLAN_CORE_SOURCE_ROOT", "/another/source")
|
||||
project = load_project(tmp_path)
|
||||
env = execution_environment(
|
||||
tmp_path, project, {"python": sys.executable, "node": "node", "npm": "npm"}
|
||||
)
|
||||
assert env["GOVOPLAN_WORKSPACE_ROOT"] == str(tmp_path)
|
||||
assert env["GOVOPLAN_CORE_ROOT"] == str(tmp_path / "govoplan-core")
|
||||
assert env["GOVOPLAN_CORE_SOURCE_ROOT"] == str(tmp_path / "govoplan-core")
|
||||
|
||||
|
||||
def test_portable_environment_does_not_inject_govoplan_scope(example, monkeypatch):
|
||||
from govoplan_devkit.environment import execution_environment
|
||||
|
||||
args, _ = example
|
||||
for key in (
|
||||
"GOVOPLAN_WORKSPACE_ROOT",
|
||||
"GOVOPLAN_CORE_ROOT",
|
||||
"GOVOPLAN_CORE_SOURCE_ROOT",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
env = execution_environment(
|
||||
args.workspace_root,
|
||||
load_project(args.workspace_root, args.project),
|
||||
{"python": sys.executable, "node": "node", "npm": "npm"},
|
||||
)
|
||||
assert "GOVOPLAN_WORKSPACE_ROOT" not in env
|
||||
|
||||
|
||||
def test_failure_skips_dependents_but_runs_independent_check(example):
|
||||
args, repo = example
|
||||
result = run(
|
||||
args,
|
||||
[
|
||||
stage(repo, "bad", "raise SystemExit(4)"),
|
||||
stage(repo, "dependent", deps=["bad"]),
|
||||
stage(repo, "independent"),
|
||||
],
|
||||
)
|
||||
states = {item["id"]: item["status"] for item in result["stages"]}
|
||||
assert states == {"bad": "failed", "dependent": "skipped", "independent": "passed"}
|
||||
assert result["_exit_code"] == 1
|
||||
|
||||
|
||||
def test_timeout_is_not_a_pass(example):
|
||||
args, repo = example
|
||||
result = run(
|
||||
args, [stage(repo, code="import time; time.sleep(10)", timeout_seconds=0.1)]
|
||||
)
|
||||
assert result["stages"][0]["status"] == "timed_out"
|
||||
|
||||
|
||||
def test_dry_run_does_not_create_state(example):
|
||||
args, repo = example
|
||||
args.dry_run = True
|
||||
result = run(args, [stage(repo, code="raise SystemExit(9)")])
|
||||
assert result["status"] == "planned"
|
||||
assert not args.state_dir.exists()
|
||||
|
||||
|
||||
def test_empty_plan_is_not_claimed_as_verified(example):
|
||||
args, _ = example
|
||||
assert run(args, [])["status"] == "not_run"
|
||||
|
||||
|
||||
def test_plan_validation_rejects_cycles_unknowns_duplicate_ids_escape(example):
|
||||
args, repo = example
|
||||
invalid = [
|
||||
[stage(repo, deps=["unknown"])],
|
||||
[stage(repo, "a", deps=["b"]), stage(repo, "b", deps=["a"])],
|
||||
[stage(repo), stage(repo)],
|
||||
[stage(repo, "../escape")],
|
||||
[stage(repo.parent.parent)],
|
||||
]
|
||||
for plan in invalid:
|
||||
with pytest.raises(ValueError):
|
||||
runner.validate_stages(plan, args.workspace_root, {})
|
||||
|
||||
|
||||
def test_source_identity_includes_staged_unstaged_and_untracked_bytes(example):
|
||||
args, repo = example
|
||||
project = load_project(args.workspace_root, args.project)
|
||||
first = source_fingerprint(project)
|
||||
(repo / "source.txt").write_text("second\n")
|
||||
second = source_fingerprint(project)
|
||||
assert second != first
|
||||
subprocess.run(["git", "-C", str(repo), "add", "source.txt"], check=True)
|
||||
third = source_fingerprint(project)
|
||||
assert third != second
|
||||
(repo / "extra.txt").write_text("extra")
|
||||
assert source_fingerprint(project) != third
|
||||
|
||||
|
||||
def test_source_mutation_during_run_invalidates_receipt(example):
|
||||
args, repo = example
|
||||
result = run(
|
||||
args,
|
||||
[
|
||||
stage(
|
||||
repo,
|
||||
code="from pathlib import Path; Path('source.txt').write_text('changed')",
|
||||
)
|
||||
],
|
||||
)
|
||||
assert result["stages"][0]["status"] == "stale"
|
||||
assert result["stages"][0]["checkpoint_verified"] is False
|
||||
assert result["status"] == "stale"
|
||||
|
||||
|
||||
def test_resume_reuses_only_identical_plan_and_inputs(example):
|
||||
args, repo = example
|
||||
plan = [stage(repo)]
|
||||
first = run(args, plan)
|
||||
args.resume = first["run_id"]
|
||||
second = run(args, plan)
|
||||
assert second["stages"][0]["reused_from"] == first["run_id"]
|
||||
changed_plan = run(args, [stage(repo, code="print('different')")])
|
||||
assert changed_plan["status"] == "passed"
|
||||
assert "reused_from" not in changed_plan["stages"][0]
|
||||
assert Path(changed_plan["stages"][0]["log_path"]).read_text() == "different\n"
|
||||
(repo / "source.txt").write_text("changed")
|
||||
changed_source = run(args, plan)
|
||||
assert changed_source["status"] == "passed"
|
||||
assert "reused_from" not in changed_source["stages"][0]
|
||||
assert changed_source["stages"][0]["cache_key"] != first["stages"][0]["cache_key"]
|
||||
|
||||
|
||||
def test_receipt_integrity_and_foreign_id_are_rejected(example):
|
||||
args, repo = example
|
||||
result = run(args, [stage(repo)])
|
||||
path = Path(result["receipt_path"])
|
||||
record = read_json(path)
|
||||
record["status"] = "fabricated"
|
||||
atomic_json(path, record)
|
||||
with pytest.raises(ValueError, match="integrity"):
|
||||
runner.read_receipt(args.workspace_root, args.state_dir, result["run_id"])
|
||||
with pytest.raises(ValueError):
|
||||
runner.read_receipt(args.workspace_root, args.state_dir, "../other")
|
||||
|
||||
|
||||
def test_shared_resources_serialize_stages(example):
|
||||
args, repo = example
|
||||
active = 0
|
||||
maximum = 0
|
||||
lock = threading.Lock()
|
||||
original = runner._execute_stage_command
|
||||
|
||||
def execute(*args, **kwargs):
|
||||
nonlocal active, maximum
|
||||
with lock:
|
||||
active += 1
|
||||
maximum = max(maximum, active)
|
||||
try:
|
||||
time.sleep(0.04)
|
||||
return original(*args, **kwargs)
|
||||
finally:
|
||||
with lock:
|
||||
active -= 1
|
||||
|
||||
with patch.object(runner, "_execute_stage_command", side_effect=execute):
|
||||
result = run(
|
||||
args,
|
||||
[
|
||||
stage(repo, "a", resources=["shared"]),
|
||||
stage(repo, "b", resources=["shared"]),
|
||||
],
|
||||
)
|
||||
assert result["status"] == "passed"
|
||||
assert maximum == 1
|
||||
|
||||
|
||||
def test_cross_process_resource_conflict_is_explicit(example):
|
||||
args, repo = example
|
||||
locks = state_root(args.workspace_root) / "resource-locks"
|
||||
with resource_lock(locks, "shared"):
|
||||
result = run(args, [stage(repo, resources=["shared"])])
|
||||
assert result["stages"][0]["status"] == "blocked"
|
||||
|
||||
|
||||
def test_output_is_bounded_and_known_secrets_redacted(example, monkeypatch):
|
||||
args, repo = example
|
||||
monkeypatch.setenv("FIXTURE_SECRET", "private-fixture-credential")
|
||||
with patch.object(runner, "MAX_LOG_BYTES", 1024):
|
||||
result = run(
|
||||
args,
|
||||
[
|
||||
stage(
|
||||
repo,
|
||||
code="import os; print(os.environ['FIXTURE_SECRET']); print('x'*10000)",
|
||||
)
|
||||
],
|
||||
)
|
||||
record = result["stages"][0]
|
||||
output = Path(record["log_path"]).read_text()
|
||||
assert record["output_truncated"] is True
|
||||
assert "private-fixture-credential" not in output
|
||||
assert "[redacted]" in output
|
||||
assert len(output) < 2000
|
||||
|
||||
|
||||
def test_symlinked_state_is_rejected(example, tmp_path):
|
||||
args, repo = example
|
||||
target = tmp_path / "target"
|
||||
target.mkdir()
|
||||
args.state_dir.symlink_to(target, target_is_directory=True)
|
||||
with pytest.raises(ValueError, match="symlink"):
|
||||
run(args, [stage(repo)])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", ["--assume-unchanged", "--skip-worktree"])
|
||||
def test_hidden_tracked_edits_invalidate_source_identity(example, flag):
|
||||
args, repo = example
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo), "update-index", flag, "source.txt"], check=True
|
||||
)
|
||||
project = load_project(args.workspace_root, args.project)
|
||||
initial = source_fingerprint(project)
|
||||
(repo / "source.txt").write_text("hidden edit\n")
|
||||
assert source_fingerprint(project) != initial
|
||||
|
||||
|
||||
def test_failed_final_snapshot_never_persists_a_passing_receipt(example):
|
||||
args, repo = example
|
||||
events = []
|
||||
args.on_progress = events.append
|
||||
original = Checkpoints.source
|
||||
|
||||
def fingerprint(self, *values, **kwargs):
|
||||
if events and events[-1]["phase"] == "finalizing":
|
||||
raise ValueError("unreadable source")
|
||||
return original(self, *values, **kwargs)
|
||||
|
||||
with patch.object(Checkpoints, "source", fingerprint):
|
||||
result = run(args, [stage(repo)])
|
||||
assert result["status"] == "stale"
|
||||
assert result["snapshot_verified"] is False
|
||||
assert result["_exit_code"] == 1
|
||||
assert "unreadable source" in result["invalidated_reason"]
|
||||
assert (
|
||||
runner.read_receipt(args.workspace_root, args.state_dir, result["run_id"])[
|
||||
"status"
|
||||
]
|
||||
== "stale"
|
||||
)
|
||||
|
||||
|
||||
def test_resume_rejects_a_modified_cached_log(example):
|
||||
args, repo = example
|
||||
plan = [stage(repo)]
|
||||
first = run(args, plan)
|
||||
Path(first["stages"][0]["log_path"]).write_text("altered")
|
||||
args.resume = first["run_id"]
|
||||
result = run(args, plan)
|
||||
assert result["status"] == "failed"
|
||||
assert result["stages"][0]["status"] == "failed"
|
||||
assert "log integrity" in result["stages"][0]["error"]
|
||||
assert "reused_from" not in result["stages"][0]
|
||||
|
||||
|
||||
def test_receipt_argument_redaction_applies_to_unknown_separate_values(example):
|
||||
args, repo = example
|
||||
plan = stage(repo)
|
||||
plan["argv"].extend(["--password", "fixture-not-from-environment"])
|
||||
result = run(args, [plan])
|
||||
assert (
|
||||
"fixture-not-from-environment" not in Path(result["receipt_path"]).read_text()
|
||||
)
|
||||
|
||||
|
||||
def recovery_parser():
|
||||
parser = argparse.ArgumentParser()
|
||||
runner.register(parser.add_subparsers())
|
||||
return parser
|
||||
|
||||
|
||||
def test_recovery_requires_manual_survivor_confirmation(example):
|
||||
args, repo = example
|
||||
result = run(args, [stage(repo)])
|
||||
record = read_json(Path(result["receipt_path"]))
|
||||
record["status"] = "running"
|
||||
record["snapshot_verified"] = False
|
||||
atomic_json(Path(result["receipt_path"]), runner._seal(record))
|
||||
parsed = recovery_parser().parse_args(
|
||||
["recover", result["run_id"], "--apply"], namespace=args
|
||||
)
|
||||
with pytest.raises(ValueError, match="manual verification"):
|
||||
parsed.handler(parsed)
|
||||
|
||||
|
||||
def test_recovery_does_not_overwrite_completion_between_reads(example):
|
||||
args, repo = example
|
||||
result = run(args, [stage(repo)])
|
||||
completed = runner.read_receipt(
|
||||
args.workspace_root, args.state_dir, result["run_id"]
|
||||
)
|
||||
initial = {**completed, "status": "running"}
|
||||
parsed = recovery_parser().parse_args(
|
||||
["recover", result["run_id"], "--apply", "--confirm-processes-stopped"],
|
||||
namespace=args,
|
||||
)
|
||||
with (
|
||||
patch.object(runner, "read_receipt", side_effect=[initial, completed]),
|
||||
patch.object(runner, "atomic_json") as write,
|
||||
):
|
||||
assert parsed.handler(parsed)["status"] == "unchanged"
|
||||
write.assert_not_called()
|
||||
|
||||
|
||||
def test_active_owner_lock_blocks_recovery(example):
|
||||
args, repo = example
|
||||
result = run(args, [stage(repo)])
|
||||
record = read_json(Path(result["receipt_path"]))
|
||||
record.update(status="running", snapshot_verified=False)
|
||||
atomic_json(Path(result["receipt_path"]), runner._seal(record))
|
||||
parsed = recovery_parser().parse_args(
|
||||
["recover", result["run_id"], "--apply", "--confirm-processes-stopped"],
|
||||
namespace=args,
|
||||
)
|
||||
with resource_lock(
|
||||
state_root(args.workspace_root, args.state_dir) / "locks",
|
||||
"run:" + result["run_id"],
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="busy"):
|
||||
parsed.handler(parsed)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutation",
|
||||
[
|
||||
"unknown_status",
|
||||
"unverified_pass",
|
||||
"duplicate_stage",
|
||||
"nonboolean_verification",
|
||||
"nonpassing_stage",
|
||||
],
|
||||
)
|
||||
def test_even_correctly_hashed_receipts_must_have_consistent_states(example, mutation):
|
||||
args, repo = example
|
||||
result = run(args, [stage(repo)])
|
||||
path = Path(result["receipt_path"])
|
||||
record = read_json(path)
|
||||
if mutation == "unknown_status":
|
||||
record["status"] = "wonderful"
|
||||
elif mutation == "unverified_pass":
|
||||
record["snapshot_verified"] = False
|
||||
elif mutation == "duplicate_stage":
|
||||
record["stages"].append(record["stages"][0])
|
||||
elif mutation == "nonboolean_verification":
|
||||
record["snapshot_verified"] = "true"
|
||||
else:
|
||||
record["stages"][0]["status"] = "failed"
|
||||
# Deliberately construct an externally corrupted but correctly hashed record;
|
||||
# the runner's writer now rejects inconsistent checkpoint state before sealing.
|
||||
record["integrity_sha256"] = digest(
|
||||
{key: value for key, value in record.items() if key != "integrity_sha256"}
|
||||
)
|
||||
atomic_json(path, record)
|
||||
with pytest.raises(ValueError):
|
||||
runner.read_receipt(args.workspace_root, args.state_dir, result["run_id"])
|
||||
Executable
+251
@@ -0,0 +1,251 @@
|
||||
"""Published-schema parity and all-declaration validation, without commands."""
|
||||
|
||||
from copy import deepcopy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import jsonschema
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools/devkit"))
|
||||
from govoplan_devkit.workspace import load_project # noqa: E402
|
||||
from govoplan_devkit.validation import _schema_value # noqa: E402
|
||||
|
||||
|
||||
def valid():
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"repositories": [{"name": "example", "path": "example"}],
|
||||
"checks": [
|
||||
{"id": "selected", "argv": ["true"]},
|
||||
{"id": "unselected", "argv": ["true"]},
|
||||
],
|
||||
"profiles": {"quick": ["selected"], "full": ["unselected"]},
|
||||
}
|
||||
|
||||
|
||||
def check(tmp_path, value):
|
||||
path = tmp_path / "project.json"
|
||||
path.write_text(json.dumps(value))
|
||||
return load_project(tmp_path, path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("resource", ["shared"]),
|
||||
("timout_seconds", 20),
|
||||
("cwd", "../escape"),
|
||||
("cwd", "/outside"),
|
||||
("argv", [""]),
|
||||
("argv", ["true", "x" * 8193]),
|
||||
("argv", ["true"] * 257),
|
||||
("argv", ["true", "bad\0value"]),
|
||||
("resources", ["a", "a"]),
|
||||
("resources", [""]),
|
||||
("resources", ["x" * 257]),
|
||||
("resources", ["bad\nname"]),
|
||||
("resources", True),
|
||||
("deps", ["selected", "selected"]),
|
||||
("repos", ["example", "example"]),
|
||||
("title", 10),
|
||||
("title", "x" * 1025),
|
||||
("timeout_seconds", True),
|
||||
("timeout_seconds", 43201),
|
||||
("timeout_seconds", 0),
|
||||
("id", "invalid\n"),
|
||||
],
|
||||
)
|
||||
def test_unselected_checks_obey_the_published_schema(tmp_path, field, value):
|
||||
project = valid()
|
||||
project["checks"][1][field] = value
|
||||
schema = json.loads((ROOT / "tools/devkit/project.schema.json").read_text())
|
||||
with pytest.raises(jsonschema.ValidationError):
|
||||
jsonschema.validate(project, schema)
|
||||
with pytest.raises(ValueError):
|
||||
check(tmp_path, project)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutation",
|
||||
[
|
||||
lambda value: value["profiles"].update(typo=[]),
|
||||
lambda value: value["profiles"].update(full=["unselected", "unselected"]),
|
||||
lambda value: value["tools"].update(pyton="python"),
|
||||
lambda value: value["tools"].update(python="bad\0tool"),
|
||||
lambda value: value["review"].update(principle="rules.md"),
|
||||
lambda value: value["review"].update(principles="../rules.md"),
|
||||
lambda value: value["repositories"][0].update(alias="other"),
|
||||
lambda value: value["repositories"][0].update(aliases=["alias", "alias"]),
|
||||
lambda value: value.update(checks=value["checks"] * 257),
|
||||
lambda value: value.update(schema_version=True),
|
||||
],
|
||||
)
|
||||
def test_all_nested_shape_constraints_match_schema(tmp_path, mutation):
|
||||
project = valid()
|
||||
project.update(tools={}, review={})
|
||||
mutation(project)
|
||||
schema = json.loads((ROOT / "tools/devkit/project.schema.json").read_text())
|
||||
with pytest.raises(jsonschema.ValidationError):
|
||||
jsonschema.validate(project, schema)
|
||||
with pytest.raises(ValueError):
|
||||
check(tmp_path, project)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutation,reason",
|
||||
[
|
||||
(
|
||||
lambda value: value["checks"][1].update(deps=["missing"]),
|
||||
"Unknown check dependency",
|
||||
),
|
||||
(lambda value: value["checks"][1].update(deps=["unselected"]), "Cyclic"),
|
||||
(
|
||||
lambda value: value["checks"][1].update(repos=["missing"]),
|
||||
"Unknown repository",
|
||||
),
|
||||
(lambda value: value["profiles"].update(full=["missing"]), "Unknown profile"),
|
||||
(lambda value: value["checks"][1].update(id="selected"), "Duplicate"),
|
||||
(
|
||||
lambda value: value["repositories"].append(
|
||||
{"name": "other", "path": "example"}
|
||||
),
|
||||
"Duplicate project repository path",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_all_semantic_references_are_checked_even_outside_selected_profile(
|
||||
tmp_path, mutation, reason
|
||||
):
|
||||
project = valid()
|
||||
mutation(project)
|
||||
with pytest.raises(ValueError, match=reason):
|
||||
check(tmp_path, project)
|
||||
|
||||
|
||||
def test_valid_defaults_boundaries_and_dependency_order(tmp_path):
|
||||
project = valid()
|
||||
project["checks"][0].update(
|
||||
deps=["unselected"], argv=["true", ""], timeout_seconds=0.1
|
||||
)
|
||||
schema = json.loads((ROOT / "tools/devkit/project.schema.json").read_text())
|
||||
jsonschema.validate(project, schema)
|
||||
assert check(tmp_path, project).config == project
|
||||
|
||||
|
||||
def test_runtime_shape_uses_only_implemented_schema_keywords():
|
||||
schema = json.loads((ROOT / "tools/devkit/project.schema.json").read_text())
|
||||
permitted = {
|
||||
"$schema",
|
||||
"$defs",
|
||||
"$ref",
|
||||
"title",
|
||||
"description",
|
||||
"type",
|
||||
"const",
|
||||
"properties",
|
||||
"additionalProperties",
|
||||
"required",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"uniqueItems",
|
||||
"prefixItems",
|
||||
"items",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"pattern",
|
||||
"maximum",
|
||||
"exclusiveMinimum",
|
||||
"enum",
|
||||
}
|
||||
|
||||
def visit(node):
|
||||
assert set(node) <= permitted
|
||||
for group in ("properties", "$defs"):
|
||||
for child in node.get(group, {}).values():
|
||||
visit(child)
|
||||
if "items" in node:
|
||||
visit(node["items"])
|
||||
for child in node.get("prefixItems", []):
|
||||
visit(child)
|
||||
|
||||
visit(schema)
|
||||
_schema_value(valid(), schema, schema["$defs"], "project")
|
||||
|
||||
|
||||
def test_huge_numeric_input_is_a_controlled_bound_error(tmp_path):
|
||||
project = deepcopy(valid())
|
||||
project["checks"][1]["timeout_seconds"] = 10**1000
|
||||
with pytest.raises(ValueError):
|
||||
check(tmp_path, project)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("inputs", {}),
|
||||
("inputs", None),
|
||||
("inputs", {"repos": []}),
|
||||
("inputs", {"paths": ["src/**"]}),
|
||||
("inputs", {"repos": ["example", "example"]}),
|
||||
("inputs", {"repos": [""]}),
|
||||
("inputs", {"repos": ["example"], "glob": "*"}),
|
||||
("after", ["selected", "selected"]),
|
||||
("after", [""]),
|
||||
("after", "selected"),
|
||||
("reuse", True),
|
||||
("reuse", "always"),
|
||||
],
|
||||
)
|
||||
def test_input_order_and_reuse_shapes_apply_to_unselected_checks(
|
||||
tmp_path, field, value
|
||||
):
|
||||
project = valid()
|
||||
project["checks"][1][field] = value
|
||||
schema = json.loads((ROOT / "tools/devkit/project.schema.json").read_text())
|
||||
with pytest.raises(jsonschema.ValidationError):
|
||||
jsonschema.validate(project, schema)
|
||||
with pytest.raises(ValueError):
|
||||
check(tmp_path, project)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"change,reason",
|
||||
[
|
||||
({"inputs": {"repos": ["unknown"]}}, "Unknown input repository"),
|
||||
({"after": ["unknown"]}, "Unknown check ordering"),
|
||||
({"after": ["unselected"]}, "Cyclic"),
|
||||
(
|
||||
{"deps": ["selected"], "after": ["selected"]},
|
||||
"Duplicate dependency/ordering",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_input_and_order_semantics_are_validated_before_selection(
|
||||
tmp_path, change, reason
|
||||
):
|
||||
project = valid()
|
||||
project["checks"][1].update(change)
|
||||
with pytest.raises(ValueError, match=reason):
|
||||
check(tmp_path, project)
|
||||
|
||||
|
||||
def test_dependencies_and_order_only_edges_share_cycle_validation(tmp_path):
|
||||
project = valid()
|
||||
project["checks"][0]["after"] = ["unselected"]
|
||||
project["checks"][1]["deps"] = ["selected"]
|
||||
with pytest.raises(ValueError, match="Cyclic"):
|
||||
check(tmp_path, project)
|
||||
|
||||
|
||||
def test_valid_explicit_inputs_after_and_reuse_match_published_schema(tmp_path):
|
||||
project = valid()
|
||||
project["checks"][0].update(
|
||||
inputs={"repos": ["example"]}, after=["unselected"], reuse="verified"
|
||||
)
|
||||
project["checks"][1]["reuse"] = "never"
|
||||
schema = json.loads((ROOT / "tools/devkit/project.schema.json").read_text())
|
||||
jsonschema.validate(project, schema)
|
||||
assert check(tmp_path, project).config == project
|
||||
Executable
+151
@@ -0,0 +1,151 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit"))
|
||||
from govoplan_devkit.common import atomic_json, read_json, redact, safe_output
|
||||
from govoplan_devkit.context import build_context
|
||||
from govoplan_devkit.workspace import (
|
||||
load_project,
|
||||
selected_repositories,
|
||||
git_bytes,
|
||||
inspect_repository,
|
||||
Repository,
|
||||
)
|
||||
|
||||
|
||||
def test_portable_project_rejects_escape_alias_collisions_and_duplicate_json(tmp_path):
|
||||
config = tmp_path / "project.json"
|
||||
for repositories in (
|
||||
[{"name": "repo", "path": "../outside"}],
|
||||
[
|
||||
{"name": "repo", "path": "repo", "aliases": ["other"]},
|
||||
{"name": "other", "path": "other"},
|
||||
],
|
||||
):
|
||||
config.write_text(
|
||||
json.dumps({"schema_version": 1, "repositories": repositories})
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
load_project(tmp_path, config)
|
||||
config.write_text('{"schema_version":1,"schema_version":2}')
|
||||
with pytest.raises(ValueError, match="Duplicate"):
|
||||
read_json(config)
|
||||
|
||||
|
||||
def test_missing_repository_is_error_not_clean(tmp_path):
|
||||
config = tmp_path / "project.json"
|
||||
config.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "Test",
|
||||
"repositories": [{"name": "missing", "path": "missing"}],
|
||||
}
|
||||
)
|
||||
)
|
||||
result = build_context(tmp_path, config, [], True)
|
||||
assert result["_exit_code"] == 1
|
||||
assert result["repositories"][0]["errors"]
|
||||
assert not result["remote_checked"]
|
||||
|
||||
|
||||
def test_unknown_repo_filter_is_not_silently_ignored(tmp_path):
|
||||
config = tmp_path / "project.json"
|
||||
config.write_text(
|
||||
json.dumps(
|
||||
{"schema_version": 1, "repositories": [{"name": "repo", "path": "repo"}]}
|
||||
)
|
||||
)
|
||||
with pytest.raises(ValueError, match="Unknown"):
|
||||
selected_repositories(load_project(tmp_path, config), ["typo"])
|
||||
|
||||
|
||||
def test_atomic_output_does_not_chmod_existing_parent(tmp_path):
|
||||
folder = tmp_path / "public"
|
||||
folder.mkdir(mode=0o755)
|
||||
atomic_json(folder / "private.json", {"safe": True})
|
||||
assert folder.stat().st_mode & 0o777 == 0o755
|
||||
assert (folder / "private.json").stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
def test_read_rejects_fifo_and_symlink(tmp_path):
|
||||
fifo = tmp_path / "fifo"
|
||||
os.mkfifo(fifo)
|
||||
with pytest.raises(ValueError, match="regular"):
|
||||
read_json(fifo)
|
||||
target = tmp_path / "target.json"
|
||||
target.write_text("{}")
|
||||
alias = tmp_path / "alias.json"
|
||||
alias.symlink_to(target)
|
||||
with pytest.raises(ValueError, match="symlink"):
|
||||
read_json(alias)
|
||||
|
||||
|
||||
def test_generic_secret_display_hygiene():
|
||||
assert "topsecret" not in redact(
|
||||
"Authorization: Bearer topsecret\nhttps://user:topsecret@example.invalid/"
|
||||
)
|
||||
|
||||
|
||||
def test_separate_secret_arguments_are_redacted_in_nested_json():
|
||||
value = {
|
||||
"stages": [{"argv": ["check", "--token", "hidden-value"]}],
|
||||
"password": "hidden-password",
|
||||
}
|
||||
output = json.dumps(safe_output(value))
|
||||
assert "hidden-value" not in output and "hidden-password" not in output
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
[
|
||||
"GIT_DIR",
|
||||
"GIT_NAMESPACE",
|
||||
"GIT_COMMON_DIR",
|
||||
"GIT_CONFIG_PARAMETERS",
|
||||
"GIT_AUTHOR_NAME",
|
||||
],
|
||||
)
|
||||
def test_inherited_git_redirects_are_rejected(tmp_path, monkeypatch, name):
|
||||
monkeypatch.setenv(name, "fixture")
|
||||
assert inspect_repository(Repository("example", tmp_path))["errors"]
|
||||
with pytest.raises(ValueError, match="overrides"):
|
||||
git_bytes(tmp_path, "status")
|
||||
|
||||
|
||||
def test_deeply_nested_json_fails_as_controlled_input_error(tmp_path):
|
||||
path = tmp_path / "deep.json"
|
||||
path.write_text("[" * 10000 + "]" * 10000)
|
||||
with pytest.raises(ValueError, match="nesting"):
|
||||
read_json(path)
|
||||
|
||||
|
||||
def test_clean_commit_without_upstream_is_still_changed(tmp_path):
|
||||
subprocess.run(["git", "init", "-q", str(tmp_path)], check=True)
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(tmp_path),
|
||||
"-c",
|
||||
"user.name=Fixture",
|
||||
"-c",
|
||||
"user.email=fixture@example.invalid",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-qm",
|
||||
"fixture",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
from govoplan_devkit.workspace import Project
|
||||
|
||||
repo = Repository("example", tmp_path)
|
||||
assert selected_repositories(Project("Example", (repo,), {}), [], changed=True) == [
|
||||
repo
|
||||
]
|
||||
Executable
+393
@@ -0,0 +1,393 @@
|
||||
"""Canonical phase dispatch tested with inert tools in disposable workspaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = META_ROOT / "tools/checks/check-focused.sh"
|
||||
METADATA = META_ROOT / "tools/checks/focused-phases.json"
|
||||
PHASE_IDS = [
|
||||
"preflight",
|
||||
"tooling",
|
||||
"backend",
|
||||
"core-ui",
|
||||
"module-builds",
|
||||
"browser",
|
||||
"module-ui",
|
||||
]
|
||||
# These hashes bind the original working-copy check bodies at the phase split.
|
||||
# Only two explicit Core/webui cd lines were added for independent invocation.
|
||||
# Future deliberate changes to the canonical gate must update this contract.
|
||||
LEGACY_BODY_SHA256 = {
|
||||
"preflight": "b9c90fd3c84df788de5f2c001443672f683a9918459e1a7955ed4a225e6f20dd",
|
||||
"tooling": "3d9a5bf32acbe97134f51327ed4b2457a69ad23b723c15a2b9bd0dce7f82396b",
|
||||
"backend": "dd57c33919f06bd240516bbe47861be022e0b1c047334eedc7f6c596c2c49632",
|
||||
"core-ui": "613f806a602970f1dbfb243f4c96ccc6ca4836849bb1f1a2c4cff97aea6f3aeb",
|
||||
"module-builds": "1f2cd1e2c336f748fbf2972a2da87a0efec58ef9b084a9433511f794a456fa84",
|
||||
"browser": "aa20d1e1d4ec9f00bc27c06cdee7ffa2456d3d3b8a7155da4e888a7e2209306d",
|
||||
"module-ui": "c47794d82eb7bb47de114e86a95264e5898886f743c31eb7bdf82a079c827c9f",
|
||||
}
|
||||
EXTRA_TEST_COMMAND = '"$PYTHON" -m pytest -q tests/test_focused_phases.py\n'
|
||||
|
||||
|
||||
def definitions():
|
||||
source = SCRIPT.read_text()
|
||||
return dict(
|
||||
re.findall(
|
||||
r"^# devkit-phase: ([a-z-]+) begin\n(.*?)^# devkit-phase: \1 end$",
|
||||
source,
|
||||
re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_original_commands_remain_exactly_once_in_original_order():
|
||||
bodies = definitions()
|
||||
metadata = json.loads(METADATA.read_text())
|
||||
assert list(bodies) == PHASE_IDS
|
||||
assert [phase["id"] for phase in metadata["phases"]] == PHASE_IDS
|
||||
assert SCRIPT.read_text().count(EXTRA_TEST_COMMAND) == 1
|
||||
for identity, body in bodies.items():
|
||||
assert SCRIPT.read_text().count(f"# devkit-phase: {identity} begin") == 1
|
||||
if identity == "tooling":
|
||||
assert body.count(EXTRA_TEST_COMMAND) == 1
|
||||
body = body.replace(EXTRA_TEST_COMMAND, "")
|
||||
assert hashlib.sha256(body.encode()).hexdigest() == LEGACY_BODY_SHA256[identity]
|
||||
|
||||
|
||||
def test_ordering_and_artifact_dependencies_are_distinct():
|
||||
phases = json.loads(METADATA.read_text())["phases"]
|
||||
assert [phase["order_after"] for phase in phases] == [
|
||||
[],
|
||||
*[[identity] for identity in PHASE_IDS[:-1]],
|
||||
]
|
||||
assert all(phase["depends_on"] == [] for phase in phases)
|
||||
by_id = {phase["id"]: phase for phase in phases}
|
||||
assert "{core}/webui/dist" in by_id["module-builds"]["outputs"]
|
||||
assert any(
|
||||
"does not serve or require module-builds dist" in note
|
||||
for note in by_id["browser"]["notes"]
|
||||
)
|
||||
assert "port:4174" in by_id["browser"]["resources"]
|
||||
cwd_lines = {
|
||||
"core": 'cd "$ROOT"',
|
||||
"meta": 'cd "$META_ROOT"',
|
||||
"core-webui": 'cd "$ROOT/webui"',
|
||||
"access-webui": 'cd "${WORKSPACE_ROOT}/govoplan-access/webui"',
|
||||
}
|
||||
for phase in phases:
|
||||
assert definitions()[phase["id"]].splitlines()[0] == cwd_lines[phase["cwd"]]
|
||||
|
||||
|
||||
FAKE_TOOL = r"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
log = Path(os.environ["FOCUSED_FIXTURE_LOG"])
|
||||
record = {
|
||||
"tool": Path(sys.argv[0]).name,
|
||||
"argv": sys.argv[1:],
|
||||
"cwd": os.getcwd(),
|
||||
"env": {key: os.environ.get(key) for key in (
|
||||
"GOVOPLAN_WORKSPACE_ROOT", "NPM_CONFIG_USERCONFIG", "GOVOPLAN_NPM_USERCONFIG",
|
||||
"NPM_CONFIG_TMP", "npm_config_tmp", "PYTHONPATH", "PATH",
|
||||
)},
|
||||
}
|
||||
if "-" in sys.argv[1:]:
|
||||
record["stdin"] = sys.stdin.read()
|
||||
with log.open("a") as handle:
|
||||
handle.write(json.dumps(record) + "\n")
|
||||
if os.environ.get("FOCUSED_FIXTURE_FAIL_TOKEN") in sys.argv[1:]:
|
||||
raise SystemExit(7)
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixture_workspace(tmp_path):
|
||||
workspace = tmp_path / "workspace"
|
||||
meta = workspace / "govoplan"
|
||||
core = workspace / "govoplan-core"
|
||||
copied = meta / "tools/checks/check-focused.sh"
|
||||
copied.parent.mkdir(parents=True)
|
||||
shutil.copyfile(SCRIPT, copied)
|
||||
shutil.copyfile(METADATA, copied.with_name("focused-phases.json"))
|
||||
for name in (
|
||||
"govoplan",
|
||||
"govoplan-core",
|
||||
"govoplan-access",
|
||||
"govoplan-payments",
|
||||
"govoplan-dataflow",
|
||||
"govoplan-datasources",
|
||||
"govoplan-workflow",
|
||||
"govoplan-dashboard",
|
||||
"govoplan-approvals",
|
||||
"govoplan-postbox",
|
||||
"govoplan-mail",
|
||||
"govoplan-files",
|
||||
"govoplan-campaign",
|
||||
"govoplan-policy",
|
||||
"govoplan-wiki",
|
||||
):
|
||||
(workspace / name / "webui").mkdir(parents=True, exist_ok=True)
|
||||
(workspace / name / "src").mkdir(exist_ok=True)
|
||||
(meta / "tests").mkdir()
|
||||
for name in ("test_devkit_alpha.py", "test_devkit_beta.py"):
|
||||
(meta / "tests" / name).write_text("# inert glob fixture\n")
|
||||
fake_bin = tmp_path / "bin"
|
||||
fake_bin.mkdir()
|
||||
for target in [
|
||||
*(fake_bin / name for name in ("python-check", "node", "npm", "bash")),
|
||||
core / "webui/node_modules/.bin/tsc",
|
||||
meta / "tools/checks/check_dependency_boundaries.py",
|
||||
]:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(f"#!{sys.executable}\n" + FAKE_TOOL)
|
||||
target.chmod(0o700)
|
||||
temporary = tmp_path / "temporary"
|
||||
temporary.mkdir()
|
||||
log = tmp_path / "calls.jsonl"
|
||||
env = {
|
||||
**os.environ,
|
||||
"PATH": str(fake_bin) + os.pathsep + os.environ["PATH"],
|
||||
"GOVOPLAN_WORKSPACE_ROOT": str(workspace),
|
||||
"GOVOPLAN_CORE_ROOT": str(core),
|
||||
"PYTHON": str(fake_bin / "python-check"),
|
||||
"NODE": str(fake_bin / "node"),
|
||||
"NPM": str(fake_bin / "npm"),
|
||||
"TMPDIR": str(temporary),
|
||||
"FOCUSED_FIXTURE_LOG": str(log),
|
||||
"PYTHONPATH": "inherited-fixture-tail",
|
||||
"NPM_CONFIG_TMP": "must-be-unset",
|
||||
"npm_config_tmp": "must-be-unset",
|
||||
}
|
||||
env.pop("FOCUSED_FIXTURE_FAIL_TOKEN", None)
|
||||
return {
|
||||
"workspace": workspace,
|
||||
"meta": meta,
|
||||
"core": core,
|
||||
"script": copied,
|
||||
"env": env,
|
||||
"log": log,
|
||||
"temporary": temporary,
|
||||
}
|
||||
|
||||
|
||||
def invoke(fixture, *arguments, env=None):
|
||||
return subprocess.run(
|
||||
["/bin/bash", str(fixture["script"]), *arguments],
|
||||
cwd=fixture["workspace"].parent,
|
||||
env=env or fixture["env"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def records(fixture):
|
||||
path = fixture["log"]
|
||||
return (
|
||||
[json.loads(line) for line in path.read_text().splitlines()]
|
||||
if path.exists()
|
||||
else []
|
||||
)
|
||||
|
||||
|
||||
def normalized(calls):
|
||||
result = []
|
||||
for call in calls:
|
||||
call = json.loads(json.dumps(call))
|
||||
call["env"].pop("NPM_CONFIG_USERCONFIG")
|
||||
call["env"].pop("GOVOPLAN_NPM_USERCONFIG")
|
||||
result.append(call)
|
||||
return result
|
||||
|
||||
|
||||
def test_default_gate_equals_sequential_independent_phases(fixture_workspace):
|
||||
fixture = fixture_workspace
|
||||
full = invoke(fixture)
|
||||
assert full.returncode == 0, full.stderr
|
||||
original = records(fixture)
|
||||
offset = len(original)
|
||||
for identity in PHASE_IDS:
|
||||
isolated = invoke(fixture, "--phase", identity)
|
||||
assert isolated.returncode == 0, isolated.stderr
|
||||
assert normalized(original) == normalized(records(fixture)[offset:])
|
||||
assert len({call["env"]["NPM_CONFIG_USERCONFIG"] for call in original}) == len(
|
||||
PHASE_IDS
|
||||
)
|
||||
assert not list(fixture["temporary"].iterdir())
|
||||
assert sum("test:conformance" in call["argv"] for call in original) == 1
|
||||
assert sum("test:module-permutations" in call["argv"] for call in original) == 1
|
||||
assert sum("tests/test_focused_phases.py" in call["argv"] for call in original) == 1
|
||||
# The here-document remains one Python invocation, not executed shell text.
|
||||
ast_scan = [call for call in original if "stdin" in call]
|
||||
assert len(ast_scan) == 1
|
||||
assert "AST syntax check passed for" in ast_scan[0]["stdin"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("identity", PHASE_IDS)
|
||||
def test_each_phase_initializes_its_own_cwd_and_environment(
|
||||
fixture_workspace, identity
|
||||
):
|
||||
fixture = fixture_workspace
|
||||
result = invoke(fixture, "--phase", identity)
|
||||
assert result.returncode == 0, result.stderr
|
||||
calls = records(fixture)
|
||||
assert calls
|
||||
phase = next(
|
||||
item
|
||||
for item in json.loads(METADATA.read_text())["phases"]
|
||||
if item["id"] == identity
|
||||
)
|
||||
starts = {
|
||||
"core": fixture["core"],
|
||||
"meta": fixture["meta"],
|
||||
"core-webui": fixture["core"] / "webui",
|
||||
"access-webui": fixture["workspace"] / "govoplan-access/webui",
|
||||
}
|
||||
assert calls[0]["cwd"] == str(starts[phase["cwd"]])
|
||||
for call in calls:
|
||||
env = call["env"]
|
||||
assert env["GOVOPLAN_WORKSPACE_ROOT"] == str(fixture["workspace"])
|
||||
assert env["NPM_CONFIG_USERCONFIG"] == env["GOVOPLAN_NPM_USERCONFIG"]
|
||||
assert Path(env["NPM_CONFIG_USERCONFIG"]).parent == fixture["temporary"]
|
||||
assert not Path(env["NPM_CONFIG_USERCONFIG"]).exists()
|
||||
assert env["NPM_CONFIG_TMP"] is None and env["npm_config_tmp"] is None
|
||||
assert env["PATH"].split(os.pathsep)[0] == str(
|
||||
fixture["core"] / "webui/node_modules/.bin"
|
||||
)
|
||||
assert env["PYTHONPATH"].endswith(os.pathsep + "inherited-fixture-tail")
|
||||
assert str(fixture["core"] / "src") in env["PYTHONPATH"].split(os.pathsep)
|
||||
assert not list(fixture["temporary"].iterdir())
|
||||
|
||||
|
||||
def test_full_is_fail_fast_and_failure_cleans_setup(fixture_workspace):
|
||||
fixture = fixture_workspace
|
||||
result = invoke(
|
||||
fixture,
|
||||
env={
|
||||
**fixture["env"],
|
||||
"FOCUSED_FIXTURE_FAIL_TOKEN": "test:module-permutations",
|
||||
},
|
||||
)
|
||||
assert result.returncode == 7
|
||||
calls = records(fixture)
|
||||
assert calls[-1]["argv"] == ["run", "test:module-permutations"]
|
||||
assert not any("test:conformance" in call["argv"] for call in calls)
|
||||
assert not any("test:passwords" in call["argv"] for call in calls)
|
||||
assert not list(fixture["temporary"].iterdir())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"arguments",
|
||||
[
|
||||
["--unknown"],
|
||||
["--phase"],
|
||||
["--phase", ""],
|
||||
["--phase", "not-a-phase"],
|
||||
["--phase", "browser", "--phase", "tooling"],
|
||||
["--list-phases", "--phase", "browser"],
|
||||
["--phase", "browser", "--list-phases"],
|
||||
["--list-phases", "--list-phases"],
|
||||
["--json"],
|
||||
["--list-phases", "--json", "--json"],
|
||||
["--phase", "browser; touch unexpected"],
|
||||
],
|
||||
)
|
||||
def test_invalid_selection_is_rejected_before_setup(fixture_workspace, arguments):
|
||||
fixture = fixture_workspace
|
||||
env = {
|
||||
**fixture["env"],
|
||||
"GOVOPLAN_CORE_ROOT": str(fixture["workspace"] / "absent-core"),
|
||||
"NODE": "absent-node",
|
||||
"NPM": "absent-npm",
|
||||
}
|
||||
result = invoke(fixture, *arguments, env=env)
|
||||
assert result.returncode == 2
|
||||
assert "check-focused:" in result.stderr
|
||||
assert not fixture["log"].exists()
|
||||
assert not list(fixture["temporary"].iterdir())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"arguments",
|
||||
[["--list-phases"], ["--list-phases", "--json"], ["--json", "--list-phases"]],
|
||||
)
|
||||
def test_listing_is_read_only_without_product_tools(fixture_workspace, arguments):
|
||||
fixture = fixture_workspace
|
||||
env = {
|
||||
**fixture["env"],
|
||||
"GOVOPLAN_CORE_ROOT": str(fixture["workspace"] / "absent-core"),
|
||||
"PYTHON": "/absent-python",
|
||||
"NODE": "absent-node",
|
||||
"NPM": "absent-npm",
|
||||
}
|
||||
result = invoke(fixture, *arguments, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
if "--json" in arguments:
|
||||
assert json.loads(result.stdout) == json.loads(METADATA.read_text())
|
||||
else:
|
||||
assert [line.split("\t")[0] for line in result.stdout.splitlines()] == PHASE_IDS
|
||||
assert not fixture["log"].exists()
|
||||
assert not list(fixture["temporary"].iterdir())
|
||||
|
||||
|
||||
def test_metadata_loading_never_imports_project_python_modules(fixture_workspace):
|
||||
fixture = fixture_workspace
|
||||
foreign = fixture["workspace"].parent / "json.py"
|
||||
foreign.write_text(
|
||||
"raise AssertionError('project module imported during listing')\n"
|
||||
)
|
||||
result = invoke(
|
||||
fixture,
|
||||
"--list-phases",
|
||||
"--json",
|
||||
env={**fixture["env"], "PYTHONPATH": str(foreign.parent)},
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout)["schema_version"] == 1
|
||||
assert not fixture["log"].exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"change",
|
||||
[
|
||||
"unknown-fields",
|
||||
"duplicate-id",
|
||||
"unknown-cwd",
|
||||
"later-prerequisite",
|
||||
"invalid-version",
|
||||
],
|
||||
)
|
||||
def test_malformed_metadata_fails_before_any_check(fixture_workspace, change):
|
||||
fixture = fixture_workspace
|
||||
path = fixture["script"].with_name("focused-phases.json")
|
||||
catalog = json.loads(path.read_text())
|
||||
if change == "unknown-fields":
|
||||
catalog["phases"][0]["shell"] = "touch should-not-run"
|
||||
elif change == "duplicate-id":
|
||||
catalog["phases"][1]["id"] = catalog["phases"][0]["id"]
|
||||
elif change == "unknown-cwd":
|
||||
catalog["phases"][0]["cwd"] = "elsewhere"
|
||||
elif change == "later-prerequisite":
|
||||
catalog["phases"][0]["depends_on"] = ["browser"]
|
||||
else:
|
||||
catalog["schema_version"] = True
|
||||
path.write_text(json.dumps(catalog))
|
||||
result = invoke(fixture, "--phase", "browser")
|
||||
assert result.returncode == 2
|
||||
assert not fixture["log"].exists()
|
||||
assert not list(fixture["temporary"].iterdir())
|
||||
Executable
+194
@@ -0,0 +1,194 @@
|
||||
"""Offline safety and completeness checks for the cross-product UI review program."""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools/gitea"))
|
||||
SPEC = importlib.util.spec_from_file_location("ui_review_program", ROOT / "tools/gitea/gitea-ui-review-program.py")
|
||||
assert SPEC and SPEC.loader
|
||||
program = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = program
|
||||
SPEC.loader.exec_module(program)
|
||||
|
||||
|
||||
def scope(scope_id="campaigns", kind="manifest"):
|
||||
return {
|
||||
"scope_id": scope_id, "name": "Campaigns", "repository": "govoplan-campaign",
|
||||
"kind": kind, "manifest_paths": ["src/govoplan_campaign/backend/manifest.py"],
|
||||
"frontend": {
|
||||
"routes": [{"path": "/campaigns/:campaignId/*", "component": "CampaignWorkspace"}],
|
||||
"public_routes": [{"path": "/public/example", "component": "PublicExample"}],
|
||||
"settings_routes": [{"path": "/settings/example", "component": "ExampleSettings"}],
|
||||
"nav_items": [{"path": "/campaigns", "label": "Campaigns"}],
|
||||
"view_surfaces": [{"id": "campaigns.widget.activity", "label": "Activity widget"}],
|
||||
},
|
||||
"source_groups": {"Dialogs and embedded editing surfaces": ["webui/src/ExampleDialog.tsx"]},
|
||||
"ui_source_count": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_catalog_includes_core_all_manifests_and_each_placeholder(tmp_path):
|
||||
names = ["govoplan", "govoplan-core", "govoplan-campaign", "govoplan-ledger", "govoplan-xoev", "website"]
|
||||
for name in names:
|
||||
(tmp_path / name).mkdir()
|
||||
catalog = {"repositories": [
|
||||
{"name": name, "path": name, "category": "website" if name == "website" else "system" if name in {"govoplan", "govoplan-core"} else "connector" if name == "govoplan-xoev" else "module"}
|
||||
for name in names
|
||||
]}
|
||||
manifests = [{"id": "campaigns", "name": "Campaigns", "repository": "govoplan-campaign", "frontend": None}]
|
||||
scopes = program.build_scopes(catalog, tmp_path, manifests)
|
||||
assert {item["scope_id"] for item in scopes} == {"core", "campaigns", "catalog:govoplan-ledger", "catalog:govoplan-xoev"}
|
||||
assert sum(item["kind"] == "placeholder" for item in scopes) == 2
|
||||
assert next(item for item in scopes if item["scope_id"] == "campaigns")["kind"] == "manifest"
|
||||
|
||||
|
||||
def test_missing_checkout_or_implementation_without_manifest_is_not_called_placeholder(tmp_path):
|
||||
catalog = {"repositories": [{"name": "govoplan-example", "path": "govoplan-example", "category": "module"}]}
|
||||
with pytest.raises(program.GiteaError, match="Missing source checkout"):
|
||||
program.build_scopes(catalog, tmp_path, [])
|
||||
root = tmp_path / "govoplan-example"
|
||||
root.mkdir()
|
||||
(root / "pyproject.toml").touch()
|
||||
with pytest.raises(program.GiteaError, match="implementation but no extracted manifest"):
|
||||
program.build_scopes(catalog, tmp_path, [])
|
||||
|
||||
|
||||
def test_duplicate_or_uncatalogued_manifest_is_rejected(tmp_path):
|
||||
manifest = {"id": "example", "name": "Example", "repository": "govoplan-example", "frontend": None}
|
||||
with pytest.raises(program.GiteaError, match="Duplicate source manifest"):
|
||||
program.build_scopes({"repositories": []}, tmp_path, [manifest, manifest])
|
||||
with pytest.raises(program.GiteaError, match="absent from the module review catalog"):
|
||||
program.build_scopes({"repositories": []}, tmp_path, [manifest])
|
||||
|
||||
|
||||
def test_existing_closed_issue_is_found_and_unchanged():
|
||||
issue = {"number": 4, "title": "Reviewer renamed it", "state": "closed", "body": program.marker("campaigns") + "\nHuman findings\n- [x] Done"}
|
||||
before = issue.copy()
|
||||
assert program.find_existing([issue], "campaigns", program.issue_title(scope())) is issue
|
||||
assert issue == before
|
||||
|
||||
|
||||
def test_gitea_null_pull_request_field_is_an_ordinary_issue():
|
||||
issue = {"number": 25, "title": "UI review", "state": "open", "body": program.marker("mail"), "pull_request": None}
|
||||
assert program.find_existing([issue], "mail", "UI review") is issue
|
||||
|
||||
|
||||
def test_unmanaged_title_and_ambiguous_marker_stop_without_overwrite():
|
||||
title = program.issue_title(scope())
|
||||
with pytest.raises(program.GiteaError, match="Unmanaged exact-title"):
|
||||
program.find_existing([{"title": " " + title.upper() + " ", "body": "user content"}], "campaigns", title)
|
||||
duplicate = {"title": title, "body": program.marker("campaigns")}
|
||||
with pytest.raises(program.GiteaError, match="Ambiguous"):
|
||||
program.find_existing([duplicate, duplicate.copy()], "campaigns", title)
|
||||
|
||||
|
||||
def test_pr_not_used_as_matching_issue():
|
||||
issue = {"title": program.issue_title(scope()), "body": program.marker("campaigns"), "pull_request": {}}
|
||||
assert program.find_existing([issue], "campaigns", issue["title"]) is None
|
||||
|
||||
|
||||
def test_child_inventory_and_all_principles_start_pending():
|
||||
body = program.issue_body(scope(), "https://example.test/epic/56")
|
||||
assert "**Pending / not reviewed.**" in body
|
||||
assert "https://example.test/epic/56" in body
|
||||
assert "/campaigns/:campaignId/*" in body
|
||||
assert "/public/example" in body
|
||||
assert "/settings/example" in body
|
||||
assert "campaigns.widget.activity" in body
|
||||
assert "webui/src/ExampleDialog.tsx" in body
|
||||
assert "compact read-only campaign settings dashboard" in body
|
||||
assert "Save/Cancel" in body and "dirty-state protection" in body
|
||||
assert "- [x]" not in body
|
||||
assert body.count("| Pending inventory | Pending review | Not yet recorded | None approved |") == 9
|
||||
for identity, _ in program.PRINCIPLES:
|
||||
assert identity in body
|
||||
assert "Reopen this issue or link an owned follow-up" in body
|
||||
|
||||
|
||||
def test_headless_and_placeholder_scopes_are_not_automatic_completions():
|
||||
headless = scope("rest")
|
||||
headless["frontend"] = None
|
||||
assert "No standalone frontend is declared" in program.issue_body(headless, "epic")
|
||||
assert "**The review is still pending:**" in program.issue_body(headless, "epic")
|
||||
placeholder = scope("catalog:govoplan-ledger", "placeholder")
|
||||
body = program.issue_body(placeholder, "epic")
|
||||
assert "there is no runtime module ID, manifest or standalone WebUI" in body
|
||||
assert "Keep the future interface review pending" in body
|
||||
assert "- [x]" not in body
|
||||
|
||||
|
||||
def test_source_grouping_uses_real_files_and_clearly_bounds_large_seeds(tmp_path):
|
||||
source = tmp_path / "webui/src"
|
||||
source.mkdir(parents=True)
|
||||
for name in ["ExamplePage.tsx", "EditDialog.tsx", "TenantSettings.tsx", "ActivityWidget.tsx", "Button.tsx"]:
|
||||
(source / name).touch()
|
||||
groups = program.source_groups(tmp_path)
|
||||
assert sum(map(len, groups.values())) == 5
|
||||
assert groups["Dialogs and embedded editing surfaces"] == ["webui/src/EditDialog.tsx"]
|
||||
large = scope()
|
||||
large["source_groups"] = {"Pages": [f"webui/src/Page{index}.tsx" for index in range(45)]}
|
||||
large["ui_source_count"] = 45
|
||||
seed = program.source_seed(large)
|
||||
assert "5 further files" in seed
|
||||
assert "not a completed runtime audit" in seed
|
||||
|
||||
|
||||
def test_epic_initialization_preserves_surrounding_text_and_keeps_all_unchecked():
|
||||
body = program.EPIC_MARKER + "\nHuman introduction\n" + program.LIST_START + "\n" + program.INITIAL_LIST + "\n" + program.LIST_END + "\nHuman evidence"
|
||||
records = [
|
||||
{"name": "Core", "scope_id": "core", "repository": "govoplan-core", "kind": "core", "url": "https://example.test/core/1"},
|
||||
{"name": "Ledger", "scope_id": "catalog:govoplan-ledger", "repository": "govoplan-ledger", "kind": "placeholder", "url": "https://example.test/ledger/2"},
|
||||
]
|
||||
result = program.initialized_epic_body(body, records)
|
||||
assert "Human introduction" in result and "Human evidence" in result
|
||||
assert result.count("- [ ]") == 2
|
||||
assert "Catalogued placeholders" in result
|
||||
assert program.initialized_epic_body(result, records) == result
|
||||
human_progress = result.replace("- [ ] [Core]", "- [x] [Core]")
|
||||
assert program.initialized_epic_body(human_progress, records) == human_progress
|
||||
|
||||
|
||||
def test_epic_edited_or_ambiguous_lists_are_never_overwritten():
|
||||
records = [{"name": "Core", "scope_id": "core", "repository": "govoplan-core", "kind": "core", "url": "https://example.test/core/1"}]
|
||||
body = program.EPIC_MARKER + program.LIST_START + "User-managed content" + program.LIST_END
|
||||
with pytest.raises(program.GiteaError, match="already edited"):
|
||||
program.initialized_epic_body(body, records)
|
||||
with pytest.raises(program.GiteaError, match="absent or ambiguous"):
|
||||
program.initialized_epic_body(body + program.LIST_START, records)
|
||||
with pytest.raises(program.GiteaError, match="incomplete issue link inventory"):
|
||||
program.render_links([{**records[0], "url": None}])
|
||||
|
||||
|
||||
def test_ipv4_override_is_host_scoped_and_restored(monkeypatch):
|
||||
calls = []
|
||||
def original(host, port, family=0, type=0, proto=0, flags=0):
|
||||
calls.append((host, family))
|
||||
return []
|
||||
monkeypatch.setattr(socket, "getaddrinfo", original)
|
||||
with program.ipv4_for_target(True):
|
||||
socket.getaddrinfo("git.add-ideas.de", 443)
|
||||
socket.getaddrinfo("unrelated.example", 443)
|
||||
assert calls == [("git.add-ideas.de", socket.AF_INET), ("unrelated.example", 0)]
|
||||
assert socket.getaddrinfo is original
|
||||
|
||||
|
||||
def test_snapshot_covers_every_current_catalog_module():
|
||||
import json
|
||||
catalog = json.loads((ROOT / "repositories.json").read_text())
|
||||
inventory_path = ROOT / "docs/project/ui-review-issue-inventory.json"
|
||||
snapshot = json.loads(inventory_path.read_text())
|
||||
expected = {repo["name"] for repo in catalog["repositories"] if repo["category"] in {"module", "connector"} or repo["name"] == "govoplan-core"}
|
||||
assert {issue["repository"] for issue in snapshot["issues"]} == expected
|
||||
assert len(snapshot["issues"]) == len(expected)
|
||||
assert len({issue["url"] for issue in snapshot["issues"]}) == len(expected)
|
||||
assert snapshot["scope_count"] == 77
|
||||
assert snapshot["implemented_scopes"] == 73
|
||||
assert snapshot["manifest_modules"] == 72
|
||||
assert snapshot["catalogued_placeholders"] == 4
|
||||
assert all(issue["number"] and issue["url"].startswith("https://git.add-ideas.de/GovOPlaN/") for issue in snapshot["issues"])
|
||||
Reference in New Issue
Block a user