implements packer PR-06 doctor diagnostics baseline
This commit is contained in:
parent
c4fc6e0041
commit
f03cbb28af
@ -0,0 +1,153 @@
|
|||||||
|
package p.packer.doctor;
|
||||||
|
|
||||||
|
import p.packer.api.PackerOperationClass;
|
||||||
|
import p.packer.api.PackerOperationStatus;
|
||||||
|
import p.packer.api.PackerProjectContext;
|
||||||
|
import p.packer.api.assets.PackerAssetState;
|
||||||
|
import p.packer.api.diagnostics.PackerDiagnostic;
|
||||||
|
import p.packer.api.diagnostics.PackerDiagnosticCategory;
|
||||||
|
import p.packer.api.diagnostics.PackerDiagnosticSeverity;
|
||||||
|
import p.packer.api.doctor.PackerDoctorMode;
|
||||||
|
import p.packer.api.doctor.PackerDoctorRequest;
|
||||||
|
import p.packer.api.doctor.PackerDoctorResult;
|
||||||
|
import p.packer.api.doctor.PackerDoctorService;
|
||||||
|
import p.packer.api.workspace.GetAssetDetailsRequest;
|
||||||
|
import p.packer.api.workspace.ListAssetsRequest;
|
||||||
|
import p.packer.declarations.PackerAssetDetailsService;
|
||||||
|
import p.packer.workspace.FileSystemPackerWorkspaceService;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public final class FileSystemPackerDoctorService implements PackerDoctorService {
|
||||||
|
private final FileSystemPackerWorkspaceService workspaceService;
|
||||||
|
private final PackerAssetDetailsService detailsService;
|
||||||
|
|
||||||
|
public FileSystemPackerDoctorService() {
|
||||||
|
this(new FileSystemPackerWorkspaceService(), new PackerAssetDetailsService());
|
||||||
|
}
|
||||||
|
|
||||||
|
public FileSystemPackerDoctorService(
|
||||||
|
FileSystemPackerWorkspaceService workspaceService,
|
||||||
|
PackerAssetDetailsService detailsService) {
|
||||||
|
this.workspaceService = Objects.requireNonNull(workspaceService, "workspaceService");
|
||||||
|
this.detailsService = Objects.requireNonNull(detailsService, "detailsService");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PackerOperationClass operationClass() {
|
||||||
|
return PackerOperationClass.READ_ONLY;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PackerDoctorResult doctor(PackerDoctorRequest request) {
|
||||||
|
final PackerDoctorRequest doctorRequest = Objects.requireNonNull(request, "request");
|
||||||
|
final PackerProjectContext project = doctorRequest.project();
|
||||||
|
final var snapshot = workspaceService.listAssets(new ListAssetsRequest(project));
|
||||||
|
final List<PackerDiagnostic> diagnostics = new ArrayList<>();
|
||||||
|
final Set<String> safeFixes = new LinkedHashSet<>();
|
||||||
|
final Set<String> seenDiagnostics = new LinkedHashSet<>();
|
||||||
|
|
||||||
|
for (PackerDiagnostic diagnostic : snapshot.diagnostics()) {
|
||||||
|
addDiagnostic(diagnostics, seenDiagnostics, diagnostic);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var asset : snapshot.assets()) {
|
||||||
|
if (!includeAsset(doctorRequest.mode(), asset.state())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final String assetReference = asset.identity().assetId() == null
|
||||||
|
? relativeAssetRoot(project, asset.identity().assetRoot())
|
||||||
|
: Integer.toString(asset.identity().assetId());
|
||||||
|
final var details = detailsService.getAssetDetails(new GetAssetDetailsRequest(project, assetReference)).details();
|
||||||
|
for (PackerDiagnostic diagnostic : details.diagnostics()) {
|
||||||
|
addDiagnostic(diagnostics, seenDiagnostics, diagnostic);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doctorRequest.mode() == PackerDoctorMode.EXPANDED_WORKSPACE && asset.state() == PackerAssetState.ORPHAN) {
|
||||||
|
addDiagnostic(diagnostics, seenDiagnostics, new PackerDiagnostic(
|
||||||
|
PackerDiagnosticSeverity.WARNING,
|
||||||
|
PackerDiagnosticCategory.HYGIENE,
|
||||||
|
"Orphan asset is valid but not registered in the managed build set: " + relativeAssetRoot(project, asset.identity().assetRoot()),
|
||||||
|
asset.identity().assetRoot(),
|
||||||
|
false));
|
||||||
|
if (doctorRequest.includeSafeFixes() && details.summary().state() != PackerAssetState.INVALID) {
|
||||||
|
safeFixes.add("register_asset " + relativeAssetRoot(project, asset.identity().assetRoot()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doctorRequest.mode() == PackerDoctorMode.EXPANDED_WORKSPACE && isInsideQuarantine(project, asset.identity().assetRoot())) {
|
||||||
|
addDiagnostic(diagnostics, seenDiagnostics, new PackerDiagnostic(
|
||||||
|
PackerDiagnosticSeverity.INFO,
|
||||||
|
PackerDiagnosticCategory.HYGIENE,
|
||||||
|
"Asset root is currently quarantined and excluded from the active workspace surface.",
|
||||||
|
asset.identity().assetRoot(),
|
||||||
|
false));
|
||||||
|
}
|
||||||
|
|
||||||
|
details.inputsByRole().forEach((role, inputs) -> inputs.forEach(input -> {
|
||||||
|
if (Files.isRegularFile(input)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final boolean managed = asset.state() == PackerAssetState.MANAGED;
|
||||||
|
addDiagnostic(diagnostics, seenDiagnostics, new PackerDiagnostic(
|
||||||
|
managed ? PackerDiagnosticSeverity.ERROR : PackerDiagnosticSeverity.WARNING,
|
||||||
|
managed ? PackerDiagnosticCategory.STRUCTURAL : PackerDiagnosticCategory.HYGIENE,
|
||||||
|
"Declared input is missing for role '" + role + "': " + relativeEvidence(project, input),
|
||||||
|
input,
|
||||||
|
managed));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
final long blockingCount = diagnostics.stream().filter(PackerDiagnostic::blocking).count();
|
||||||
|
final long warningCount = diagnostics.stream().filter(diagnostic -> diagnostic.severity() == PackerDiagnosticSeverity.WARNING).count();
|
||||||
|
final PackerOperationStatus status = blockingCount > 0L ? PackerOperationStatus.FAILED
|
||||||
|
: diagnostics.isEmpty() ? PackerOperationStatus.SUCCESS : PackerOperationStatus.PARTIAL;
|
||||||
|
final String summary = blockingCount > 0L
|
||||||
|
? "Doctor found " + blockingCount + " blocking diagnostics and " + warningCount + " warnings."
|
||||||
|
: diagnostics.isEmpty()
|
||||||
|
? "Doctor found no diagnostics."
|
||||||
|
: "Doctor found " + diagnostics.size() + " diagnostics with no blockers.";
|
||||||
|
return new PackerDoctorResult(status, summary, diagnostics, List.copyOf(safeFixes));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean includeAsset(PackerDoctorMode mode, PackerAssetState state) {
|
||||||
|
if (mode == PackerDoctorMode.EXPANDED_WORKSPACE) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return state == PackerAssetState.MANAGED || state == PackerAssetState.INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addDiagnostic(List<PackerDiagnostic> diagnostics, Set<String> seenDiagnostics, PackerDiagnostic diagnostic) {
|
||||||
|
final String key = diagnostic.category() + "|" + diagnostic.severity() + "|" + diagnostic.message() + "|" + diagnostic.evidencePath() + "|" + diagnostic.blocking();
|
||||||
|
if (seenDiagnostics.add(key)) {
|
||||||
|
diagnostics.add(diagnostic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isInsideQuarantine(PackerProjectContext project, Path assetRoot) {
|
||||||
|
return assetRoot.toAbsolutePath().normalize()
|
||||||
|
.startsWith(project.rootPath().resolve("assets/.prometeu/quarantine").toAbsolutePath().normalize());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String relativeAssetRoot(PackerProjectContext project, Path assetRoot) {
|
||||||
|
return project.rootPath().resolve("assets").toAbsolutePath().normalize()
|
||||||
|
.relativize(assetRoot.toAbsolutePath().normalize())
|
||||||
|
.toString()
|
||||||
|
.replace('\\', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private String relativeEvidence(PackerProjectContext project, Path evidencePath) {
|
||||||
|
final Path normalizedEvidence = evidencePath.toAbsolutePath().normalize();
|
||||||
|
final Path projectRoot = project.rootPath().toAbsolutePath().normalize();
|
||||||
|
if (normalizedEvidence.startsWith(projectRoot)) {
|
||||||
|
return projectRoot.relativize(normalizedEvidence).toString().replace('\\', '/');
|
||||||
|
}
|
||||||
|
return normalizedEvidence.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,160 @@
|
|||||||
|
package p.packer.doctor;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import p.packer.api.PackerOperationStatus;
|
||||||
|
import p.packer.api.PackerProjectContext;
|
||||||
|
import p.packer.api.diagnostics.PackerDiagnosticCategory;
|
||||||
|
import p.packer.api.doctor.PackerDoctorMode;
|
||||||
|
import p.packer.api.doctor.PackerDoctorRequest;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
final class FileSystemPackerDoctorServiceTest {
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void managedWorldReportsBlockingDiagnosticsForMissingManagedInputs() throws Exception {
|
||||||
|
final Path projectRoot = createManagedProjectWithMissingInput();
|
||||||
|
final FileSystemPackerDoctorService service = new FileSystemPackerDoctorService();
|
||||||
|
|
||||||
|
final var result = service.doctor(new PackerDoctorRequest(
|
||||||
|
new PackerProjectContext("main", projectRoot),
|
||||||
|
PackerDoctorMode.MANAGED_WORLD,
|
||||||
|
false));
|
||||||
|
|
||||||
|
assertEquals(PackerOperationStatus.FAILED, result.status());
|
||||||
|
assertTrue(result.diagnostics().stream().anyMatch(diagnostic ->
|
||||||
|
diagnostic.category() == PackerDiagnosticCategory.STRUCTURAL
|
||||||
|
&& diagnostic.blocking()
|
||||||
|
&& diagnostic.message().contains("Declared input is missing")));
|
||||||
|
assertTrue(result.safeFixes().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void expandedWorkspaceReportsOrphanAssetsAndRegisterSafeFixes() throws Exception {
|
||||||
|
final Path projectRoot = createExpandedWorkspace();
|
||||||
|
final FileSystemPackerDoctorService service = new FileSystemPackerDoctorService();
|
||||||
|
|
||||||
|
final var result = service.doctor(new PackerDoctorRequest(
|
||||||
|
new PackerProjectContext("main", projectRoot),
|
||||||
|
PackerDoctorMode.EXPANDED_WORKSPACE,
|
||||||
|
true));
|
||||||
|
|
||||||
|
assertEquals(PackerOperationStatus.PARTIAL, result.status());
|
||||||
|
assertTrue(result.diagnostics().stream().anyMatch(diagnostic ->
|
||||||
|
diagnostic.category() == PackerDiagnosticCategory.HYGIENE
|
||||||
|
&& diagnostic.message().contains("Orphan asset is valid but not registered")));
|
||||||
|
assertTrue(result.safeFixes().contains("register_asset orphans/ui_sounds"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void managedWorldIgnoresOrphanOnlyHygieneFindings() throws Exception {
|
||||||
|
final Path projectRoot = tempDir.resolve("orphan-only");
|
||||||
|
final Path orphanRoot = projectRoot.resolve("assets/orphans/ui_sounds");
|
||||||
|
Files.createDirectories(orphanRoot);
|
||||||
|
Files.writeString(orphanRoot.resolve("asset.json"), """
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"name": "ui_sounds",
|
||||||
|
"type": "sound_bank",
|
||||||
|
"inputs": { "sources": ["confirm.wav"] },
|
||||||
|
"output": { "format": "SOUND/bank_v1", "codec": "RAW" },
|
||||||
|
"preload": { "enabled": false }
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
Files.writeString(orphanRoot.resolve("confirm.wav"), "beep");
|
||||||
|
|
||||||
|
final FileSystemPackerDoctorService service = new FileSystemPackerDoctorService();
|
||||||
|
|
||||||
|
final var result = service.doctor(new PackerDoctorRequest(
|
||||||
|
new PackerProjectContext("main", projectRoot),
|
||||||
|
PackerDoctorMode.MANAGED_WORLD,
|
||||||
|
true));
|
||||||
|
|
||||||
|
assertEquals(PackerOperationStatus.SUCCESS, result.status());
|
||||||
|
assertTrue(result.diagnostics().isEmpty());
|
||||||
|
assertTrue(result.safeFixes().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path createManagedProjectWithMissingInput() throws Exception {
|
||||||
|
final Path projectRoot = tempDir.resolve("managed-missing-input");
|
||||||
|
final Path assetRoot = projectRoot.resolve("assets/ui/atlas");
|
||||||
|
Files.createDirectories(assetRoot);
|
||||||
|
Files.createDirectories(projectRoot.resolve("assets/.prometeu"));
|
||||||
|
Files.writeString(assetRoot.resolve("asset.json"), """
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"name": "ui_atlas",
|
||||||
|
"type": "image_bank",
|
||||||
|
"inputs": { "sprites": ["sprites/confirm.png"] },
|
||||||
|
"output": { "format": "TILES/indexed_v1", "codec": "RAW" },
|
||||||
|
"preload": { "enabled": true }
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), """
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"next_asset_id": 2,
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"asset_id": 1,
|
||||||
|
"asset_uuid": "uuid-1",
|
||||||
|
"root": "ui/atlas"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
return projectRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path createExpandedWorkspace() throws Exception {
|
||||||
|
final Path projectRoot = tempDir.resolve("expanded");
|
||||||
|
final Path managedRoot = projectRoot.resolve("assets/ui/atlas");
|
||||||
|
Files.createDirectories(managedRoot);
|
||||||
|
Files.createDirectories(projectRoot.resolve("assets/.prometeu"));
|
||||||
|
Files.createDirectories(managedRoot.resolve("sprites"));
|
||||||
|
Files.writeString(managedRoot.resolve("asset.json"), """
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"name": "ui_atlas",
|
||||||
|
"type": "image_bank",
|
||||||
|
"inputs": { "sprites": ["sprites/confirm.png"] },
|
||||||
|
"output": { "format": "TILES/indexed_v1", "codec": "RAW" },
|
||||||
|
"preload": { "enabled": true }
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
Files.writeString(managedRoot.resolve("sprites/confirm.png"), "png");
|
||||||
|
Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), """
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"next_asset_id": 2,
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"asset_id": 1,
|
||||||
|
"asset_uuid": "uuid-1",
|
||||||
|
"root": "ui/atlas"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
final Path orphanRoot = projectRoot.resolve("assets/orphans/ui_sounds");
|
||||||
|
Files.createDirectories(orphanRoot);
|
||||||
|
Files.writeString(orphanRoot.resolve("asset.json"), """
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"name": "ui_sounds",
|
||||||
|
"type": "sound_bank",
|
||||||
|
"inputs": { "sources": ["confirm.wav"] },
|
||||||
|
"output": { "format": "SOUND/bank_v1", "codec": "RAW" },
|
||||||
|
"preload": { "enabled": false }
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
Files.writeString(orphanRoot.resolve("confirm.wav"), "beep");
|
||||||
|
return projectRoot;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user