mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +03:00
Surface the build-path finding in doctor, and keep cwd out of the lookup
Round two of review findings, all four valid. `doctor` builds its own finding list and never called `checkBuildPathUnset`, so `config-build-path-unset` could not appear in the report even though doctor.md documents it. That is also the only path left once stalenessCheck is off, which is exactly when someone is looking for it. The lookup chain included `process.cwd()`, which lets an ambient invoking directory decide another project's workflow: run from workspace A with --target resolving onto workspace B, and B inherited A's buildPath ahead of the repository default. The chain is now the resolved project then the repo root, matching `checkBuildPathUnset` exactly; cwd stands in only when no project resolved at all. Two prose contradictions, both mine. new-work said to write the value "when the user says yes" and then to "record the answer either way", which reads as persist-on-yes-only and leaves the decline to be asked again next session. It now says the write always happens and the answer picks the value. The README still pointed existing projects at re-running init, which is the problem this PR exists to solve; it now names the toggle as the migration path. The workspace-isolation test earned a correction of its own: the first version passed a relative --target, which resolves against the caller's cwd and puts projectRoot back on the calling workspace, so it asserted nothing. Written with AI assistance (Claude Code). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c0e7f2d778
commit
816ffe92d0
@@ -369,7 +369,9 @@ When a new surface gets designed, Impeccable either generates a full-fidelity co
|
||||
{ "buildPath": "comp" }
|
||||
```
|
||||
|
||||
The values are `comp` and `code`, and nothing else is read. Set it in the gitignored `.impeccable/config.local.json` to override the team's committed value on one machine, which is what you want when your harness has no image generation. Whatever is recorded is a default rather than a lock: every decision page carries a footer toggle, and flipping it binds that session only. The choice appears at all only where image generation is available, since without it there is nothing to comp.
|
||||
The values are `comp` and `code`, and nothing else is read. Set it in the gitignored `.impeccable/config.local.json` to override the team's committed value on one machine, which is what you want when your harness has no image generation. In a monorepo, commit it once at the repo root and any workspace that wants something else sets its own. The choice appears at all only where image generation is available, since without it there is nothing to comp.
|
||||
|
||||
You do not have to re-run `init` to set it on a project that predates the setting, and you do not have to edit the file by hand either. Whatever is recorded is a default rather than a lock: every decision page carries a footer toggle, and flipping it binds that session only. Flip it on a project that has recorded nothing and Impeccable asks once, after the round, whether to keep it, then writes your answer. That is the whole migration path for an existing project: use the toggle when the default is wrong, and answer the question that follows.
|
||||
|
||||
Codex requires one platform step that Impeccable cannot safely skip: open `/hooks` after install or update and approve the project hook. There is no Codex marketplace/plugin install flow for this hook.
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ The standing exit: every direction round offers one quiet, permanent alternative
|
||||
|
||||
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity, produced under the comp discipline in [visualize.md](visualize.md): the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way; visualize.md's self-checks bind decision comps identically. Generation takes the same time at any fidelity, so an unfinished draft pays draft quality for comp cost; fairness between cards comes from equal fidelity in each card's own grammar, one surface, one aspect, never from shared unfinishedness. The frame's aspect is the surface's own: a native app or mobile-first surface comps portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen comped landscape is a broken frame, not a neutral default. Produce in the order the user reads, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: on a comp-led build it enters the comp round as compositional option one, and on a code-led build it returns at the finish review as the critique reference, what the image dared that the build did not. The unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images. <!-- rule:skill-decision-comps-full-fidelity --> <!-- rule:skill-salience-parity -->
|
||||
|
||||
The execution contract, comp-led or code-led, is a workflow preference, not a per-surface decision, so no round asks it: the recorded default rides every round and the page's toggle handles the exception. Read the default from `.impeccable/config.json` (`buildPath`), with the gitignored `.impeccable/config.local.json` winning where one machine differs from the team's committed value; with neither, comp-led is the default whenever image generation exists. Author every direction and surface payload with `buildPath: { "value": <default>, "toggle": true }`; the page renders a footer toggle with the trade stated beside it, and the ANSWER returns `buildPath` plus `buildPathFlipped`. A flipped value binds that session only and is never written back, with one exception, and it is the only thing inside a round that earns a question about this preference (init records it up front on projects that get the chance): when `buildPathFlipped` comes back true on a project that records no `buildPath` at all, ask once after the round closes whether to keep it as the standing default, and write it to `.impeccable/config.json` when the user says yes. Ask on the flip and never on the untouched default, because a user who left the toggle alone has told you nothing. Record the answer either way: "no, just this once" means the standing default is the one they flipped away from, so write that value. A declined offer nothing writes down is an offer the next session makes again. When the user asks in words to change the standing default, update the file without asking. **Comp-led**: the chosen card's comp is law, generated before building when it does not exist yet, and the finish review audits the build against it; boldest composition on the table, fix rounds expected; comp-led makes the comp non-optional, no silent skipping. **Code-led**: no comp of this page and no apology for it; the QUALITY BAR boards still calibrate finish, and the ambition moves into the written contract, the FIRST VIEWPORT block plus a named signature interaction and motion grammar, which the finish reviewer audits in behavior; code-led is not a discount on commitment, the direction still lands fully committed in code. A code-led round still declares each card's comp path as a flip reserve: when the user flips the toggle to comp mid-round, `--wait` returns once with BUILD PATH FLIPPED while the page shimmers the slots; generate each open card's comp into its declared path then, lead first, and wait again. The flip back is free, and a comp that already rendered rides at the finish review as the critique reference. Without image generation there is no toggle and no choice: code-led is the only path, stated in one line rather than asked. The old two-card execution-contract round is retired; `followup: true` remains the general mechanism for delivering any later round over the same table via `--update`. <!-- rule:skill-build-path-round -->
|
||||
The execution contract, comp-led or code-led, is a workflow preference, not a per-surface decision, so no round asks it: the recorded default rides every round and the page's toggle handles the exception. Read the default from `.impeccable/config.json` (`buildPath`), with the gitignored `.impeccable/config.local.json` winning where one machine differs from the team's committed value; with neither, comp-led is the default whenever image generation exists. Author every direction and surface payload with `buildPath: { "value": <default>, "toggle": true }`; the page renders a footer toggle with the trade stated beside it, and the ANSWER returns `buildPath` plus `buildPathFlipped`. A flipped value binds that session only and is never written back, with one exception, and it is the only thing inside a round that earns a question about this preference (init records it up front on projects that get the chance): when `buildPathFlipped` comes back true on a project that records no `buildPath` at all, ask once after the round closes whether to keep it as the standing default. Either answer ends in a write to `.impeccable/config.json`; the answer picks the value, never whether to record one. Yes writes the flipped value, and "no, just this once" writes the value they flipped away from, which is the standing default they just confirmed by declining. Ask on the flip and never on the untouched default, because a user who left the toggle alone has told you nothing. A declined offer nothing writes down is an offer the next session makes again. When the user asks in words to change the standing default, update the file without asking. **Comp-led**: the chosen card's comp is law, generated before building when it does not exist yet, and the finish review audits the build against it; boldest composition on the table, fix rounds expected; comp-led makes the comp non-optional, no silent skipping. **Code-led**: no comp of this page and no apology for it; the QUALITY BAR boards still calibrate finish, and the ambition moves into the written contract, the FIRST VIEWPORT block plus a named signature interaction and motion grammar, which the finish reviewer audits in behavior; code-led is not a discount on commitment, the direction still lands fully committed in code. A code-led round still declares each card's comp path as a flip reserve: when the user flips the toggle to comp mid-round, `--wait` returns once with BUILD PATH FLIPPED while the page shimmers the slots; generate each open card's comp into its declared path then, lead first, and wait again. The flip back is free, and a comp that already rendered rides at the finish review as the critique reference. Without image generation there is no toggle and no choice: code-led is the only path, stated in one line rather than asked. The old two-card execution-contract round is retired; `followup: true` remains the general mechanism for delivering any later round over the same table via `--update`. <!-- rule:skill-build-path-round -->
|
||||
|
||||
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. Open the QUALITY BAR board and hero for the world you build the moment the choice lands, even if you viewed another card earlier; the ANSWER line names the chosen card's images (when the harness only reads files or runs sandboxed, download them into the workspace and open the relative path; sandboxed viewers reject absolute paths outside it). They set the craft level the build must reach, a rendered reference's finish, commitment, and art direction, never the composition; your surface serves this product.
|
||||
|
||||
|
||||
@@ -1299,14 +1299,19 @@ function readBuildPathAt(root) {
|
||||
return value ? { value, source } : null;
|
||||
}
|
||||
|
||||
// Roots in precedence order, nearest first: the active workspace decides, and
|
||||
// Roots in precedence order, nearest first: the resolved project decides, and
|
||||
// the repo root is the fallback a monorepo commits once for every app in it.
|
||||
// `checkBuildPathUnset` already reads both, so leaving repoRoot out here made
|
||||
// the two disagree: the finding stayed silent because a value existed while the
|
||||
// directive never named it, which is the one combination nobody can debug.
|
||||
// `checkBuildPathUnset` reads exactly these two, and the pair has to match:
|
||||
// when they disagree the finding goes silent because a value exists while the
|
||||
// directive never names it, which is the one combination nobody can debug.
|
||||
//
|
||||
// The invoking directory is deliberately not in the chain. With `--target`
|
||||
// selecting another workspace, cwd is the caller's app, not the target's, and
|
||||
// letting it rank above the repo root hands one workspace another's workflow.
|
||||
// It stands in only when no project resolved at all.
|
||||
function appendBuildPathDirective(parts, ctx) {
|
||||
const roots = [...new Set(
|
||||
[ctx?.projectRoot, process.cwd(), ctx?.repoRoot].filter(Boolean).map((root) => path.resolve(root)),
|
||||
[ctx?.projectRoot || process.cwd(), ctx?.repoRoot].filter(Boolean).map((root) => path.resolve(root)),
|
||||
)];
|
||||
for (const root of roots) {
|
||||
const found = readBuildPathAt(root);
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
stampProductSchema,
|
||||
} from './lib/artifact-schema.mjs';
|
||||
import {
|
||||
checkBuildPathUnset,
|
||||
checkConfig,
|
||||
checkDesignSidecar,
|
||||
checkNativePlatformEvidence,
|
||||
@@ -120,6 +121,7 @@ async function collect(cwd, targetOptions) {
|
||||
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
|
||||
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
|
||||
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...checkHookInstallation({
|
||||
|
||||
@@ -1123,6 +1123,26 @@ describe('context.mjs CLI', () => {
|
||||
write('apps/dashboard/.impeccable/config.json', JSON.stringify({ buildPath: 'comp' }));
|
||||
assert.match(runFromWorkspace().stdout, /BUILD_PATH_DEFAULT: comp/);
|
||||
});
|
||||
|
||||
// Running from one workspace while targeting another must not hand the
|
||||
// target the caller's preference. The invoking directory is not evidence
|
||||
// about the project being worked on.
|
||||
it('does not let the invoking workspace lend its value to the target', () => {
|
||||
writeWorkspace();
|
||||
write('apps/marketing/src/App.jsx', 'export default function App() { return "m"; }\n');
|
||||
write('.impeccable/config.json', JSON.stringify({ buildPath: 'code' }));
|
||||
write('apps/marketing/.impeccable/config.json', JSON.stringify({ buildPath: 'comp' }));
|
||||
// Absolute, so the target actually resolves onto dashboard. A relative
|
||||
// path would be read against the caller's cwd, which resolves the
|
||||
// project back to marketing and tests nothing.
|
||||
const res = spawnSync(process.execPath, [SCRIPT_PATH, '--target', path.join(scratch, 'apps', 'dashboard', 'src', 'App.jsx')], {
|
||||
cwd: path.join(scratch, 'apps', 'marketing'),
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
|
||||
});
|
||||
// The repo-root default, not marketing's comp.
|
||||
assert.match(res.stdout, /BUILD_PATH_DEFAULT: code/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user