mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-13 00:19:03 +02:00
feat(bin): gstack-design-detect wrapper + design_detector config key
bin/gstack-design-detect.ts finds and runs an impeccable engine the user installed; it never installs, downloads, or executes anything that could download. `probe` reads only: config (design_detector off → DISABLED), IMPECCABLE_BIN (absolute, realpath outside the repo and cwd), a PATH walk (absolute entries outside the repo; a #! shim counts as launcher-present, never READY), the ~/.impeccable/bin/<newest semver>/ cache, and the engine installed beside a skill launcher (scripts/bin/<os>-<arch>/impeccable, the layout a real install produced). It reports IMPECCABLE_SKILL, host-aware IMPECCABLE_HOOK (+ HOOK_OTHER), the ignore lists from .impeccable/config*.json, IMPECCABLE_ENGINE_UNTESTED for versions outside the fixture set, and a hint only when a launcher exists without its engine. `scan` re-probes, refuses URLs and anything outside the repo root or the design-report allow-list (realpath, so symlinks cannot escape), derives `--changed <base>` targets NUL-safely through git and lib/frontend-scope.ts, batches 100 absolute paths per engine call with stdin ignored, a SIGKILL timeout, a 50 MB stdout cap, and sanitized length-capped fields, then prints one normalized JSON document (--format gstack) or the engine's bytes (--format raw); DETECT_TOP (fenced as untrusted content), DETECT_SUMMARY, and DETECT_EXIT go to stderr; exit code passes through with 1 over 2 over 0; exit 3 is a gstack bug. `rules` prints the mapped set. Every run appends a content-free line to the local analytics file. lib/design-detect-contract.ts owns every sentinel string, the limits, and the normalized-finding shape (pure module); test/design-detect-contract.test.ts asserts every sentinel-shaped token the agent can read exists there. lib/frontend-scope.ts mirrors gstack-diff-scope's frontend arm, pinned by a parity test that runs the bash script. bin/gstack-config gains design_detector (auto | off, default auto, invalid values rejected with the file unchanged). test/fixtures/fake-impeccable.ts is the env-driven engine stand-in; test/gstack-design-detect.test.ts covers READY/NOT_CACHED/ NOT_AVAILABLE/DISABLED, env trust (.env never loaded, in-repo IMPECCABLE_BIN ignored), newest-semver cache, hook and ignore detection, refusals, exit passthrough, raw byte-identity, normalization, the display cap, timeout, parse errors, diagnostics, --changed, and analytics. The egress scanner test records the wrapper as a documented non-sink. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
71505f8374
commit
3867dae355
@@ -0,0 +1,116 @@
|
||||
// lib/design-detect-contract.ts — the one owner of the design-detector vocabulary.
|
||||
//
|
||||
// Pure module: no I/O, no imports from scripts/. Every sentinel the wrapper
|
||||
// (bin/gstack-design-detect.ts) or the DESIGN.md tool (bin/gstack-design-md.ts)
|
||||
// prints, and every one the skill prose reads, is a constant here, so the two
|
||||
// sides cannot drift: gen-time resolvers import these strings into SKILL.md
|
||||
// prose, the bins import them at runtime, and test/design-detect-contract.test.ts
|
||||
// asserts that every sentinel-shaped token in generated docs exists here.
|
||||
//
|
||||
// probe ──► one of: IMPECCABLE_READY | IMPECCABLE_NOT_CACHED | IMPECCABLE_NOT_AVAILABLE | IMPECCABLE_DISABLED
|
||||
// ──► always: IMPECCABLE_SKILL, IMPECCABLE_HOOK, IMPECCABLE_IGNORED_RULES, IMPECCABLE_IGNORED_FILES
|
||||
// ──► maybe: IMPECCABLE_HOOK_OTHER, IMPECCABLE_CONFIG_UNREADABLE, IMPECCABLE_ENV_IGNORED,
|
||||
// IMPECCABLE_ENGINE_UNTESTED, DESIGN_DETECTOR_HINT
|
||||
// scan ──► stdout: one JSON document (--format gstack) or engine bytes (--format raw)
|
||||
// ──► stderr: DETECT_TOP block, DETECT_SUMMARY, DETECT_EXIT, DETECT_REFUSED / DETECT_NO_TARGETS /
|
||||
// DETECT_TIMEOUT / DETECT_PARSE_ERROR / DETECT_OUTPUT_TOO_LARGE
|
||||
// any ──► exit 3 + DESIGN_DETECT_INTERNAL_ERROR: a gstack bug, never retried
|
||||
|
||||
export const SENTINEL = {
|
||||
READY: 'IMPECCABLE_READY',
|
||||
NOT_CACHED: 'IMPECCABLE_NOT_CACHED',
|
||||
NOT_AVAILABLE: 'IMPECCABLE_NOT_AVAILABLE',
|
||||
DISABLED: 'IMPECCABLE_DISABLED',
|
||||
SKILL: 'IMPECCABLE_SKILL',
|
||||
HOOK: 'IMPECCABLE_HOOK',
|
||||
HOOK_OTHER: 'IMPECCABLE_HOOK_OTHER',
|
||||
IGNORED_RULES: 'IMPECCABLE_IGNORED_RULES',
|
||||
IGNORED_FILES: 'IMPECCABLE_IGNORED_FILES',
|
||||
CONFIG_UNREADABLE: 'IMPECCABLE_CONFIG_UNREADABLE',
|
||||
ENV_IGNORED: 'IMPECCABLE_ENV_IGNORED',
|
||||
ENGINE_UNTESTED: 'IMPECCABLE_ENGINE_UNTESTED',
|
||||
HINT: 'DESIGN_DETECTOR_HINT',
|
||||
DETECT_EXIT: 'DETECT_EXIT',
|
||||
DETECT_EXIT_CODE: 'DETECT_EXIT_CODE',
|
||||
DETECT_SUMMARY: 'DETECT_SUMMARY',
|
||||
DETECT_TOP: 'DETECT_TOP',
|
||||
DETECT_REFUSED: 'DETECT_REFUSED',
|
||||
DETECT_NO_TARGETS: 'DETECT_NO_TARGETS',
|
||||
DETECT_TIMEOUT: 'DETECT_TIMEOUT',
|
||||
DETECT_PARSE_ERROR: 'DETECT_PARSE_ERROR',
|
||||
DETECT_OUTPUT_TOO_LARGE: 'DETECT_OUTPUT_TOO_LARGE',
|
||||
INTERNAL_ERROR: 'DESIGN_DETECT_INTERNAL_ERROR',
|
||||
DOM_DUMP_REDACTION_BLOCKED: 'DOM_DUMP_REDACTION_BLOCKED',
|
||||
DOM_DUMP_TOO_LARGE: 'DOM_DUMP_TOO_LARGE',
|
||||
DESIGN_MD_FORMAT: 'DESIGN_MD_FORMAT',
|
||||
DESIGN_MD_CONVERT_REFUSED: 'DESIGN_MD_CONVERT_REFUSED',
|
||||
DESIGN_MD_INTERNAL_ERROR: 'DESIGN_MD_INTERNAL_ERROR',
|
||||
DESIGN_MD_TOKEN_REF_INVALID: 'DESIGN_MD_TOKEN_REF_INVALID',
|
||||
} as const;
|
||||
|
||||
export type SentinelName = keyof typeof SENTINEL;
|
||||
|
||||
/** Engine versions the committed fixtures were captured from. */
|
||||
export const TESTED_ENGINE_VERSIONS: readonly string[] = ['0.1.3'];
|
||||
|
||||
/** Rules the engine reports but never counts (they never change its exit code). */
|
||||
export const ADVISORY_RULE_IDS: readonly string[] = ['em-dash-overuse'];
|
||||
|
||||
export const DETECT_LIMITS = {
|
||||
/** default engine wall clock; GSTACK_DESIGN_DETECT_TIMEOUT_MS overrides */
|
||||
timeoutMs: 120_000,
|
||||
/** absolute paths per engine invocation */
|
||||
batch: 100,
|
||||
/** engine stdout above this is DETECT_OUTPUT_TOO_LARGE */
|
||||
stdoutBytes: 50 * 1024 * 1024,
|
||||
/** normalized findings kept; the rest is `truncated: true` */
|
||||
findings: 5_000,
|
||||
/** locations printed in the DETECT_TOP block */
|
||||
topLocations: 50,
|
||||
/** rendered-DOM dump above this is DOM_DUMP_TOO_LARGE */
|
||||
domDumpBytes: 10 * 1024 * 1024,
|
||||
field: { id: 64, message: 120, snippet: 120, value: 200, file: 4096, diagnostic: 400 },
|
||||
} as const;
|
||||
|
||||
/** Markers around any engine text the skill may quote (page text can echo through it). */
|
||||
export const UNTRUSTED_BEGIN = '═══ BEGIN UNTRUSTED CONTENT (design detector output) ═══';
|
||||
export const UNTRUSTED_END = '═══ END UNTRUSTED CONTENT ═══';
|
||||
|
||||
export interface NormalizedFinding {
|
||||
/** catalog id (equals impeccableId when mapped; the engine's id, sanitized, when not) */
|
||||
id: string;
|
||||
impeccableId: string;
|
||||
file: string;
|
||||
line: number;
|
||||
snippet: string;
|
||||
value?: string;
|
||||
message: string;
|
||||
category: string;
|
||||
kind: 'slop' | 'quality' | 'unknown';
|
||||
impact: 'high' | 'medium' | 'polish';
|
||||
tier: 'auto-fix' | 'ask' | 'possible';
|
||||
handoff?: string;
|
||||
advisory: boolean;
|
||||
unmapped?: true;
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
schemaVersion: 1;
|
||||
engine: string;
|
||||
engineVersion: string;
|
||||
targets: number;
|
||||
/** engine exit code after precedence (1 over 2 over 0) */
|
||||
exit: number;
|
||||
total: number;
|
||||
counted: number;
|
||||
advisory: number;
|
||||
/** rule ids the project config ignores (never present in findings) */
|
||||
ignoredRules: string[];
|
||||
byRule: Record<string, number>;
|
||||
findings: NormalizedFinding[];
|
||||
truncated: boolean;
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
/** The bash a skill renders after a scan so exit 2 (findings) never aborts the block. */
|
||||
export const DETECT_EXIT_ECHO = `; echo "${SENTINEL.DETECT_EXIT_CODE}=$?"`;
|
||||
@@ -0,0 +1,31 @@
|
||||
// lib/frontend-scope.ts — which repo paths count as frontend.
|
||||
//
|
||||
// Pure module: no I/O, no imports from scripts/. The patterns mirror the
|
||||
// `m_frontend` arm of bin/gstack-diff-scope (the bash source of truth for
|
||||
// SCOPE_FRONTEND); test/frontend-scope.test.ts pins the two against the same
|
||||
// sample paths so they cannot drift. bin/gstack-design-detect.ts uses this to
|
||||
// derive `scan --changed <base>` targets without consuming a shell-split list.
|
||||
|
||||
const EXTENSIONS = new Set([
|
||||
'.css', '.scss', '.less', '.sass', '.pcss',
|
||||
'.tsx', '.jsx', '.vue', '.svelte', '.astro',
|
||||
'.erb', '.haml', '.slim', '.hbs', '.ejs',
|
||||
'.html',
|
||||
]);
|
||||
|
||||
const BASENAME_PREFIXES = ['tailwind.config.', 'postcss.config.'];
|
||||
|
||||
/** Repo-relative path (forward slashes) → is it a frontend file per gstack-diff-scope? */
|
||||
export function isFrontendPath(relPath: string): boolean {
|
||||
const rel = relPath.replace(/\\/g, '/').replace(/^\.\//, '');
|
||||
const base = rel.slice(rel.lastIndexOf('/') + 1);
|
||||
const dot = base.lastIndexOf('.');
|
||||
const ext = dot >= 0 ? base.slice(dot).toLowerCase() : '';
|
||||
if (EXTENSIONS.has(ext)) return true;
|
||||
if (BASENAME_PREFIXES.some(p => base.startsWith(p))) return true;
|
||||
if (rel.startsWith('app/views/')) return true;
|
||||
if (rel.includes('/components/')) return true;
|
||||
if (rel.startsWith('styles/') || rel.startsWith('css/')) return true;
|
||||
if (rel.startsWith('app/assets/stylesheets/')) return true;
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user