mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-19 09:36:59 +03:00
Separate region drafts from measurements and reject stale source geometry
Automatic bands now produce a non-overwriting draft, not a measured spec. Bind successful region measurements to their source bytes so failed revisions cannot advance against old geometry. Preserve prior measurements and native fidelity checks. AI-assisted implementation and validation by Codex under maintainer direction.
This commit is contained in:
@@ -336,6 +336,8 @@ fn gate_spec(io: &Io, state: &Value) -> Gate {
|
||||
"no spec at {SPEC_PATH}: run comp-spec.mjs --comp {comp} --grid, name the regions, then --regions regions.json"
|
||||
)]);
|
||||
};
|
||||
if spec["draft"] == true { return Gate::fail(vec!["automatic region draft is not a measured element map; refine it with comp-spec --regions".into()]); }
|
||||
if let Some(issue) = crate::comp_spec::region_source_issue(io,&spec) { return Gate::fail(vec![issue]); }
|
||||
let regions = spec_regions(&spec);
|
||||
if regions.is_empty() {
|
||||
return Gate::fail(vec!["spec has no regions".into()]);
|
||||
@@ -496,6 +498,7 @@ fn gate_plates(io: &Io) -> Gate {
|
||||
|
||||
fn gate_plates_for(io: &Io, spec: &Value, only_id: Option<&str>) -> Gate {
|
||||
let s = self_cmd(io);
|
||||
if let Some(issue) = crate::comp_spec::region_source_issue(io,spec) { return Gate::fail(vec![issue]); }
|
||||
let regions = spec_regions(&spec);
|
||||
let raster_regions: Vec<Value> = regions.iter().filter(|r| r.get("medium").and_then(Value::as_str) == Some("raster")
|
||||
&& only_id.is_none_or(|id| r["id"] == id)).cloned().collect();
|
||||
|
||||
@@ -754,3 +754,51 @@ fn stripped_blurred_shifted_comp_pixels_cannot_pass_with_a_generation_prompt() {
|
||||
let texture_gate = gate_plates(&ws.io());
|
||||
assert!(!texture_gate.reasons.iter().any(|r|r.contains("is the comp crop")),"{:?}",texture_gate.reasons);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_region_revision_cannot_advance_using_an_older_spec() {
|
||||
let ws = Workspace::new();
|
||||
let image = r::create_image(64,64,[80,100,120,255]);
|
||||
ws.write("comp.png", &png_io::encode_png(&image, &[]).unwrap());
|
||||
let input = json!({"regions":[{"id":"photo","kind":"image","bleed":true,
|
||||
"pixelBox":{"x":0,"y":0,"w":64,"h":64},"note":"Full frame photograph"}]});
|
||||
ws.write("regions.json",input.to_string().as_bytes());
|
||||
let mut io=ws.io();
|
||||
let args=["--comp","comp.png","--regions","regions.json"].map(String::from);
|
||||
assert_eq!(crate::comp_spec::run(&args,&mut io),0);
|
||||
let state=json!({"comp":"comp.png"});
|
||||
assert!(gate_spec(&io,&state).ok);
|
||||
let measured=std::fs::read(ws.path.join(SPEC_PATH)).unwrap();
|
||||
ws.write("regions.json",b"{bad json");
|
||||
assert_eq!(crate::comp_spec::run(&args,&mut io),1);
|
||||
assert_eq!(std::fs::read(ws.path.join(SPEC_PATH)).unwrap(),measured);
|
||||
let gate=gate_spec(&io,&state);
|
||||
assert!(!gate.ok,"a rejected edit must not silently reuse the last measured map");
|
||||
assert!(gate.reasons.join(" ").contains("regions.json"));
|
||||
assert!(gate_plates(&io).reasons.join(" ").contains("regions.json"));
|
||||
ws.write("regions.json",input.to_string().as_bytes());
|
||||
assert!(gate_spec(&io,&state).ok);
|
||||
std::fs::remove_file(ws.path.join("regions.json")).unwrap();
|
||||
assert!(!gate_spec(&io,&state).ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_bands_are_a_draft_not_a_build_spec() {
|
||||
let ws=Workspace::new();
|
||||
let mut image=r::create_image(128,128,[255,255,255,255]);
|
||||
for y in (5..100).step_by(6) { r::fill_rect(&mut image,10.,y as f64,100.,3.,[0.,0.,0.,255.]); }
|
||||
ws.write("comp.png",&png_io::encode_png(&image,&[]).unwrap());
|
||||
let mut io=ws.io();
|
||||
let args=["--comp","comp.png","--auto"].map(String::from);
|
||||
assert_eq!(crate::comp_spec::run(&args,&mut io),0,"auto must produce a usable draft even on a busy comp");
|
||||
let draft=ws.path.join(".impeccable/build/regions.draft.json");
|
||||
assert!(draft.exists());
|
||||
assert_eq!(crate::comp_spec::run(&["--comp","comp.png","--regions",".impeccable/build/regions.draft.json"].map(String::from),&mut io),1);
|
||||
assert!(!ws.path.join(SPEC_PATH).exists());
|
||||
assert!(!gate_spec(&io,&json!({"comp":"comp.png"})).ok);
|
||||
ws.write(SPEC_PATH,&std::fs::read(&draft).unwrap());
|
||||
assert!(!gate_spec(&io,&json!({"comp":"comp.png"})).ok,"copying a draft to the spec path cannot validate it");
|
||||
ws.write(SPEC_PATH,b"previous accepted spec");
|
||||
assert_eq!(crate::comp_spec::run(&args,&mut io),1,"do not overwrite an edited draft");
|
||||
assert_eq!(std::fs::read(ws.path.join(SPEC_PATH)).unwrap(),b"previous accepted spec");
|
||||
}
|
||||
|
||||
@@ -893,11 +893,24 @@ fn resolve(io: &Io, p: &str) -> PathBuf {
|
||||
// ---- CLI -------------------------------------------------------------------
|
||||
|
||||
/// `impeccable comp-spec ...`
|
||||
/// A failed edit leaves the last valid measurements intact, but they cannot
|
||||
/// authorize a build against different source geometry.
|
||||
pub fn region_source_issue(io: &Io, spec: &Value) -> Option<String> {
|
||||
let source = spec.get("regionsSource")?;
|
||||
let Some(path) = source["path"].as_str() else {
|
||||
return Some("spec has invalid region source evidence; re-run comp-spec --regions".into());
|
||||
};
|
||||
let current = std::fs::read(resolve(io,path)).ok()
|
||||
.map(|bytes| format!("{:x}",Sha256::digest(&bytes)));
|
||||
if current.as_deref().is_some_and(|hash| Some(hash) == source["sha256"].as_str()) { return None; }
|
||||
Some(format!("region source {path} changed or is missing since measurement; fix it and re-run comp-spec --regions {path} before continuing"))
|
||||
}
|
||||
|
||||
pub fn run(argv: &[String], io: &mut Io) -> i32 {
|
||||
let spec_path = arg_or(argv, "spec", SPEC_PATH).to_string();
|
||||
if flag(argv, "help") || argv.is_empty() {
|
||||
io.out("REGION COORDINATES: use one of grid (coarse inclusive cells), box {x,y,w,h} (fractions of the comp, 0..1), or pixelBox {x,y,w,h} (whole pixels in the original comp). Use exact bounds when an element ends inside a grid cell; do not include neighbouring content.\n");
|
||||
io.out("usage: comp-spec.mjs --comp <png> --grid write .impeccable/build/comp-grid.png (10x10 labeled grid) + palette + bands\n comp-spec.mjs --comp <png> --regions <json> measure regions -> .impeccable/build/spec.json\n regions json: { \"regions\": [ { \"id\": \"art\", \"kind\": \"plate|image|texture|text|control|chrome\", \"grid\": \"E0:J4\", \"note\": \"...\" } ] }\n comp-spec.mjs --comp <png> --auto band regions when you have no regions file\n comp-spec.mjs --print the compact spec\n comp-spec.mjs --crop <id> [--out f] [--scale n] reference crop of a region (never a shipping asset)\n comp-spec.mjs --plate-prompt <id> [--background transparent|opaque|auto] the regeneration prompt for a raster region\n");
|
||||
io.out("usage: comp-spec.mjs --comp <png> --grid write .impeccable/build/comp-grid.png (10x10 labeled grid) + palette + bands\n comp-spec.mjs --comp <png> --regions <json> measure regions -> .impeccable/build/spec.json\n regions json: { \"regions\": [ { \"id\": \"art\", \"kind\": \"plate|image|texture|text|control|chrome\", \"grid\": \"E0:J4\", \"note\": \"...\" } ] }\n comp-spec.mjs --comp <png> --auto [--out f] write a band draft; refine into elements before --regions\n comp-spec.mjs --print the compact spec\n comp-spec.mjs --crop <id> [--out f] [--scale n] reference crop of a region (never a shipping asset)\n comp-spec.mjs --plate-prompt <id> [--background transparent|opaque|auto] the regeneration prompt for a raster region\n");
|
||||
return 0;
|
||||
}
|
||||
if flag(argv, "print") {
|
||||
@@ -1037,10 +1050,39 @@ pub fn run(argv: &[String], io: &mut Io) -> i32 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if flag(argv,"auto") && arg(argv,"regions").is_none() {
|
||||
if flag(argv,"spec") {
|
||||
io.err("comp-spec: --auto writes a draft, not a measured spec; use --out <draft.json> instead of --spec\n");
|
||||
return 1;
|
||||
}
|
||||
let draft_path = arg_or(argv,"out",".impeccable/build/regions.draft.json");
|
||||
let dest = resolve(io,draft_path);
|
||||
if dest == resolve(io,SPEC_PATH) {
|
||||
io.err("comp-spec: an automatic draft cannot replace the measured spec\n");
|
||||
return 1;
|
||||
}
|
||||
let mut draft = auto_regions(&comp);
|
||||
draft["draft"] = json!(true);
|
||||
draft["comp"] = json!(comp_path);
|
||||
if let Some(parent) = dest.parent() { let _ = std::fs::create_dir_all(parent); }
|
||||
use std::io::Write;
|
||||
let written = std::fs::OpenOptions::new().write(true).create_new(true).open(&dest)
|
||||
.and_then(|mut file| file.write_all(util::json_pretty(&draft).as_bytes()));
|
||||
if let Err(error) = written {
|
||||
io.err(&format!("comp-spec: cannot write draft {draft_path}: {error}; use a new --out path to preserve existing work\n"));
|
||||
return 1;
|
||||
}
|
||||
io.out(&format!("DRAFT {draft_path}: {} approximate horizontal bands, not an element map.\nRefine the bands into the visible elements, remove the draft flag, then run comp-spec --comp {comp_path} --regions {draft_path}. The measured spec and build state are unchanged.\n",draft["regions"].as_array().map_or(0,Vec::len)));
|
||||
return 0;
|
||||
}
|
||||
let regions_source;
|
||||
let regions_input: Value = if let Some(rf) = arg(argv, "regions") {
|
||||
match std::fs::read_to_string(resolve(io, rf)) {
|
||||
Ok(raw) => match serde_json::from_str(&raw) {
|
||||
Ok(v) => v,
|
||||
Ok(v) => {
|
||||
regions_source = json!({"path":rf,"sha256":format!("{:x}",Sha256::digest(raw.as_bytes()))});
|
||||
v
|
||||
},
|
||||
Err(e) => {
|
||||
io.err(&format!("comp-spec: cannot read regions {rf}: {e}\n"));
|
||||
return 1;
|
||||
@@ -1051,12 +1093,14 @@ pub fn run(argv: &[String], io: &mut Io) -> i32 {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
} else if flag(argv, "auto") {
|
||||
auto_regions(&comp)
|
||||
} else {
|
||||
io.err("comp-spec: pass --grid to get the coordinate grid, then --regions <json> (or --auto for band regions)\n");
|
||||
return 1;
|
||||
};
|
||||
if regions_input["draft"] == true {
|
||||
io.err("comp-spec: this is an automatic draft, not a measured element map; refine its bands into the visible elements before removing the draft flag\n");
|
||||
return 1;
|
||||
}
|
||||
let mut spec = match measure_regions(&comp, ®ions_input, comp_path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
@@ -1072,6 +1116,7 @@ pub fn run(argv: &[String], io: &mut Io) -> i32 {
|
||||
hasher.update(comp.height.to_le_bytes());
|
||||
hasher.update(&comp.data);
|
||||
spec["compSha256"] = json!(format!("{:x}", hasher.finalize()));
|
||||
spec["regionsSource"] = regions_source;
|
||||
if let Some(previous) = std::fs::read(&spec_out).ok()
|
||||
.and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok()) {
|
||||
preserve_typography(&mut spec, &previous);
|
||||
|
||||
@@ -449,7 +449,8 @@ retain their local-development trust behavior. See [bundle signing](BUNDLE-SIGNI
|
||||
|
||||
Ported from the former `skill/scripts/{comp-spec,comp-diff,font-match,build-phase}.mjs` (+ `lib/{png,raster,image-metrics,font-fingerprint,font-index,hero-checks}.mjs`) into the engine; invoked as `{{scripts_path}}/impeccable <verb>`. All four resolve paths against the process cwd. Printed commands spell the launcher via `IMPECCABLE_SELF` (default `impeccable`), so they name `{{scripts_path}}/impeccable <verb>`, never `node …mjs`. ISO `createdAt`/`startedAt` timestamps in stdout and written JSON are the only run-dependent output.
|
||||
|
||||
- **`comp-spec`** — turns an approved comp into a measured build spec. `--comp <png> --grid` writes `.impeccable/build/comp-grid.png` (10x10 labeled grid) and prints PALETTE/BANDS/NEXT; `--comp <png> --regions <json>` measures regions into `.impeccable/build/spec.json` (region box, sampled palette, medium, aspect, detail energy, plate path for raster kinds) and prints the spec; `--comp <png> --auto` derives band regions; `--print` prints the compact spec; `--crop <id> [--out f] [--scale n] [--raw]` writes a reference crop; `--plate-prompt <id>` prints the regeneration prompt. `--spec <path>` overrides the spec path (default `.impeccable/build/spec.json`). Validation refusals (stderr, exit 1) are the JS strings verbatim: a region with no id / duplicate id / no note, a code-kind region whose note names painted material, a code region over 25% of the comp, a grid span that is not `<colrow>:<colrow>`, uncovered ink cells without `allowUncovered`. spec.json is byte-identical to the JS output.
|
||||
- **`comp-spec`** — turns an approved comp into a measured build spec. `--comp <png> --grid` writes `.impeccable/build/comp-grid.png` (10x10 labeled grid) and prints PALETTE/BANDS/NEXT; `--comp <png> --regions <json>` measures regions into `.impeccable/build/spec.json` (region box, sampled palette, medium, aspect, detail energy, plate path for raster kinds) and prints the spec; `--comp <png> --auto [--out <draft.json>]` writes approximate bands to a new draft file (default `.impeccable/build/regions.draft.json`), without modifying the measured spec or build state; `--print` prints the compact spec; `--crop <id> [--out f] [--scale n] [--raw]` writes a reference crop; `--plate-prompt <id>` prints the regeneration prompt. `--spec <path>` overrides the spec path (default `.impeccable/build/spec.json`). Validation refusals (stderr, exit 1) are the JS strings verbatim: a region with no id / duplicate id / no note, a code-kind region whose note names painted material, a code region over 25% of the comp, a grid span that is not `<colrow>:<colrow>`, uncovered ink cells without `allowUncovered`. spec.json is byte-identical to the JS output.
|
||||
Automatic drafts require decomposition into actual visible elements before measurement; a draft flag blocks accidental submission, and existing draft files are never overwritten. Successful `--regions` measurements record the source path and SHA-256. Spec and plate gates reject changed or missing source files, so a failed region edit cannot silently reuse the previous measurements. Legacy specs without source metadata remain readable.
|
||||
Region inputs support three coordinate representations: inclusive `grid` cells, normalized `box: {x,y,w,h}`, or `pixelBox: {x,y,w,h}` in whole original-comp pixels. Pixel boxes cannot be combined with the other formats and must be positive-sized and contained in the comp; they avoid snapping an asset boundary to a neighbouring grid cell. Foreground UI excluded from a plate reference is excluded at the same aligned coordinates from the candidate during scoring; unmasked asset bytes still undergo provenance checks.
|
||||
- **`comp-diff`** — `--comp <png> --build <png> [--spec spec.json] [--out-dir dir] [--align top|stretch|cover] [--label name] [--threshold t] [--json] [--no-files]`. Scores structure / color / detail / bands and per-region verdicts (`match`/`drift`/`missing`/`contradicted`); writes `side-by-side.png`, `heatmap.png`, `regions/<id>.png`, and `report.json` under `--out-dir` (unless `--no-files`); prints the text summary or, with `--json`, the report. Exit 0 measured, 1 usage/unreadable input, 3 below `--threshold`. The JSON report and text summary are byte-identical to the JS.
|
||||
- **`font-match`** — `--measure <text-region-id>` fingerprints the comp crop of a text region (cap height, width/weight class, shape vector), records it on the region's `type` block in the spec, and prints the MEASURE line (pure; byte-identical to the JS). `--rank <id> [--candidates "Family:700,…"] [--text "…"] [--transform …] [--category …]` additionally renders candidate faces in a headless browser and ranks them by fingerprint distance, writing a stamped `chosen` face onto the region and a proof sheet under `.impeccable/build/font-match/`. **Browser**: an installed Chrome discovered and driven over CDP (the same browser the URL engine uses; the JS used Playwright/Puppeteer). With no browser resolvable, the catalog's nearest face is recorded (source `catalog`, estimated size) or, with no catalog either, the MEASURE line stands — matching the JS fallbacks; the sha1 `chosen` stamp is byte-identical. Screenshots vary by Chrome version, so the rendered ranking is not byte-stable.
|
||||
|
||||
@@ -191,3 +191,13 @@ files, measurements, exit codes and frozen function vectors were not replaced.
|
||||
The plate gate applies reference UI exclusions symmetrically after alignment;
|
||||
regressions separately verify hidden-pixel invariance, visible missing-art
|
||||
rejection, raw comp-copy rejection and unchanged candidate-check state.
|
||||
|
||||
## Recorded 2026-09-18: draft region authoring and source binding
|
||||
|
||||
`comp-spec-usage` now describes --auto as a draft writer. In
|
||||
`comp-spec-regions`, the only file-content addition is regionsSource with the
|
||||
input path and fixture-derived SHA-256; all existing fields and measurements
|
||||
were compared unchanged. No frozen function vectors changed. New Rust
|
||||
regressions verify automatic drafts do not overwrite specs or existing drafts,
|
||||
cannot be submitted unchanged, and a rejected/missing region-source revision
|
||||
cannot advance the build using the last successful measurements.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"stdout": "REGION COORDINATES: use one of grid (coarse inclusive cells), box {x,y,w,h} (fractions of the comp, 0..1), or pixelBox {x,y,w,h} (whole pixels in the original comp). Use exact bounds when an element ends inside a grid cell; do not include neighbouring content.\nusage: comp-spec.mjs --comp <png> --grid write .impeccable/build/comp-grid.png (10x10 labeled grid) + palette + bands\n comp-spec.mjs --comp <png> --regions <json> measure regions -> .impeccable/build/spec.json\n regions json: { \"regions\": [ { \"id\": \"art\", \"kind\": \"plate|image|texture|text|control|chrome\", \"grid\": \"E0:J4\", \"note\": \"...\" } ] }\n comp-spec.mjs --comp <png> --auto band regions when you have no regions file\n comp-spec.mjs --print the compact spec\n comp-spec.mjs --crop <id> [--out f] [--scale n] reference crop of a region (never a shipping asset)\n comp-spec.mjs --plate-prompt <id> [--background transparent|opaque|auto] the regeneration prompt for a raster region\n",
|
||||
"stdout": "REGION COORDINATES: use one of grid (coarse inclusive cells), box {x,y,w,h} (fractions of the comp, 0..1), or pixelBox {x,y,w,h} (whole pixels in the original comp). Use exact bounds when an element ends inside a grid cell; do not include neighbouring content.\nusage: comp-spec.mjs --comp <png> --grid write .impeccable/build/comp-grid.png (10x10 labeled grid) + palette + bands\n comp-spec.mjs --comp <png> --regions <json> measure regions -> .impeccable/build/spec.json\n regions json: { \"regions\": [ { \"id\": \"art\", \"kind\": \"plate|image|texture|text|control|chrome\", \"grid\": \"E0:J4\", \"note\": \"...\" } ] }\n comp-spec.mjs --comp <png> --auto [--out f] write a band draft; refine into elements before --regions\n comp-spec.mjs --print the compact spec\n comp-spec.mjs --crop <id> [--out f] [--scale n] reference crop of a region (never a shipping asset)\n comp-spec.mjs --plate-prompt <id> [--background transparent|opaque|auto] the regeneration prompt for a raster region\n",
|
||||
"stderr": "",
|
||||
"exit": 0,
|
||||
"signal": null,
|
||||
|
||||
Reference in New Issue
Block a user