Bug fixes
This commit is contained in:
93
.agents/skills/impeccable/scripts/lib/artifact-schema.mjs
Normal file
93
.agents/skills/impeccable/scripts/lib/artifact-schema.mjs
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Schema versions for the artifacts Impeccable writes, plus the readers and
|
||||
* writers for the PRODUCT.md provenance stamp.
|
||||
*
|
||||
* Why schema versions rather than the skill version: a PRODUCT.md written by
|
||||
* v4.0.0 is not stale under v4.0.1, so stamping the release version would make
|
||||
* every artifact "old" on every patch. A schema version changes only when the
|
||||
* shape changes, which is exactly when a migration is owed. It also gives the
|
||||
* writing flows a literal constant to copy instead of a value they would have
|
||||
* to look up.
|
||||
*
|
||||
* DESIGN.md deliberately carries no stamp. It follows the external
|
||||
* design.md spec that Stitch's linter validates, and an extra frontmatter key
|
||||
* risks failing that lint for no gain: every DESIGN.md staleness signal
|
||||
* (sidecar schema version, sidecar mtime, section coverage, git drift) is
|
||||
* measurable without one.
|
||||
*/
|
||||
|
||||
/** PRODUCT.md as init.md writes it today: the ten-section v4 record. */
|
||||
export const PRODUCT_SCHEMA_VERSION = 1;
|
||||
|
||||
/** `.impeccable/design.json`, as documented in reference/document.md Step 4b. */
|
||||
export const DESIGN_SIDECAR_SCHEMA_VERSION = 2;
|
||||
|
||||
/**
|
||||
* Sections init.md added in v4. A PRODUCT.md carrying none of them, and no
|
||||
* stamp, predates the current record. Used only as a fallback: an explicit
|
||||
* stamp always wins.
|
||||
*/
|
||||
export const PRODUCT_V4_SECTIONS = Object.freeze([
|
||||
'Positioning',
|
||||
'Operating Context',
|
||||
'Evidence on Hand',
|
||||
'Product Principles',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Headings Impeccable used to read and no longer does, with the reason. The
|
||||
* agent needs the reason: told only that a field is deprecated it tends to
|
||||
* preserve it "just in case", which is how a v3 register value keeps steering
|
||||
* v4 output.
|
||||
*/
|
||||
export const PRODUCT_DEPRECATED_SECTIONS = Object.freeze({
|
||||
Register: 'v4 replaced the brand/product register axis with the four visitor modes '
|
||||
+ '(Persuade, Operate, Read, Experience), which are chosen per surface and persisted in that '
|
||||
+ "surface's brief. Nothing reads `## Register` any more.",
|
||||
});
|
||||
|
||||
const PRODUCT_STAMP_RE = /^[ \t]*<!--[ \t]*impeccable:product-schema[ \t]+(\d+)[ \t]*-->[ \t]*$/im;
|
||||
|
||||
/** The literal stamp line, for the init template and for migrations. */
|
||||
export function productStampLine(version = PRODUCT_SCHEMA_VERSION) {
|
||||
return `<!-- impeccable:product-schema ${version} -->`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema version stamped in a PRODUCT.md body, or null when unstamped. Null
|
||||
* means "written before stamping existed", not "invalid".
|
||||
*/
|
||||
export function readProductSchemaVersion(markdown) {
|
||||
const match = String(markdown || '').match(PRODUCT_STAMP_RE);
|
||||
if (!match) return null;
|
||||
const version = Number.parseInt(match[1], 10);
|
||||
return Number.isInteger(version) ? version : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update the stamp, returning the new body. Idempotent. A stamped file
|
||||
* keeps the stamp where it already sits so a migration never reorders the
|
||||
* user's prose; an unstamped file gets it directly under the leading `#`
|
||||
* heading, or at the top when there is none.
|
||||
*/
|
||||
export function stampProductSchema(markdown, version = PRODUCT_SCHEMA_VERSION) {
|
||||
const body = String(markdown || '');
|
||||
const line = productStampLine(version);
|
||||
if (PRODUCT_STAMP_RE.test(body)) return body.replace(PRODUCT_STAMP_RE, line);
|
||||
|
||||
const lines = body.split('\n');
|
||||
const headingIndex = lines.findIndex((entry) => /^#\s+\S/.test(entry));
|
||||
if (headingIndex === -1) return `${line}\n\n${body.replace(/^\n+/, '')}`;
|
||||
lines.splice(headingIndex + 1, 0, '', line);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema version of a parsed design.json. Returns null for a missing or
|
||||
* non-numeric field, which is how schemaVersion-1-era sidecars present
|
||||
* (the field predates the v2 rewrite in some files).
|
||||
*/
|
||||
export function readSidecarSchemaVersion(sidecar) {
|
||||
const version = sidecar && typeof sidecar === 'object' ? sidecar.schemaVersion : null;
|
||||
return Number.isInteger(version) ? version : null;
|
||||
}
|
||||
165
.agents/skills/impeccable/scripts/lib/composition-catalog.mjs
Normal file
165
.agents/skills/impeccable/scripts/lib/composition-catalog.mjs
Normal file
@@ -0,0 +1,165 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { CONCEPT_STATUSES, normalizeConceptForm } from './concept-catalog.mjs';
|
||||
|
||||
// Catalog B: stagings rather than styles. A composition organizes attention,
|
||||
// sequence, or manipulation on a surface and must survive being dressed in
|
||||
// any committed visual identity; it deliberately carries no palette or type
|
||||
// half. Surface-scope seeds draw from here (plus catalog A duals); direction
|
||||
// seeds pair one composition with a chosen world for the first surface.
|
||||
|
||||
export const COMPOSITION_GRAMMAR_PREFIXES = [
|
||||
'Staging/hierarchy:',
|
||||
'Sequence/attention:',
|
||||
'Controls/state:',
|
||||
'Adaptation:',
|
||||
];
|
||||
|
||||
// Surfaces align with the skill's modes: a persuade staging and an operate
|
||||
// staging are different species, and read/experience surfaces get their own.
|
||||
export const COMPOSITION_SURFACES = new Set(['persuade', 'operate', 'read', 'experience']);
|
||||
|
||||
export function compositionContentHash(composition) {
|
||||
const payload = [
|
||||
composition?.form ?? '',
|
||||
composition?.lineage ?? '',
|
||||
JSON.stringify(composition?.tags ?? []),
|
||||
JSON.stringify(composition?.grammar ?? []),
|
||||
composition?.spark ?? '',
|
||||
composition?.webLeverage ?? '',
|
||||
].join('\n');
|
||||
return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
export function validateCompositionEntry(composition, { existingForms = new Map() } = {}) {
|
||||
const errors = [];
|
||||
const id = composition?.id || '(unknown)';
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(composition?.id || '')) {
|
||||
errors.push(`invalid composition id: ${String(composition?.id)}`);
|
||||
}
|
||||
const normalized = normalizeConceptForm(composition?.form);
|
||||
if (!normalized) {
|
||||
errors.push(`composition ${id} needs a form`);
|
||||
} else if (existingForms.has(normalized)) {
|
||||
errors.push(`duplicate composition form: ${id} and ${existingForms.get(normalized)}`);
|
||||
}
|
||||
if (typeof composition?.form !== 'string'
|
||||
|| composition.form.trim().length < 40
|
||||
|| composition.form.trim().length > 360
|
||||
|| !composition.form.includes(',')) {
|
||||
errors.push(`composition ${id} must name a staging and its structural mechanism after a comma`);
|
||||
}
|
||||
if (typeof composition?.lineage !== 'string'
|
||||
|| composition.lineage.trim().length < 12
|
||||
|| composition.lineage.trim().length > 200) {
|
||||
errors.push(`composition ${id} needs lineage metadata of 12–200 characters`);
|
||||
}
|
||||
if (!COMPOSITION_SURFACES.has(composition?.surface)) {
|
||||
errors.push(`composition ${id} needs a surface of ${[...COMPOSITION_SURFACES].join(', ')}`);
|
||||
}
|
||||
if (!Array.isArray(composition?.tags)
|
||||
|| composition.tags.length !== 3
|
||||
|| composition.tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
|
||||
errors.push(`composition ${id} must have exactly three structural tags`);
|
||||
}
|
||||
if (!Array.isArray(composition?.grammar)
|
||||
|| composition.grammar.length !== COMPOSITION_GRAMMAR_PREFIXES.length
|
||||
|| composition.grammar.some(rule => typeof rule !== 'string' || rule.trim().length < 12 || rule.trim().length > 180)) {
|
||||
errors.push(`composition ${id} needs grammar with exactly four rules of 12–180 characters`);
|
||||
} else {
|
||||
const unique = new Set(composition.grammar.map(normalizeConceptForm));
|
||||
if (unique.size !== COMPOSITION_GRAMMAR_PREFIXES.length) {
|
||||
errors.push(`composition ${id} has duplicate grammar rules`);
|
||||
}
|
||||
if (composition.grammar.some((rule, index) => !rule.startsWith(COMPOSITION_GRAMMAR_PREFIXES[index]))) {
|
||||
errors.push(`composition ${id} grammar must use staging, sequence, controls, and adaptation prefixes in order`);
|
||||
}
|
||||
}
|
||||
if (typeof composition?.spark !== 'string'
|
||||
|| composition.spark.trim().length < 80
|
||||
|| composition.spark.trim().length > 320) {
|
||||
errors.push(`composition ${id} needs a vivid spark of 80–320 characters`);
|
||||
}
|
||||
if (typeof composition?.webLeverage !== 'string'
|
||||
|| composition.webLeverage.trim().length < 20
|
||||
|| composition.webLeverage.trim().length > 240) {
|
||||
errors.push(`composition ${id} needs web leverage of 20–240 characters`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function readCompositionCatalog(catalogPath, reviewsPath) {
|
||||
const catalog = JSON.parse(readFileSync(catalogPath, 'utf8'));
|
||||
const reviewData = JSON.parse(readFileSync(reviewsPath, 'utf8'));
|
||||
const reviews = reviewData.reviews || {};
|
||||
const familiesById = new Map((catalog.families || []).map(family => [family.id, family]));
|
||||
const compositions = (catalog.compositions || []).map(composition => ({
|
||||
...composition,
|
||||
familyLabel: familiesById.get(composition.familyId)?.label || null,
|
||||
status: reviews[composition.id]?.status || 'pending',
|
||||
review: reviews[composition.id] || null,
|
||||
}));
|
||||
return { catalog, reviewData, reviews, compositions };
|
||||
}
|
||||
|
||||
export function validateCompositionCatalog(catalog, reviewData, { minimumTotal } = {}) {
|
||||
const errors = [];
|
||||
const familyIds = new Set();
|
||||
const ids = new Set();
|
||||
const forms = new Map();
|
||||
|
||||
if (!Number.isInteger(catalog?.schemaVersion) || catalog.schemaVersion < 1) {
|
||||
errors.push('composition catalog schemaVersion must be a positive integer');
|
||||
}
|
||||
if (typeof catalog?.qualityBar?.principle !== 'string' || catalog.qualityBar.principle.trim().length < 80) {
|
||||
errors.push('composition qualityBar.principle must define the staging bar');
|
||||
}
|
||||
if (!Array.isArray(catalog?.families) || catalog.families.length < 4) {
|
||||
errors.push('composition catalog needs at least four families');
|
||||
}
|
||||
for (const family of catalog?.families || []) {
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(family.id || '')) errors.push(`invalid composition family id: ${String(family.id)}`);
|
||||
if (familyIds.has(family.id)) errors.push(`duplicate composition family id: ${family.id}`);
|
||||
familyIds.add(family.id);
|
||||
if (typeof family.description !== 'string' || family.description.trim().length < 40) {
|
||||
errors.push(`composition family ${family.id || '(unknown)'} needs a description`);
|
||||
}
|
||||
}
|
||||
for (const composition of catalog?.compositions || []) {
|
||||
if (ids.has(composition.id)) errors.push(`duplicate composition id: ${composition.id}`);
|
||||
ids.add(composition.id);
|
||||
if (!familyIds.has(composition.familyId)) {
|
||||
errors.push(`composition ${composition.id} must belong to a declared family, got: ${String(composition.familyId)}`);
|
||||
}
|
||||
errors.push(...validateCompositionEntry(composition, { existingForms: forms }));
|
||||
const normalized = normalizeConceptForm(composition.form);
|
||||
if (normalized) forms.set(normalized, composition.id);
|
||||
}
|
||||
if (minimumTotal !== undefined && (catalog?.compositions || []).length < minimumTotal) {
|
||||
errors.push(`expected at least ${minimumTotal} compositions, found ${(catalog?.compositions || []).length}`);
|
||||
}
|
||||
for (const [id, review] of Object.entries(reviewData?.reviews || {})) {
|
||||
if (!ids.has(id)) errors.push(`composition review references missing entry: ${id}`);
|
||||
if (!CONCEPT_STATUSES.has(review?.status)) errors.push(`invalid composition review status for ${id}`);
|
||||
if (typeof review?.formHash !== 'string' || !review.formHash.trim()) {
|
||||
errors.push(`composition review ${id} needs a formHash`);
|
||||
} else {
|
||||
const entry = (catalog?.compositions || []).find(composition => composition.id === id);
|
||||
if (entry && review.formHash !== compositionContentHash(entry)) {
|
||||
errors.push(`composition review ${id} is stale: content changed since review`);
|
||||
}
|
||||
}
|
||||
if (review?.note !== undefined && (typeof review.note !== 'string' || !review.note.trim() || review.note.length > 500)) {
|
||||
errors.push(`composition review ${id} note must be a non-empty string of 500 characters or fewer`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
errors,
|
||||
stats: {
|
||||
families: familyIds.size,
|
||||
compositions: (catalog?.compositions || []).length,
|
||||
approved: Object.values(reviewData?.reviews || {}).filter(review => review?.status === 'approved').length,
|
||||
rejected: Object.values(reviewData?.reviews || {}).filter(review => review?.status === 'rejected').length,
|
||||
},
|
||||
};
|
||||
}
|
||||
329
.agents/skills/impeccable/scripts/lib/concept-catalog.mjs
Normal file
329
.agents/skills/impeccable/scripts/lib/concept-catalog.mjs
Normal file
@@ -0,0 +1,329 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
export const CONCEPT_STATUSES = new Set(['approved', 'rejected']);
|
||||
|
||||
// What a concept is actually strong at. Worlds carry a durable visual
|
||||
// identity (their palette/type half is the magnet); compositions carry a
|
||||
// staging or interaction idea (their topology half is the magnet) that can be
|
||||
// dressed in any committed identity; duals fuse both inseparably. Direction
|
||||
// seeds draw world|dual, surface seeds draw composition|dual.
|
||||
export const CONCEPT_STRENGTHS = new Set(['world', 'composition', 'dual']);
|
||||
|
||||
// Challenger tiers, ordered by translation cost: graphic grammars map to
|
||||
// interface almost directly, instrument languages carry interaction physics,
|
||||
// atmosphere worlds need the largest translation step. Every seed roll draws
|
||||
// one challenger from each tier so at least one directly-usable graphic
|
||||
// system is always on the table.
|
||||
export const WELL_TIERS = ['graphic', 'interaction', 'atmosphere'];
|
||||
|
||||
const WEB_LEVERAGE_RE = /(?:\b3d\b|\badaptive\b|\banimat(?:e|ed|ion)\b|\bapi\b|\baria\b|\baudio\b|\bautomated?\b|\bbarcode\b|\bbroadcastchannel\b|\bbrowser\b|\bcamera\b|canvas\b|\bcaption\b|\bcollaborat(?:e|ive|ion)\b|\bcompar(?:e|ison)\b|\bcomput(?:e|ed|ation)\b|\bcomputer[- ]vision\b|\bconstraint[- ]solving\b|\bcryptographic?\b|\bcss\b|\bdeep[- ]link(?:ing)?\b|\bdirect manipulation\b|\bdom\b|\bdrag\b|\bfilter\b|\bfocus\b|\bgenerative\b|\bgeolocat(?:e|ed|ion)\b|\bgesture\b|\bgpu\b|\bgraph\b|\bhistory\b|\bindexeddb\b|\binteractive\b|\bintersectionobserver\b|\bkeyboard\b|\blive\b|\blocal\b|\bmicrophone\b|\bmotion\b|\bmultiplayer\b|\bnative\b|\bnotification\b|\boffline\b|\bpersonaliz(?:e|ed|ation)\b|\bplayable\b|\bpointer\b|\bprocedural\b|\bprovenance\b|\breal[- ]?time\b|\bresizeobserver\b|\bresponsive\b|\breveal\b|\bscrub\b|\bsearch\b|\bsearchparams\b|\bsensor\b|\bserver[- ]sent\b|\bservice worker\b|\bshader\b|\bsimulat(?:e|ed|ion|or)\b|\bspatial\b|\bstate\b|\bstream(?:ing)?\b|\bsvg\b|\bsynchroniz(?:e|ed|ation)\b|\btimeline\b|\btouch\b|\burl|\bvideo\b|\bweb(?:gl|socket|vtt)?\b|\bworker\b|\bzoom\b)/i;
|
||||
export const SYSTEM_PREFIXES = [
|
||||
'Palette/material:',
|
||||
'Type/composition:',
|
||||
'Topology/navigation:',
|
||||
'Controls/state:',
|
||||
'Responsive/motion:',
|
||||
];
|
||||
const BLAND_FORM_RE = /\b(?:control room|command center|operations center|dispatch desk|review queue|speaker queue|management console|admin console|operator loop|coordination system|tracking system|planning system|software platform|digital platform|operations cockpit|app portal|web portal|data hub|dashboard|workflow|planner|tracker|orchestrator)\b/i;
|
||||
|
||||
export function normalizeConceptForm(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFKD')
|
||||
.toLowerCase()
|
||||
.replace(/[’‘]/g, "'")
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function validateConceptEntry(concept, { existingForms = new Map() } = {}) {
|
||||
const errors = [];
|
||||
const id = concept?.id || '(unknown)';
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(concept?.id || '')) {
|
||||
errors.push(`invalid concept id: ${String(concept?.id)}`);
|
||||
}
|
||||
|
||||
const normalized = normalizeConceptForm(concept?.form);
|
||||
if (!normalized) {
|
||||
errors.push(`concept ${id} needs a form`);
|
||||
} else if (existingForms.has(normalized)) {
|
||||
errors.push(`duplicate concept form: ${id} and ${existingForms.get(normalized)}`);
|
||||
}
|
||||
if (typeof concept?.form !== 'string'
|
||||
|| concept.form.trim().length < 40
|
||||
|| concept.form.trim().length > 360
|
||||
|| !concept.form.includes(',')) {
|
||||
errors.push(`concept ${id} must name a form and inherited structure after a comma`);
|
||||
}
|
||||
if (typeof concept?.lineage !== 'string'
|
||||
|| concept.lineage.trim().length < 12
|
||||
|| concept.lineage.trim().length > 200) {
|
||||
errors.push(`concept ${id} needs specific lineage metadata of 12–200 characters`);
|
||||
}
|
||||
if (!CONCEPT_STRENGTHS.has(concept?.strength)) {
|
||||
errors.push(`concept ${id} needs a strength of ${[...CONCEPT_STRENGTHS].join(', ')}`);
|
||||
}
|
||||
if (!Array.isArray(concept?.tags)
|
||||
|| concept.tags.length !== 3
|
||||
|| concept.tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
|
||||
errors.push(`concept ${id} must have exactly three structural tags`);
|
||||
}
|
||||
if (!Array.isArray(concept?.system)
|
||||
|| concept.system.length !== SYSTEM_PREFIXES.length
|
||||
|| concept.system.some(rule => typeof rule !== 'string' || rule.trim().length < 12 || rule.trim().length > 180)) {
|
||||
errors.push(`concept ${id} needs system grammar with exactly five rules of 12–180 characters`);
|
||||
} else {
|
||||
const uniqueRules = new Set(concept.system.map(normalizeConceptForm));
|
||||
if (uniqueRules.size !== SYSTEM_PREFIXES.length) {
|
||||
errors.push(`concept ${id} has duplicate system grammar rules`);
|
||||
}
|
||||
if (concept.system.some((rule, index) => !rule.startsWith(SYSTEM_PREFIXES[index]))) {
|
||||
errors.push(`concept ${id} system grammar must use palette, type, topology, controls, and responsive prefixes in order`);
|
||||
}
|
||||
}
|
||||
if (typeof concept?.spark !== 'string'
|
||||
|| concept.spark.trim().length < 80
|
||||
|| concept.spark.trim().length > 320) {
|
||||
errors.push(`concept ${id} needs a vivid creative spark of 80–320 characters`);
|
||||
}
|
||||
if (typeof concept?.webLeverage !== 'string'
|
||||
|| concept.webLeverage.trim().length < 20
|
||||
|| concept.webLeverage.trim().length > 240) {
|
||||
errors.push(`concept ${id} needs web leverage of 20–240 characters`);
|
||||
}
|
||||
if (/\b(?:live digital system|shared participatory system) modeled on\b/i.test(concept?.form || '')) {
|
||||
errors.push(`concept ${id} is a generic wrapper around another artifact`);
|
||||
}
|
||||
if (/\b(?:in the style of|styled like|copy of)\b/i.test(concept?.form || '')) {
|
||||
errors.push(`concept ${id} contains imitation language`);
|
||||
}
|
||||
if (BLAND_FORM_RE.test(concept?.form || '')) {
|
||||
errors.push(`concept ${id} is framed as a literal software or operations archetype instead of an inspiring visual world`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Fingerprint of everything a reviewer judged. Reviews carry this hash so an
|
||||
// approval cannot silently survive a content edit: the validator rejects any
|
||||
// review whose hash no longer matches the concept it points at.
|
||||
export function conceptContentHash(concept) {
|
||||
const payload = [
|
||||
concept?.form ?? '',
|
||||
concept?.lineage ?? '',
|
||||
JSON.stringify(concept?.tags ?? []),
|
||||
JSON.stringify(concept?.system ?? []),
|
||||
concept?.spark ?? '',
|
||||
concept?.webLeverage ?? '',
|
||||
].join('\n');
|
||||
return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
export function readConceptCatalog(catalogPath, reviewsPath) {
|
||||
const catalog = JSON.parse(readFileSync(catalogPath, 'utf8'));
|
||||
const reviewData = JSON.parse(readFileSync(reviewsPath, 'utf8'));
|
||||
const reviews = reviewData.reviews || {};
|
||||
const wellsById = new Map((catalog.wells || []).map(well => [well.id, well]));
|
||||
const concepts = [];
|
||||
|
||||
for (const family of catalog.families || []) {
|
||||
for (const concept of family.concepts || []) {
|
||||
concepts.push({
|
||||
...concept,
|
||||
familyId: family.id,
|
||||
familyLabel: family.label,
|
||||
wellId: family.well || null,
|
||||
wellLabel: wellsById.get(family.well)?.label || null,
|
||||
wellTier: wellsById.get(family.well)?.tier || null,
|
||||
status: reviews[concept.id]?.status || 'pending',
|
||||
review: reviews[concept.id] || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { catalog, reviewData, reviews, concepts };
|
||||
}
|
||||
|
||||
export function validateConceptCatalog(catalog, reviewData, {
|
||||
expectedTotal,
|
||||
minimumTotal,
|
||||
requireApprovedMinimum = true,
|
||||
} = {}) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const familyIds = new Set();
|
||||
const conceptIds = new Set();
|
||||
const normalizedForms = new Map();
|
||||
const concepts = [];
|
||||
|
||||
if (!Number.isInteger(catalog?.schemaVersion) || catalog.schemaVersion < 7) {
|
||||
errors.push('catalog.schemaVersion must be 7 or newer');
|
||||
}
|
||||
if (typeof catalog?.catalogVersion !== 'string' || !catalog.catalogVersion.trim()) {
|
||||
errors.push('catalog.catalogVersion must be a non-empty string');
|
||||
}
|
||||
if (typeof catalog?.qualityBar?.principle !== 'string' || catalog.qualityBar.principle.trim().length < 80) {
|
||||
errors.push('catalog.qualityBar.principle must define the universal creative bar');
|
||||
}
|
||||
if (!Array.isArray(catalog?.qualityBar?.rejectIf) || catalog.qualityBar.rejectIf.length < 5) {
|
||||
errors.push('catalog.qualityBar.rejectIf must define at least five rejection gates');
|
||||
}
|
||||
if (!Array.isArray(catalog?.qualityBar?.reviewAxes) || catalog.qualityBar.reviewAxes.length < 8) {
|
||||
errors.push('catalog.qualityBar.reviewAxes must define at least eight review axes');
|
||||
}
|
||||
if (!Array.isArray(catalog?.families) || catalog.families.length < 3) {
|
||||
errors.push('catalog.families must contain at least three families');
|
||||
}
|
||||
|
||||
const wellIds = new Set();
|
||||
if (!Array.isArray(catalog?.wells) || catalog.wells.length < 5) {
|
||||
errors.push('catalog.wells must define at least five inspiration wells');
|
||||
}
|
||||
for (const well of catalog?.wells || []) {
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(well.id || '')) {
|
||||
errors.push(`invalid well id: ${String(well.id)}`);
|
||||
} else if (wellIds.has(well.id)) {
|
||||
errors.push(`duplicate well id: ${well.id}`);
|
||||
}
|
||||
wellIds.add(well.id);
|
||||
if (typeof well.label !== 'string' || !well.label.trim()) {
|
||||
errors.push(`well ${well.id || '(unknown)'} needs a label`);
|
||||
}
|
||||
if (typeof well.description !== 'string' || well.description.trim().length < 40) {
|
||||
errors.push(`well ${well.id || '(unknown)'} needs a description of at least 40 characters`);
|
||||
}
|
||||
if (!WELL_TIERS.includes(well.tier)) {
|
||||
errors.push(`well ${well.id || '(unknown)'} needs a tier of ${WELL_TIERS.join(', ')}, got: ${String(well.tier)}`);
|
||||
}
|
||||
}
|
||||
const tiersPresent = new Set((catalog?.wells || []).map(well => well.tier).filter(tier => WELL_TIERS.includes(tier)));
|
||||
for (const tier of WELL_TIERS) {
|
||||
if ((catalog?.wells || []).length > 0 && !tiersPresent.has(tier)) {
|
||||
errors.push(`no well declares the ${tier} tier`);
|
||||
}
|
||||
}
|
||||
const populatedWells = new Set();
|
||||
|
||||
for (const family of catalog?.families || []) {
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(family.id || '')) {
|
||||
errors.push(`invalid family id: ${String(family.id)}`);
|
||||
} else if (familyIds.has(family.id)) {
|
||||
errors.push(`duplicate family id: ${family.id}`);
|
||||
}
|
||||
familyIds.add(family.id);
|
||||
if (typeof family.label !== 'string' || !family.label.trim()) {
|
||||
errors.push(`family ${family.id || '(unknown)'} needs a label`);
|
||||
}
|
||||
if (!wellIds.has(family.well)) {
|
||||
errors.push(`family ${family.id || '(unknown)'} must belong to a declared well, got: ${String(family.well)}`);
|
||||
} else {
|
||||
populatedWells.add(family.well);
|
||||
}
|
||||
if (!Array.isArray(family.concepts) || family.concepts.length === 0) {
|
||||
errors.push(`family ${family.id || '(unknown)'} has no concepts`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const concept of family.concepts) {
|
||||
concepts.push(concept);
|
||||
if (conceptIds.has(concept.id)) {
|
||||
errors.push(`duplicate concept id: ${concept.id}`);
|
||||
}
|
||||
errors.push(...validateConceptEntry(concept, { existingForms: normalizedForms }));
|
||||
conceptIds.add(concept.id);
|
||||
const normalized = normalizeConceptForm(concept.form);
|
||||
if (normalized) normalizedForms.set(normalized, concept.id);
|
||||
if (typeof concept.webLeverage === 'string' && !WEB_LEVERAGE_RE.test(concept.webLeverage)) {
|
||||
warnings.push(`concept ${concept.id} web leverage should be checked for a specific browser-native capability`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const well of catalog?.wells || []) {
|
||||
if (well.id && !populatedWells.has(well.id)) {
|
||||
errors.push(`well ${well.id} has no families`);
|
||||
}
|
||||
}
|
||||
|
||||
if (expectedTotal !== undefined && concepts.length !== expectedTotal) {
|
||||
errors.push(`expected ${expectedTotal} concepts, found ${concepts.length}`);
|
||||
}
|
||||
if (minimumTotal !== undefined && concepts.length < minimumTotal) {
|
||||
errors.push(`expected at least ${minimumTotal} concepts, found ${concepts.length}`);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(reviewData?.schemaVersion) || reviewData.schemaVersion < 2) {
|
||||
errors.push('reviews.schemaVersion must be 2 or newer');
|
||||
}
|
||||
const conceptsById = new Map(concepts.map(concept => [concept.id, concept]));
|
||||
for (const [id, review] of Object.entries(reviewData?.reviews || {})) {
|
||||
if (!conceptIds.has(id)) errors.push(`review references missing concept: ${id}`);
|
||||
if (!CONCEPT_STATUSES.has(review?.status)) errors.push(`invalid review status for ${id}: ${String(review?.status)}`);
|
||||
if (typeof review?.reviewedBy !== 'string' || !review.reviewedBy.trim()) {
|
||||
errors.push(`review ${id} needs reviewedBy`);
|
||||
}
|
||||
if (typeof review?.reviewedAt !== 'string' || Number.isNaN(Date.parse(review.reviewedAt))) {
|
||||
errors.push(`review ${id} needs an ISO reviewedAt timestamp`);
|
||||
}
|
||||
if (typeof review?.formHash !== 'string' || !review.formHash.trim()) {
|
||||
errors.push(`review ${id} needs a formHash of the reviewed content`);
|
||||
} else if (conceptsById.has(id) && review.formHash !== conceptContentHash(conceptsById.get(id))) {
|
||||
errors.push(`review ${id} is stale: concept content changed since it was reviewed; reset or re-review it`);
|
||||
}
|
||||
if (review?.note !== undefined && (typeof review.note !== 'string' || !review.note.trim() || review.note.length > 500)) {
|
||||
errors.push(`review ${id} note must be a non-empty string of 500 characters or fewer`);
|
||||
}
|
||||
// Rating grades how strong an approved concept is (3 exceptional, 2 solid,
|
||||
// 1 marginal keep). Optional, approved-only, and read as a calibration
|
||||
// signal for future authoring rounds.
|
||||
if (review?.rating !== undefined) {
|
||||
if (![1, 2, 3].includes(review.rating)) {
|
||||
errors.push(`review ${id} rating must be 1, 2, or 3`);
|
||||
} else if (review.status !== 'approved') {
|
||||
errors.push(`review ${id} rating only applies to approved concepts`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const wellTierById = new Map((catalog?.wells || []).map(well => [well.id, well.tier]));
|
||||
const approved = concepts.filter(concept => reviewData?.reviews?.[concept.id]?.status === 'approved');
|
||||
const approvedTiers = new Set(
|
||||
(catalog?.families || [])
|
||||
.filter(family => family.concepts?.some(concept => reviewData?.reviews?.[concept.id]?.status === 'approved'))
|
||||
.map(family => wellTierById.get(family.well))
|
||||
.filter(tier => WELL_TIERS.includes(tier))
|
||||
);
|
||||
if (requireApprovedMinimum && approved.length < 3) errors.push('at least three concepts must be approved');
|
||||
if (requireApprovedMinimum && approvedTiers.size < WELL_TIERS.length) {
|
||||
errors.push('approved concepts must cover every challenger tier');
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
warnings,
|
||||
stats: {
|
||||
wells: wellIds.size,
|
||||
families: familyIds.size,
|
||||
concepts: concepts.length,
|
||||
approved: approved.length,
|
||||
pending: concepts.length - Object.keys(reviewData?.reviews || {}).length,
|
||||
rejected: Object.values(reviewData?.reviews || {}).filter(review => review?.status === 'rejected').length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function approvedPoolRevision(concepts) {
|
||||
const payload = concepts
|
||||
.filter(concept => concept.status === 'approved')
|
||||
.map(concept => `${concept.familyId}:${concept.id}:${concept.strength}:${concept.form}:${concept.spark}:${JSON.stringify(concept.system)}:${concept.webLeverage}`)
|
||||
.sort()
|
||||
.join('\n');
|
||||
return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
export function deterministicRank(items, input, idFor = item => item.id) {
|
||||
return [...items].sort((a, b) => {
|
||||
const scoreA = crypto.createHash('sha256').update(`${input}:${idFor(a)}`).digest('hex');
|
||||
const scoreB = crypto.createHash('sha256').update(`${input}:${idFor(b)}`).digest('hex');
|
||||
return scoreB.localeCompare(scoreA) || idFor(a).localeCompare(idFor(b));
|
||||
});
|
||||
}
|
||||
842
.agents/skills/impeccable/scripts/lib/design-parser.mjs
Normal file
842
.agents/skills/impeccable/scripts/lib/design-parser.mjs
Normal file
@@ -0,0 +1,842 @@
|
||||
// Parse a DESIGN.md (Stitch-spec format) into a structured JSON model that
|
||||
// the live-mode design-system panel can render. Deterministic, dependency-free.
|
||||
//
|
||||
// Two-layer: YAML frontmatter (machine-readable tokens) + markdown body
|
||||
// (prose with six canonical H2 sections). When frontmatter is present, it's
|
||||
// exposed on `model.frontmatter` alongside the prose-scraped sections;
|
||||
// consumers can prefer frontmatter values and fall back to prose.
|
||||
|
||||
const CANONICAL_SECTIONS = [
|
||||
'Overview',
|
||||
'Colors',
|
||||
'Typography',
|
||||
'Elevation',
|
||||
'Components',
|
||||
"Do's and Don'ts",
|
||||
];
|
||||
|
||||
// ---------- Frontmatter (Stitch YAML subset) ----------
|
||||
|
||||
function parseFrontmatter(md) {
|
||||
const lines = md.split(/\r?\n/);
|
||||
if (lines[0]?.trim() !== '---') return { frontmatter: null, body: md };
|
||||
|
||||
let end = -1;
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (lines[i].trim() === '---') { end = i; break; }
|
||||
}
|
||||
if (end === -1) return { frontmatter: null, body: md };
|
||||
|
||||
const yaml = lines.slice(1, end).join('\n');
|
||||
const body = lines.slice(end + 1).join('\n');
|
||||
try {
|
||||
return { frontmatter: parseYamlSubset(yaml), body };
|
||||
} catch {
|
||||
return { frontmatter: null, body: md };
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal YAML reader for the Stitch frontmatter subset: scalar maps with
|
||||
// one level of nested objects (typography roles, components). Indent-based,
|
||||
// 2-space convention. No arrays, no anchors, no multi-line scalars — Stitch's
|
||||
// schema doesn't need them and accepting them would require a real YAML
|
||||
// dependency we don't want to vendor.
|
||||
function parseYamlSubset(yaml) {
|
||||
const lines = yaml.split(/\r?\n/);
|
||||
const root = {};
|
||||
const stack = [{ indent: -1, obj: root }];
|
||||
|
||||
for (const raw of lines) {
|
||||
// Skip blanks and line-only comments. Don't strip inline comments:
|
||||
// unquoted hex values start with `#` and can't be safely distinguished
|
||||
// from a comment after whitespace.
|
||||
if (!raw.trim() || /^\s*#/.test(raw)) continue;
|
||||
|
||||
const indent = raw.match(/^\s*/)[0].length;
|
||||
const content = raw.slice(indent);
|
||||
|
||||
const colonIdx = findTopLevelColon(content);
|
||||
if (colonIdx === -1) continue;
|
||||
|
||||
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
const key = unquoteYamlKey(content.slice(0, colonIdx).trim());
|
||||
const rest = stripInlineYamlComment(content.slice(colonIdx + 1).trim());
|
||||
const parent = stack[stack.length - 1].obj;
|
||||
|
||||
if (rest === '') {
|
||||
const obj = {};
|
||||
parent[key] = obj;
|
||||
stack.push({ indent, obj });
|
||||
} else {
|
||||
parent[key] = parseScalar(rest);
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function findTopLevelColon(s) {
|
||||
let inQuote = null;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (inQuote) {
|
||||
if (ch === inQuote && s[i - 1] !== '\\') inQuote = null;
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
inQuote = ch;
|
||||
} else if (ch === ':') {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function unquoteYamlKey(key) {
|
||||
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
|
||||
return key.slice(1, -1);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function stripInlineYamlComment(s) {
|
||||
let inQuote = null;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (inQuote) {
|
||||
if (ch === inQuote && s[i - 1] !== '\\') inQuote = null;
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
inQuote = ch;
|
||||
} else if (ch === '#' && i > 0 && /\s/.test(s[i - 1])) {
|
||||
return s.slice(0, i).trimEnd();
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function parseScalar(raw) {
|
||||
const s = raw.trim();
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
if (s === 'true') return true;
|
||||
if (s === 'false') return false;
|
||||
if (s === 'null' || s === '~') return null;
|
||||
if (/^-?\d+$/.test(s)) return Number(s);
|
||||
if (/^-?\d*\.\d+$/.test(s)) return Number(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
|
||||
const OKLCH_RE = /oklch\([^)]+\)/gi;
|
||||
const RGBA_RE = /rgba?\([^)]+\)/gi;
|
||||
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
|
||||
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
|
||||
|
||||
// ---------- Section splitting ----------
|
||||
|
||||
function splitSections(md) {
|
||||
const lines = md.split(/\r?\n/);
|
||||
let title = null;
|
||||
const sections = {};
|
||||
let current = null;
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.trimEnd();
|
||||
|
||||
if (!title && line.startsWith('# ') && !line.startsWith('## ')) {
|
||||
title = line.replace(/^#\s+/, '').trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
const h2 = line.match(/^##\s+(?:\d+\.\s*)?([^:\n]+?)(?::\s*(.+))?$/);
|
||||
if (h2) {
|
||||
const rawName = normalizeApostrophes(h2[1].trim());
|
||||
const subtitle = h2[2] ? h2[2].trim() : null;
|
||||
const canonical = matchCanonicalSection(rawName);
|
||||
if (canonical) {
|
||||
current = { name: canonical, subtitle, lines: [] };
|
||||
sections[canonical] = current;
|
||||
continue;
|
||||
}
|
||||
// non-canonical H2 — ignore but stop feeding into current
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current) current.lines.push(raw);
|
||||
}
|
||||
|
||||
return { title, sections };
|
||||
}
|
||||
|
||||
function normalizeApostrophes(s) {
|
||||
return s.replace(/[\u2018\u2019]/g, "'");
|
||||
}
|
||||
|
||||
function matchCanonicalSection(name) {
|
||||
const normalized = normalizeApostrophes(name).toLowerCase();
|
||||
// Exact match first
|
||||
for (const c of CANONICAL_SECTIONS) {
|
||||
if (normalizeApostrophes(c).toLowerCase() === normalized) return c;
|
||||
}
|
||||
// Keyword-contained match: "Overview & Creative North Star" -> "Overview",
|
||||
// "Elevation & Depth" -> "Elevation", etc.
|
||||
for (const c of CANONICAL_SECTIONS) {
|
||||
const key = normalizeApostrophes(c).toLowerCase();
|
||||
const pattern = new RegExp(`\\b${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
|
||||
if (pattern.test(normalized)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- Subsection splitting (inside a canonical section) ----------
|
||||
|
||||
function splitSubsections(lines) {
|
||||
const subs = [];
|
||||
let current = { name: null, lines: [] };
|
||||
subs.push(current);
|
||||
|
||||
for (const raw of lines) {
|
||||
const h3 = raw.match(/^###\s+(.+?)\s*$/);
|
||||
if (h3) {
|
||||
current = { name: h3[1].trim(), lines: [] };
|
||||
subs.push(current);
|
||||
continue;
|
||||
}
|
||||
current.lines.push(raw);
|
||||
}
|
||||
|
||||
return subs;
|
||||
}
|
||||
|
||||
// ---------- Generic helpers ----------
|
||||
|
||||
function collectParagraphs(lines) {
|
||||
const paragraphs = [];
|
||||
let buf = [];
|
||||
const flush = () => {
|
||||
if (buf.length) {
|
||||
paragraphs.push(buf.join(' ').trim());
|
||||
buf = [];
|
||||
}
|
||||
};
|
||||
for (const raw of lines) {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') { flush(); continue; }
|
||||
// Horizontal rules (---, ***) and headings/bullets end a paragraph.
|
||||
if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { flush(); continue; }
|
||||
if (raw.startsWith('#') || raw.match(/^[-*]\s/)) { flush(); continue; }
|
||||
buf.push(trimmed);
|
||||
}
|
||||
flush();
|
||||
return paragraphs.filter(Boolean);
|
||||
}
|
||||
|
||||
function collectBullets(lines) {
|
||||
const bullets = [];
|
||||
let current = null;
|
||||
for (const raw of lines) {
|
||||
const m = raw.match(/^\s*[-*]\s+(.+)$/);
|
||||
if (m) {
|
||||
if (current) bullets.push(current);
|
||||
current = m[1];
|
||||
continue;
|
||||
}
|
||||
// continuation of a bullet (indented line)
|
||||
if (current && raw.match(/^\s{2,}\S/)) {
|
||||
current += ' ' + raw.trim();
|
||||
continue;
|
||||
}
|
||||
// blank line ends a bullet
|
||||
if (raw.trim() === '' && current) {
|
||||
bullets.push(current);
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
if (current) bullets.push(current);
|
||||
return bullets;
|
||||
}
|
||||
|
||||
function stripBold(s) {
|
||||
return s.replace(/\*\*(.+?)\*\*/g, '$1');
|
||||
}
|
||||
|
||||
function extractNamedRules(lines) {
|
||||
const rules = [];
|
||||
const seen = new Set();
|
||||
|
||||
// Style A (Impeccable): "**The X Rule.** body body body" — can span lines.
|
||||
const joined = lines.join('\n');
|
||||
const inlineStart = /\*\*(The [^*]+?Rule)\.\*\*/g;
|
||||
const inlineMatches = [];
|
||||
let m;
|
||||
while ((m = inlineStart.exec(joined)) !== null) {
|
||||
inlineMatches.push({ name: m[1], start: m.index, end: inlineStart.lastIndex });
|
||||
}
|
||||
for (let i = 0; i < inlineMatches.length; i++) {
|
||||
const mm = inlineMatches[i];
|
||||
const bodyEnd = i + 1 < inlineMatches.length ? inlineMatches[i + 1].start : joined.length;
|
||||
const body = joined
|
||||
.slice(mm.end, bodyEnd)
|
||||
.replace(/\n##[^\n]*$/s, '')
|
||||
.replace(/\n###[^\n]*$/s, '')
|
||||
.trim();
|
||||
const name = stripBold(mm.name).trim();
|
||||
seen.add(name.toLowerCase());
|
||||
rules.push({ name, body: stripBold(body) });
|
||||
}
|
||||
|
||||
// Style B (Stitch): `### The "X" Rule` or `### The X Fallback`, body is the
|
||||
// bullets/paragraphs until the next heading. Accept Rule / Fallback / Principle.
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const h3 = lines[i].match(/^###\s+(.+?)\s*$/);
|
||||
if (!h3) continue;
|
||||
const headerName = stripBold(h3[1]).replace(/["“”]/g, '').trim();
|
||||
if (!/^The\b.*\b(Rule|Fallback|Principle)\b/i.test(headerName)) continue;
|
||||
if (seen.has(headerName.toLowerCase())) continue;
|
||||
|
||||
const bodyLines = [];
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
if (/^##\s|^###\s/.test(lines[j])) break;
|
||||
bodyLines.push(lines[j]);
|
||||
}
|
||||
const body = stripBold(bodyLines.join('\n').replace(/\n+/g, ' ')).trim();
|
||||
if (body) {
|
||||
seen.add(headerName.toLowerCase());
|
||||
rules.push({ name: headerName, body });
|
||||
}
|
||||
}
|
||||
|
||||
// Style C (Stitch bullet form): "* **The Layering Principle:** body"
|
||||
// Colon/period lives inside the bold, so match "**...**" then inspect.
|
||||
for (const b of collectBullets(lines)) {
|
||||
const mm = b.match(/^\*\*([^*]+?)\*\*\s*(.+)$/);
|
||||
if (!mm) continue;
|
||||
const nameRaw = mm[1].replace(/[.:]\s*$/, '').replace(/["“”]/g, '').trim();
|
||||
if (!/^The\b.+\b(Rule|Fallback|Principle)$/i.test(nameRaw)) continue;
|
||||
if (seen.has(nameRaw.toLowerCase())) continue;
|
||||
seen.add(nameRaw.toLowerCase());
|
||||
rules.push({ name: nameRaw, body: stripBold(mm[2]).trim() });
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
// ---------- Per-section extractors ----------
|
||||
|
||||
function extractOverview(section) {
|
||||
if (!section) return null;
|
||||
const text = section.lines.join('\n');
|
||||
const northStar = text.match(/\*\*Creative North Star:\s*"([^"]+)"\*\*/);
|
||||
const keyChars = [];
|
||||
const keyCharMatch = text.match(/\*\*Key Characteristics:\*\*\s*\n([\s\S]+?)(?:\n##|\n###|$)/);
|
||||
if (keyCharMatch) {
|
||||
for (const line of keyCharMatch[1].split('\n')) {
|
||||
const m = line.match(/^\s*[-*]\s+(.+)$/);
|
||||
if (m) keyChars.push(stripBold(m[1].trim()));
|
||||
}
|
||||
}
|
||||
|
||||
// Philosophy paragraphs: everything that isn't a rule header or key-char block
|
||||
const paragraphs = collectParagraphs(section.lines).filter(
|
||||
(p) =>
|
||||
!p.startsWith('**Creative North Star') &&
|
||||
!p.startsWith('**Key Characteristics')
|
||||
);
|
||||
|
||||
return {
|
||||
subtitle: section.subtitle,
|
||||
creativeNorthStar: northStar ? northStar[1] : null,
|
||||
philosophy: paragraphs,
|
||||
keyCharacteristics: keyChars,
|
||||
};
|
||||
}
|
||||
|
||||
function extractColors(section) {
|
||||
if (!section) return null;
|
||||
const subs = splitSubsections(section.lines);
|
||||
|
||||
const description = collectParagraphs(subs[0].lines).join(' ');
|
||||
const groups = [];
|
||||
const ROLE_KEYWORDS = /^(primary|secondary|tertiary|neutral|accent)\b/i;
|
||||
|
||||
for (const sub of subs.slice(1)) {
|
||||
if (!sub.name || /Named Rules?/i.test(sub.name) || /^The\s/i.test(sub.name)) continue;
|
||||
|
||||
const bullets = collectBullets(sub.lines);
|
||||
const parsed = bullets.map((b) => parseColorBullet(b)).filter(Boolean);
|
||||
if (parsed.length === 0) continue;
|
||||
|
||||
// If every bullet starts with a role keyword (Primary/Secondary/...), promote
|
||||
// each bullet to its own group. Otherwise keep the subsection as the group.
|
||||
const allRoleBullets =
|
||||
parsed.length > 0 && parsed.every((p) => p.name && ROLE_KEYWORDS.test(p.name));
|
||||
|
||||
if (allRoleBullets) {
|
||||
for (const p of parsed) {
|
||||
groups.push({ role: p.name, colors: [p] });
|
||||
}
|
||||
} else {
|
||||
groups.push({ role: sub.name, colors: parsed });
|
||||
}
|
||||
}
|
||||
|
||||
// If the Colors section has no subsections at all (unlikely), fall back to
|
||||
// scanning the whole section as a flat bullet list.
|
||||
if (groups.length === 0) {
|
||||
const flat = collectBullets(section.lines)
|
||||
.map((b) => parseColorBullet(b))
|
||||
.filter(Boolean);
|
||||
if (flat.length) {
|
||||
for (const p of flat) {
|
||||
if (p.name && ROLE_KEYWORDS.test(p.name)) {
|
||||
groups.push({ role: p.name, colors: [p] });
|
||||
} else {
|
||||
const fallback = groups.find((g) => g.role === 'Palette');
|
||||
if (fallback) fallback.colors.push(p);
|
||||
else groups.push({ role: 'Palette', colors: [p] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subtitle: section.subtitle,
|
||||
description: description || null,
|
||||
groups,
|
||||
rules: extractNamedRules(section.lines),
|
||||
};
|
||||
}
|
||||
|
||||
function parseColorBullet(bullet) {
|
||||
const text = bullet.trim();
|
||||
|
||||
// Case 1 (Impeccable): **Name** (value-with-maybe-nested-parens): description
|
||||
const bold = text.match(/^\*\*(.+?)\*\*\s*(.*)$/);
|
||||
if (bold && bold[2].startsWith('(')) {
|
||||
const value = extractParenGroup(bold[2]);
|
||||
if (value !== null) {
|
||||
const after = bold[2].slice(value.length + 2).trimStart();
|
||||
if (after.startsWith(':')) {
|
||||
return buildColor(bold[1], value, after.slice(1).trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2 (Stitch): **Name (values):** description — value embedded in bold.
|
||||
const stitch = text.match(/^\*\*([^*]+?)\s*\(([^)]+)\):\*\*\s*(.*)$/);
|
||||
if (stitch) {
|
||||
return buildColor(stitch[1].trim(), stitch[2], stitch[3]);
|
||||
}
|
||||
|
||||
// Case 3: bullet without bold, just hex/oklch inside.
|
||||
const values = collectColorValues(text);
|
||||
if (values.length) {
|
||||
return buildColor(null, values.join(' to '), text);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractParenGroup(s) {
|
||||
if (s[0] !== '(') return null;
|
||||
let depth = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
if (s[i] === '(') depth++;
|
||||
else if (s[i] === ')') {
|
||||
depth--;
|
||||
if (depth === 0) return s.slice(1, i);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildColor(name, rawValue, description) {
|
||||
const values = collectColorValues(rawValue);
|
||||
const primary = values[0] ?? rawValue.trim();
|
||||
return {
|
||||
name: name ? stripBold(name).trim() : null,
|
||||
value: primary,
|
||||
valueRange: values.length > 1 ? values : null,
|
||||
format: detectFormat(primary),
|
||||
description: stripBold(description || '').trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function collectColorValues(s) {
|
||||
const out = [];
|
||||
s.replace(HEX_RE, (v) => {
|
||||
out.push(v);
|
||||
return v;
|
||||
});
|
||||
s.replace(OKLCH_RE, (v) => {
|
||||
out.push(v);
|
||||
return v;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function detectFormat(v) {
|
||||
if (!v) return 'unknown';
|
||||
if (v.startsWith('#')) return 'hex';
|
||||
if (/^oklch/i.test(v)) return 'oklch';
|
||||
if (/^rgb/i.test(v)) return 'rgb';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function scanInlineColors(lines) {
|
||||
const out = [];
|
||||
for (const line of lines) {
|
||||
if (!/^\s*[-*]\s/.test(line)) continue;
|
||||
const trimmed = line.replace(/^\s*[-*]\s+/, '');
|
||||
const color = parseColorBullet(trimmed);
|
||||
if (color) out.push(color);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseStitchInlineGroups(lines) {
|
||||
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
|
||||
// Each bullet IS its own role. Group them under the spoken role name.
|
||||
const out = [];
|
||||
for (const line of lines) {
|
||||
if (!/^\s*[-*]\s/.test(line)) continue;
|
||||
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
|
||||
const m = trimmed.match(
|
||||
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
|
||||
);
|
||||
if (m) {
|
||||
const role = m[1];
|
||||
const color = buildColor(role, m[2], m[3]);
|
||||
out.push({ role, colors: [color] });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractTypography(section) {
|
||||
if (!section) return null;
|
||||
const text = section.lines.join('\n');
|
||||
|
||||
const fonts = {};
|
||||
// Pattern A: **Display Font:** Family (with fallback)
|
||||
const fontLineRe = /\*\*([\w\s/]+?)Font:\*\*\s*([^\n(]+?)(?:\s*\(with\s+([^)]+)\))?\s*$/gm;
|
||||
let fm;
|
||||
while ((fm = fontLineRe.exec(text)) !== null) {
|
||||
const rawRole = fm[1].trim().toLowerCase().replace(/\s+/g, '-');
|
||||
const role = normalizeFontRole(rawRole) || 'display';
|
||||
fonts[role] = {
|
||||
family: fm[2].trim(),
|
||||
fallback: fm[3] ? fm[3].trim() : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Pattern B (Stitch): * **Display & Headlines (Noto Serif):** description
|
||||
if (Object.keys(fonts).length === 0) {
|
||||
const stitchRe = /\*\*([\w\s&/]+?)\s*\(([^)]+)\):\*\*\s*(.+)/g;
|
||||
let sm;
|
||||
while ((sm = stitchRe.exec(text)) !== null) {
|
||||
const rawRole = sm[1]
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s*&\s*/g, '-')
|
||||
.replace(/\s+/g, '-');
|
||||
const role = normalizeFontRole(rawRole) || rawRole;
|
||||
fonts[role] = { family: sm[2].trim(), fallback: null, purpose: sm[3].trim() };
|
||||
}
|
||||
}
|
||||
|
||||
// Character paragraph — either a **Character:** label, or fall back to the
|
||||
// first free paragraph under the section header (Stitch style).
|
||||
const characterMatch = text.match(/\*\*Character:\*\*\s*([^\n]+(?:\n[^\n]+)*?)(?=\n\n|\n###|\n##|$)/);
|
||||
let character = characterMatch ? characterMatch[1].replace(/\n/g, ' ').trim() : null;
|
||||
if (!character) {
|
||||
const paragraphs = collectParagraphs(section.lines).filter(
|
||||
(p) => !/^\*\*[\w\s/&]+Font/i.test(p) && !/^\*\*[\w\s/&]+\([^)]+\)/.test(p)
|
||||
);
|
||||
if (paragraphs.length) character = paragraphs[0];
|
||||
}
|
||||
|
||||
// Hierarchy bullets under ### Hierarchy
|
||||
const subs = splitSubsections(section.lines);
|
||||
let hierarchy = [];
|
||||
const hierSub = subs.find((s) => s.name && /hierarch/i.test(s.name));
|
||||
if (hierSub) {
|
||||
const bullets = collectBullets(hierSub.lines);
|
||||
hierarchy = bullets.map(parseTypeBullet).filter(Boolean);
|
||||
}
|
||||
|
||||
return {
|
||||
subtitle: section.subtitle,
|
||||
fonts,
|
||||
character,
|
||||
hierarchy,
|
||||
rules: extractNamedRules(section.lines),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFontRole(raw) {
|
||||
// Canonical roles the panel cares about: display, body, label, mono.
|
||||
// Stitch often writes compound roles like "display-&-headlines" or "ui-&-body"
|
||||
// — collapse them to the first canonical role present.
|
||||
const tokens = raw.split(/[-/&\s]+/).filter(Boolean);
|
||||
const priority = ['display', 'headline', 'body', 'ui', 'label', 'mono'];
|
||||
const canonical = { headline: 'display', ui: 'body' };
|
||||
for (const p of priority) {
|
||||
if (tokens.includes(p)) return canonical[p] || p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseTypeBullet(bullet) {
|
||||
// - **Display** (family, weight 300, italic, clamp(...), line-height 1): purpose
|
||||
const m = bullet.match(/^\*\*(.+?)\*\*\s*\(([^)]+)\):\s*(.*)$/);
|
||||
if (!m) return null;
|
||||
const name = m[1].trim();
|
||||
const specs = m[2].split(',').map((s) => s.trim());
|
||||
return {
|
||||
name,
|
||||
specs,
|
||||
purpose: stripBold(m[3] || '').trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function extractElevation(section) {
|
||||
if (!section) return null;
|
||||
const subs = splitSubsections(section.lines);
|
||||
|
||||
const description = collectParagraphs(subs[0].lines).join(' ') || null;
|
||||
|
||||
const shadows = [];
|
||||
const seen = new Set();
|
||||
const dedupe = (entry) => {
|
||||
const key = (entry.name || '') + '::' + entry.value;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
shadows.push(entry);
|
||||
};
|
||||
|
||||
for (const b of collectBullets(section.lines)) {
|
||||
const parsed = parseShadowBullet(b);
|
||||
if (parsed) dedupe(parsed);
|
||||
}
|
||||
|
||||
// Fallback: extract shadows written inline in prose. Stitch style is
|
||||
// "...use an extra-diffused shadow: `box-shadow: 0 12px 40px rgba(...)`."
|
||||
for (const p of collectParagraphs(section.lines)) {
|
||||
for (const inline of extractInlineShadows(p)) dedupe(inline);
|
||||
}
|
||||
for (const b of collectBullets(section.lines)) {
|
||||
for (const inline of extractInlineShadows(b)) dedupe(inline);
|
||||
}
|
||||
|
||||
return {
|
||||
subtitle: section.subtitle,
|
||||
description,
|
||||
shadows,
|
||||
rules: extractNamedRules(section.lines),
|
||||
};
|
||||
}
|
||||
|
||||
function extractInlineShadows(text) {
|
||||
// Find `box-shadow: ...` anywhere in prose and capture the value. Work on the
|
||||
// raw string so it handles both backtick-fenced and unfenced variants.
|
||||
const out = [];
|
||||
const re = /box-shadow\s*:\s*([^`;\n]+)/gi;
|
||||
let m;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const value = m[1].replace(/[`.)]+$/, '').trim();
|
||||
if (!value) continue;
|
||||
// Name heuristic: the noun immediately before the shadow phrase.
|
||||
// e.g. "an extra-diffused shadow: ..." -> "extra-diffused shadow"
|
||||
const before = text.slice(0, m.index);
|
||||
const nameMatch = before.match(/\b([A-Za-z][A-Za-z\- ]{2,40})\s+shadow\b[^A-Za-z0-9]*$/i);
|
||||
let name = null;
|
||||
if (nameMatch) {
|
||||
const stripped = nameMatch[1]
|
||||
.replace(/^(?:use|using|apply|applying|is|are|looks? like)\s+/i, '')
|
||||
.replace(/^(?:a|an|the)\s+/i, '')
|
||||
.trim();
|
||||
if (stripped) {
|
||||
name =
|
||||
stripped.charAt(0).toUpperCase() + stripped.slice(1) + ' shadow';
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
name,
|
||||
value,
|
||||
purpose: null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseShadowBullet(bullet) {
|
||||
// - **Name** (`box-shadow: value`): purpose
|
||||
// - **Name** (`value`): purpose
|
||||
// Only accept if the paren content looks like a shadow value (contains px,
|
||||
// rem, rgba, or box-shadow). This filters out `**Rule Name:**` bullets.
|
||||
const m = bullet.match(/^\*\*(.+?)\*\*\s*\(`?([^`]+?)`?\):\s*(.*)$/);
|
||||
if (!m) return null;
|
||||
const rawValue = m[2].replace(/^box-shadow:\s*/i, '').trim();
|
||||
const looksLikeShadow =
|
||||
/box-shadow|rgba?\(|\bpx\b|\brem\b|^-?\d+\s/i.test(rawValue) &&
|
||||
/\d/.test(rawValue);
|
||||
if (!looksLikeShadow) return null;
|
||||
const name = stripBold(m[1]).trim();
|
||||
return {
|
||||
name,
|
||||
value: rawValue,
|
||||
purpose: stripBold(m[3] || '').trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function extractComponents(section) {
|
||||
if (!section) return null;
|
||||
const subs = splitSubsections(section.lines);
|
||||
const components = [];
|
||||
|
||||
for (const sub of subs.slice(1)) {
|
||||
if (!sub.name) continue;
|
||||
|
||||
const bullets = collectBullets(sub.lines);
|
||||
const paragraphs = collectParagraphs(sub.lines);
|
||||
|
||||
const variants = [];
|
||||
const properties = {};
|
||||
|
||||
for (const b of bullets) {
|
||||
// - **Key:** value
|
||||
const m = b.match(/^\*\*(.+?):?\*\*:?\s*(.+)$/);
|
||||
if (m) {
|
||||
const key = stripBold(m[1]).trim();
|
||||
const value = stripBold(m[2]).trim();
|
||||
// Heuristic: "Primary", "Secondary", "Hover", "Focus" etc are variants;
|
||||
// "Shape", "Background", "Padding" are properties.
|
||||
if (/^(primary|secondary|tertiary|ghost|hover|focus|active|disabled|default|error|selected|unselected|state)$/i.test(key.split(/[\s/]/)[0])) {
|
||||
variants.push({ name: key, description: value });
|
||||
} else {
|
||||
properties[key.toLowerCase()] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
components.push({
|
||||
name: sub.name,
|
||||
description: paragraphs.join(' ') || null,
|
||||
properties,
|
||||
variants,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
subtitle: section.subtitle,
|
||||
components,
|
||||
};
|
||||
}
|
||||
|
||||
function extractDosDonts(section) {
|
||||
if (!section) return null;
|
||||
const subs = splitSubsections(section.lines);
|
||||
const dos = [];
|
||||
const donts = [];
|
||||
|
||||
for (const sub of subs.slice(1)) {
|
||||
if (!sub.name) continue;
|
||||
const subName = normalizeApostrophes(sub.name);
|
||||
const bullets = collectBullets(sub.lines).map((b) => stripBold(b).trim());
|
||||
if (/^do'?t?:?$/i.test(subName) || /^do:?$/i.test(subName)) {
|
||||
dos.push(...bullets);
|
||||
} else if (/^don'?t:?$/i.test(subName)) {
|
||||
donts.push(...bullets);
|
||||
}
|
||||
}
|
||||
|
||||
// Classify by bullet prefix as a backup (catches loose bullets outside H3 wrappers)
|
||||
for (const b of collectBullets(section.lines)) {
|
||||
const stripped = normalizeApostrophes(stripBold(b).trim());
|
||||
if (/^don'?t\b/i.test(stripped)) {
|
||||
if (!donts.some((d) => normalizeApostrophes(d) === stripped)) donts.push(stripped);
|
||||
} else if (/^do\b/i.test(stripped)) {
|
||||
if (!dos.some((d) => normalizeApostrophes(d) === stripped)) dos.push(stripped);
|
||||
}
|
||||
}
|
||||
|
||||
return { dos, donts };
|
||||
}
|
||||
|
||||
// ---------- Coverage assessment ----------
|
||||
|
||||
function assessCoverage(model) {
|
||||
const report = {};
|
||||
|
||||
report.overview = model.overview
|
||||
? {
|
||||
northStar: Boolean(model.overview.creativeNorthStar),
|
||||
philosophy: model.overview.philosophy.length > 0,
|
||||
keyCharacteristics: model.overview.keyCharacteristics.length,
|
||||
}
|
||||
: 'missing';
|
||||
|
||||
report.colors = model.colors
|
||||
? {
|
||||
groups: model.colors.groups.length,
|
||||
totalColors: model.colors.groups.reduce((n, g) => n + g.colors.length, 0),
|
||||
rules: model.colors.rules.length,
|
||||
}
|
||||
: 'missing';
|
||||
|
||||
report.typography = model.typography
|
||||
? {
|
||||
fonts: Object.keys(model.typography.fonts).length,
|
||||
hierarchyEntries: model.typography.hierarchy.length,
|
||||
character: Boolean(model.typography.character),
|
||||
rules: model.typography.rules.length,
|
||||
}
|
||||
: 'missing';
|
||||
|
||||
report.elevation = model.elevation
|
||||
? {
|
||||
shadows: model.elevation.shadows.length,
|
||||
rules: model.elevation.rules.length,
|
||||
description: Boolean(model.elevation.description),
|
||||
}
|
||||
: 'missing';
|
||||
|
||||
report.components = model.components
|
||||
? {
|
||||
count: model.components.components.length,
|
||||
variantTotal: model.components.components.reduce((n, c) => n + c.variants.length, 0),
|
||||
}
|
||||
: 'missing';
|
||||
|
||||
report.dosDonts = model.dosDonts
|
||||
? {
|
||||
dos: model.dosDonts.dos.length,
|
||||
donts: model.dosDonts.donts.length,
|
||||
}
|
||||
: 'missing';
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
// ---------- Main ----------
|
||||
|
||||
export function parseDesignMd(md) {
|
||||
const { frontmatter, body } = parseFrontmatter(md);
|
||||
const { title, sections } = splitSections(body);
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
title,
|
||||
frontmatter,
|
||||
overview: extractOverview(sections['Overview']),
|
||||
colors: extractColors(sections['Colors']),
|
||||
typography: extractTypography(sections['Typography']),
|
||||
elevation: extractElevation(sections['Elevation']),
|
||||
components: extractComponents(sections['Components']),
|
||||
dosDonts: extractDosDonts(sections["Do's and Don'ts"]),
|
||||
};
|
||||
}
|
||||
|
||||
export { assessCoverage };
|
||||
655
.agents/skills/impeccable/scripts/lib/impeccable-config.mjs
Normal file
655
.agents/skills/impeccable/scripts/lib/impeccable-config.mjs
Normal file
@@ -0,0 +1,655 @@
|
||||
/**
|
||||
* CLI-side reader/writer for the unified `.impeccable` config.
|
||||
*
|
||||
* The CLI (published to npm) and the skill scripts (bundled into the install)
|
||||
* live in separate trees and cannot share runtime code, so this duplicates a
|
||||
* small slice of skill/scripts/hook-lib.mjs — the config-path layout, detector
|
||||
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
|
||||
* ignore filtering, and exclude marker in sync if either side changes.
|
||||
*
|
||||
* Schema (config.json shared / config.local.json gitignored, per-developer):
|
||||
* {
|
||||
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
|
||||
* "hook": { "consent": "accepted" | "declined", ... },
|
||||
* "updateCheck": bool
|
||||
* }
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
||||
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
|
||||
export function getConfigPath(root) {
|
||||
return join(root, '.impeccable', 'config.json');
|
||||
}
|
||||
|
||||
export function getLocalConfigPath(root) {
|
||||
return join(root, '.impeccable', 'config.local.json');
|
||||
}
|
||||
|
||||
function safeReadJson(filePath) {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hookSection(raw) {
|
||||
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
|
||||
}
|
||||
|
||||
function detectorSection(raw) {
|
||||
return raw && raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
|
||||
}
|
||||
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem', 'advisoryRules']);
|
||||
|
||||
const DEFAULT_DETECTION_CONFIG = Object.freeze({
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
designSystem: { enabled: true },
|
||||
});
|
||||
|
||||
function cloneDetectionConfig() {
|
||||
return {
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
|
||||
};
|
||||
}
|
||||
|
||||
function cloneRawDetectionConfig() {
|
||||
return {
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
};
|
||||
}
|
||||
|
||||
function applyDetectionConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
// Advisory rules are opt-in for the design hook; the CLI carries the setting
|
||||
// so config round-trips (e.g. `impeccable hooks ignore-value`) preserve it.
|
||||
if (raw.advisoryRules === 'include' || raw.advisoryRules === 'exclude') {
|
||||
config.advisoryRules = raw.advisoryRules;
|
||||
}
|
||||
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
||||
config.designSystem = {
|
||||
...config.designSystem,
|
||||
enabled: raw.designSystem.enabled === false ? false : true,
|
||||
};
|
||||
}
|
||||
if (Array.isArray(raw.ignoreRules)) {
|
||||
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
|
||||
}
|
||||
if (Array.isArray(raw.ignoreFiles)) {
|
||||
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
|
||||
}
|
||||
if (Array.isArray(raw.ignoreValues)) {
|
||||
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function uniqueStrings(values) {
|
||||
return Array.from(new Set(values.map(String)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detector filters shared by `npx impeccable detect` and the design hook.
|
||||
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
|
||||
* the hook is disabled, but they honor the same ignore rules and design-system
|
||||
* toggle.
|
||||
*/
|
||||
export function readDetectionConfig(root) {
|
||||
const config = cloneDetectionConfig();
|
||||
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
|
||||
const raw = safeReadJson(filePath);
|
||||
// Back-compat: old builds stored detector filters under hook.*.
|
||||
applyDetectionConfigSource(config, hookSection(raw));
|
||||
applyDetectionConfigSource(config, detectorSection(raw));
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export function readRawDetectionConfig(root, opts = {}) {
|
||||
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
|
||||
const config = cloneRawDetectionConfig();
|
||||
applyDetectionConfigSource(config, hookSection(raw));
|
||||
applyDetectionConfigSource(config, detectorSection(raw));
|
||||
return config;
|
||||
}
|
||||
|
||||
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
|
||||
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
|
||||
if (opts.local) ensureConfigGitExclude(root);
|
||||
const existing = safeReadJson(filePath) || {};
|
||||
const existingHook = hookSection(existing);
|
||||
const nextHook = stripDetectorKeys(existingHook);
|
||||
const nextDetector = {
|
||||
...(detectorSection(existing) || {}),
|
||||
...normalizeDetectionConfigForWrite(detectorConfig),
|
||||
};
|
||||
const next = {
|
||||
...existing,
|
||||
detector: nextDetector,
|
||||
};
|
||||
if (nextHook && Object.keys(nextHook).length > 0) {
|
||||
next.hook = nextHook;
|
||||
} else {
|
||||
delete next.hook;
|
||||
}
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function normalizeDetectionConfigForWrite(config) {
|
||||
const out = {};
|
||||
if (Array.isArray(config?.ignoreRules)) {
|
||||
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
|
||||
}
|
||||
if (Array.isArray(config?.ignoreFiles)) {
|
||||
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
|
||||
}
|
||||
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
if (config?.advisoryRules === 'include' || config?.advisoryRules === 'exclude') {
|
||||
out.advisoryRules = config.advisoryRules;
|
||||
}
|
||||
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
|
||||
out.designSystem = {
|
||||
enabled: config.designSystem.enabled === false ? false : true,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function stripDetectorKeys(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeIgnoreValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeIgnoreRule(rule) {
|
||||
return String(rule || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function colorIgnoreKey(value) {
|
||||
const color = parseIgnoreColor(value);
|
||||
if (!color) return '';
|
||||
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
|
||||
}
|
||||
|
||||
function parseIgnoreColor(value) {
|
||||
const text = String(value || '').trim().toLowerCase();
|
||||
if (!text) return null;
|
||||
|
||||
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
|
||||
if (hex) return parseHexIgnoreColor(hex[1]);
|
||||
|
||||
const rgb = text.match(/^rgba?\((.*)\)$/i);
|
||||
if (rgb) {
|
||||
const parts = splitColorArgs(rgb[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const r = parseRgbChannel(parts[0]);
|
||||
const g = parseRgbChannel(parts[1]);
|
||||
const b = parseRgbChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
if ([r, g, b, a].some((v) => v === null)) return null;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
const hsl = text.match(/^hsla?\((.*)\)$/i);
|
||||
if (hsl) {
|
||||
const parts = splitColorArgs(hsl[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const h = parseHueChannel(parts[0]);
|
||||
const s = parsePercentChannel(parts[1]);
|
||||
const l = parsePercentChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
if ([h, s, l, a].some((v) => v === null)) return null;
|
||||
return hslToRgb(h, s, l, a);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseHexIgnoreColor(hex) {
|
||||
if (hex.length === 3 || hex.length === 4) {
|
||||
const r = parseInt(hex[0] + hex[0], 16);
|
||||
const g = parseInt(hex[1] + hex[1], 16);
|
||||
const b = parseInt(hex[2] + hex[2], 16);
|
||||
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
function splitColorArgs(body) {
|
||||
const text = String(body || '').trim();
|
||||
if (!text) return [];
|
||||
if (text.includes(',')) {
|
||||
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
|
||||
const last = parts[parts.length - 1];
|
||||
if (last && last.includes('/')) {
|
||||
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
|
||||
return [...parts.slice(0, -1), ...split];
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
|
||||
}
|
||||
|
||||
function parseRgbChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const scaled = match[2] ? value * 2.55 : value;
|
||||
if (scaled < 0 || scaled > 255) return null;
|
||||
return Math.round(scaled);
|
||||
}
|
||||
|
||||
function parseAlphaChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const alpha = match[2] ? value / 100 : value;
|
||||
return alpha >= 0 && alpha <= 1 ? alpha : null;
|
||||
}
|
||||
|
||||
function parseHueChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const unit = match[2] || 'deg';
|
||||
if (unit === 'turn') return value * 360;
|
||||
if (unit === 'rad') return value * (180 / Math.PI);
|
||||
if (unit === 'grad') return value * 0.9;
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePercentChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)%$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return value >= 0 && value <= 100 ? value / 100 : null;
|
||||
}
|
||||
|
||||
function hslToRgb(hue, saturation, lightness, alpha) {
|
||||
const h = (((hue % 360) + 360) % 360) / 360;
|
||||
if (saturation === 0) {
|
||||
const gray = clampByte(Math.round(lightness * 255));
|
||||
return { r: gray, g: gray, b: gray, a: alpha };
|
||||
}
|
||||
const q = lightness < 0.5
|
||||
? lightness * (1 + saturation)
|
||||
: lightness + saturation - lightness * saturation;
|
||||
const p = 2 * lightness - q;
|
||||
const toRgb = (t) => {
|
||||
let channel = t;
|
||||
if (channel < 0) channel += 1;
|
||||
if (channel > 1) channel -= 1;
|
||||
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
|
||||
if (channel < 1 / 2) return q;
|
||||
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
|
||||
return p;
|
||||
};
|
||||
return {
|
||||
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
|
||||
g: clampByte(Math.round(toRgb(h) * 255)),
|
||||
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
|
||||
a: alpha,
|
||||
};
|
||||
}
|
||||
|
||||
function clampByte(value) {
|
||||
return Math.min(255, Math.max(0, value));
|
||||
}
|
||||
|
||||
function ignoreValueMatches(rule, entryValue, findingValue) {
|
||||
if (entryValue === findingValue) return true;
|
||||
if (rule !== 'design-system-color') return false;
|
||||
const entryColor = colorIgnoreKey(entryValue);
|
||||
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
|
||||
}
|
||||
|
||||
export function normalizeIgnoreValueEntries(entries) {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
const out = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const rule = normalizeIgnoreRule(entry.rule);
|
||||
const value = normalizeIgnoreValue(entry.value);
|
||||
if (!rule || !value) continue;
|
||||
const normalized = { rule, value };
|
||||
const files = uniqueStrings([
|
||||
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
|
||||
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
|
||||
]);
|
||||
if (files.length > 0) normalized.files = files;
|
||||
// Key order is rule, value, files, createdAt, reason and must stay that way:
|
||||
// normalizing runs on every write, so emitting a different order than the one
|
||||
// already on disk rewrites every untouched entry and churns the diff. Keep in
|
||||
// step with normalizeIgnoreValueEntries in skill/scripts/hook-lib.mjs.
|
||||
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
|
||||
normalized.createdAt = entry.createdAt.trim();
|
||||
}
|
||||
if (typeof entry.reason === 'string' && entry.reason.trim()) {
|
||||
normalized.reason = entry.reason.trim();
|
||||
}
|
||||
out.push(normalized);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeIgnoreValues(existing, incoming) {
|
||||
const map = new Map();
|
||||
for (const entry of normalizeIgnoreValueEntries(existing)) {
|
||||
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
||||
}
|
||||
for (const entry of normalizeIgnoreValueEntries(incoming)) {
|
||||
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
function ignoreValueFilesKey(files) {
|
||||
// Sort before joining: a scope is a set, so an entry already on disk in another
|
||||
// order must compare equal rather than dedup as two distinct entries.
|
||||
return Array.isArray(files) && files.length > 0 ? [...files].sort().join('\x1f') : '';
|
||||
}
|
||||
|
||||
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
|
||||
function globToRegex(glob) {
|
||||
let re = '^';
|
||||
let i = 0;
|
||||
while (i < glob.length) {
|
||||
const c = glob[i];
|
||||
if (c === '*') {
|
||||
if (glob[i + 1] === '*') {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
if (glob[i] === '/') i += 1;
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (c === '{') {
|
||||
const end = glob.indexOf('}', i);
|
||||
if (end === -1) { re += '\\{'; i += 1; continue; }
|
||||
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
|
||||
re += `(?:${parts.join('|')})`;
|
||||
i = end + 1;
|
||||
} else if (/[.+^$()|[\]\\]/.test(c)) {
|
||||
re += `\\${c}`;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
re += '$';
|
||||
return new RegExp(re);
|
||||
}
|
||||
|
||||
export function matchesAnyGlob(filePath, globs) {
|
||||
if (!Array.isArray(globs) || globs.length === 0) return false;
|
||||
const normalized = String(filePath || '').split(sep).join('/');
|
||||
for (const glob of globs) {
|
||||
try {
|
||||
const re = globToRegex(String(glob));
|
||||
if (re.test(normalized)) return true;
|
||||
const base = normalized.split('/').pop();
|
||||
if (re.test(base)) return true;
|
||||
} catch {
|
||||
/* malformed glob, skip */
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldIgnoreDetectionFile(filePath, root, config) {
|
||||
const globs = config?.ignoreFiles || [];
|
||||
if (!Array.isArray(globs) || globs.length === 0) return false;
|
||||
const raw = String(filePath || '').trim();
|
||||
if (!raw) return false;
|
||||
if (matchesAnyGlob(raw, globs)) return true;
|
||||
|
||||
try {
|
||||
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
|
||||
if (matchesAnyGlob(abs, globs)) return true;
|
||||
const rel = relative(root, abs);
|
||||
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
|
||||
return matchesAnyGlob(rel, globs);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function filterDetectionFindings(findings, config) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return [];
|
||||
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
|
||||
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
return findings.filter((finding) => {
|
||||
if (!finding || typeof finding !== 'object') return false;
|
||||
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
|
||||
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function isIgnoredFindingValue(finding, ignoreValues) {
|
||||
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
if (!rule) return false;
|
||||
// File-scoped wildcards suppress rules with no extractable value, such as side-tab.
|
||||
const value = extractFindingIgnoreValue(finding);
|
||||
return ignoreValues.some((entry) => {
|
||||
if (entry.rule !== rule) return false;
|
||||
const wildcardValue = entry.value === '*';
|
||||
if (!wildcardValue && (!value || !ignoreValueMatches(rule, entry.value, value))) return false;
|
||||
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
|
||||
return findingMatchesScopedIgnoreFile(finding, entry.files);
|
||||
});
|
||||
}
|
||||
|
||||
function findingMatchesScopedIgnoreFile(finding, globs) {
|
||||
const filePath = String(finding?.file || '').trim();
|
||||
if (!filePath) return false;
|
||||
if (matchesAnyGlob(filePath, globs)) return true;
|
||||
|
||||
const normalized = filePath.split(sep).join('/');
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const suffix = parts.slice(i).join('/');
|
||||
if (matchesAnyGlob(suffix, globs)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function extractFindingIgnoreValue(finding) {
|
||||
if (!finding || typeof finding !== 'object') return '';
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
const directValueRules = new Set([
|
||||
'overused-font',
|
||||
'bounce-easing',
|
||||
'design-system-font',
|
||||
'design-system-color',
|
||||
'design-system-radius',
|
||||
'design-system-font-size',
|
||||
]);
|
||||
if (!directValueRules.has(rule)) return '';
|
||||
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
|
||||
}
|
||||
|
||||
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
|
||||
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
|
||||
if (direct) return direct;
|
||||
|
||||
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
|
||||
for (const text of candidates) {
|
||||
if (rule === 'bounce-easing') {
|
||||
const motion = extractMotionIgnoreValue(text);
|
||||
if (motion) return motion;
|
||||
continue;
|
||||
}
|
||||
|
||||
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
|
||||
if (primary) return cleanIgnoreValueDisplay(primary[1]);
|
||||
|
||||
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
|
||||
if (family) return cleanIgnoreValueDisplay(family[1]);
|
||||
|
||||
const google = text.match(/[?&]family=([^&:;\n]+)/i);
|
||||
if (google) {
|
||||
try {
|
||||
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
|
||||
} catch {
|
||||
return cleanIgnoreValueDisplay(google[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractMotionIgnoreValue(text) {
|
||||
const tailwind = text.match(/\banimate-bounce\b/i);
|
||||
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
|
||||
|
||||
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
|
||||
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
|
||||
|
||||
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
|
||||
if (animation) {
|
||||
const token = animation[1]
|
||||
.split(/[,\s]+/)
|
||||
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
||||
if (token) return cleanIgnoreValueDisplay(token);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function cleanIgnoreValueDisplay(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ')
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
|
||||
* config.local.json (per-developer) overrides config.json.
|
||||
*/
|
||||
export function getHookConsent(root) {
|
||||
let consent;
|
||||
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
|
||||
const hook = hookSection(safeReadJson(filePath));
|
||||
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
|
||||
}
|
||||
return consent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the per-developer decision to config.local.json, preserving any
|
||||
* sibling keys, and ensure the file is gitignored.
|
||||
*/
|
||||
export function setHookConsent(root, value) {
|
||||
const filePath = getLocalConfigPath(root);
|
||||
const existing = safeReadJson(filePath) || {};
|
||||
const hook = hookSection(existing) || {};
|
||||
const next = { ...existing, hook: { ...hook, consent: value } };
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
|
||||
ensureConfigGitExclude(root);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
|
||||
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
|
||||
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
|
||||
|
||||
/**
|
||||
* Add config.local.json to `.git/info/exclude` so a developer's decision is
|
||||
* never committed. Idempotent via marker comments. Best-effort; returns false
|
||||
* when there is no resolvable git dir.
|
||||
*/
|
||||
export function ensureConfigGitExclude(root) {
|
||||
try {
|
||||
const gitDir = resolveGitDir(root);
|
||||
if (!gitDir) return false;
|
||||
const target = join(gitDir, 'info', 'exclude');
|
||||
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
|
||||
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
|
||||
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
|
||||
let updated;
|
||||
if (markerRe.test(existing)) {
|
||||
updated = existing.replace(markerRe, block);
|
||||
} else {
|
||||
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
|
||||
updated = `${prefix}${block}\n`;
|
||||
}
|
||||
if (updated !== existing) {
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, updated);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGitDir(root) {
|
||||
const dotGit = join(root, '.git');
|
||||
if (!existsSync(dotGit)) return null;
|
||||
try {
|
||||
if (statSync(dotGit).isDirectory()) return dotGit;
|
||||
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
|
||||
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
|
||||
if (match) {
|
||||
const resolved = match[1].trim();
|
||||
return isAbsolute(resolved) ? resolved : join(root, resolved);
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
137
.agents/skills/impeccable/scripts/lib/impeccable-paths.mjs
Normal file
137
.agents/skills/impeccable/scripts/lib/impeccable-paths.mjs
Normal file
@@ -0,0 +1,137 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
import { designSidecarCandidatesFor } from './staleness.mjs';
|
||||
export { IMPECCABLE_COMMAND_PREFIX } from './provider.mjs';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
export const CRITIQUE_DIR = 'critique';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
return designSidecarCandidatesFor(resolveProjectRoot(cwd, options), contextDir);
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd, { targetPath });
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
if (fs.existsSync(legacy)) return legacy;
|
||||
}
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
continue;
|
||||
}
|
||||
return { info, path: filePath };
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isLiveServerPidReachable(pid) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// ESRCH means "no such process". EPERM means the process exists but this
|
||||
// user cannot signal it, so the live server info is still valid.
|
||||
return err?.code !== 'ESRCH';
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) {
|
||||
const filePath = getLiveServerPath(cwd, options);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Session IDs become path segments (journals, snapshots, accept receipts,
|
||||
* preview manifests, generated component dirs). They arrive from CLI `--id`
|
||||
* arguments and HTTP payloads, so anything containing a separator or `..` must
|
||||
* be rejected before it reaches path.join, which would happily escape
|
||||
* `.impeccable/live/`. Real IDs are 8 hex chars; the tests use short slugs.
|
||||
*/
|
||||
export function safeSessionId(id) {
|
||||
if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(id)) {
|
||||
throw new Error('invalid session id: ' + id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'annotations');
|
||||
}
|
||||
|
||||
export function getCritiqueDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR);
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
return paths.find((filePath) => fs.existsSync(filePath)) || null;
|
||||
}
|
||||
69
.agents/skills/impeccable/scripts/lib/is-generated.mjs
Normal file
69
.agents/skills/impeccable/scripts/lib/is-generated.mjs
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Decide whether a given file is "generated" (regenerated by a build step,
|
||||
* unsafe to write variants into) or "source" (safe to edit, changes persist).
|
||||
*
|
||||
* Why this matters: when the user picks an element on a page whose underlying
|
||||
* file is regenerated by a build step (e.g. `scripts/build-sub-pages.js`
|
||||
* rewriting `public/docs/*.html`), writing variants or accepted changes into
|
||||
* that file is silent data loss — the next build wipes them.
|
||||
*
|
||||
* Signals, in order of reliability:
|
||||
* 1. Git check-ignore: gitignored files are assumed generated.
|
||||
* 2. File-header markers ("GENERATED", "DO NOT EDIT", "AUTO-GENERATED")
|
||||
* within the first ~300 characters — catches non-git projects.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const HEADER_SCAN_BYTES = 300;
|
||||
const HEADER_MARKERS = [
|
||||
/@generated\b/i,
|
||||
/\bGENERATED\s+FILE\b/,
|
||||
/\bAUTO-?GENERATED\b/i,
|
||||
/\bDO\s+NOT\s+EDIT\b/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {string} filePath - absolute or cwd-relative path
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.cwd] - project root (defaults to process.cwd())
|
||||
*/
|
||||
export function isGeneratedFile(filePath, options = {}) {
|
||||
const cwd = options.cwd || process.cwd();
|
||||
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
||||
|
||||
if (isGitIgnored(absPath, cwd)) return true;
|
||||
if (hasGeneratedHeader(absPath)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isGitIgnored(absPath, cwd) {
|
||||
try {
|
||||
execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, {
|
||||
cwd,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
return true; // exit 0 = ignored
|
||||
} catch (err) {
|
||||
// Exit code 1 = not ignored. Exit code 128 = not a git repo or other error.
|
||||
// In both cases, treat as "not known to be ignored."
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasGeneratedHeader(absPath) {
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(absPath, 'r');
|
||||
const buf = Buffer.alloc(HEADER_SCAN_BYTES);
|
||||
const bytesRead = fs.readSync(fd, buf, 0, HEADER_SCAN_BYTES, 0);
|
||||
const head = buf.slice(0, bytesRead).toString('utf-8');
|
||||
return HEADER_MARKERS.some((re) => re.test(head));
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
|
||||
}
|
||||
}
|
||||
5
.agents/skills/impeccable/scripts/lib/provider.mjs
Normal file
5
.agents/skills/impeccable/scripts/lib/provider.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
// Source scripts default to slash commands. The provider build replaces only
|
||||
// this exact declaration, avoiding heuristic rewrites across executable code.
|
||||
export const IMPECCABLE_COMMAND_PREFIX = "$";
|
||||
export const IMPECCABLE_PROVIDER_ID = "agents";
|
||||
export const IMPECCABLE_COMMAND = `${IMPECCABLE_COMMAND_PREFIX}impeccable`;
|
||||
457
.agents/skills/impeccable/scripts/lib/staleness-deep.mjs
Normal file
457
.agents/skills/impeccable/scripts/lib/staleness-deep.mjs
Normal file
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Tier 2 staleness checks: the ones that cost too much to run on every session
|
||||
* boot. Shelling out to git, walking workspaces, resolving hook script paths,
|
||||
* and validating ignore lists against the live rule registry all belong here.
|
||||
*
|
||||
* The boot tier answers "did an older Impeccable write this". This tier also
|
||||
* asks "does it still describe the code", which no file comparison can settle
|
||||
* on its own. Where the answer needs judgment, the finding reports a measured
|
||||
* proxy and says it is a proxy. It never claims a document is wrong because a
|
||||
* number is large.
|
||||
*
|
||||
* Same finding shape and severities as lib/staleness.mjs.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const VISUAL_SOURCE_DIRS = ['src', 'app', 'pages', 'components', 'site', 'styles', 'public'];
|
||||
|
||||
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
|
||||
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
|
||||
codex: ['.codex/hooks.json'],
|
||||
agents: ['.codex/hooks.json'],
|
||||
cursor: ['.cursor/hooks.json'],
|
||||
github: ['.github/hooks/impeccable.json'],
|
||||
grok: ['.grok/hooks/impeccable.json'],
|
||||
});
|
||||
|
||||
const HOOK_SCRIPT_MARKERS = [
|
||||
'skills/impeccable/scripts/hook.mjs',
|
||||
'skills/impeccable/scripts/hook-before-edit.mjs',
|
||||
];
|
||||
|
||||
// Retired live-mode state locations. impeccable-paths still reads these as
|
||||
// fallbacks; reporting them is what eventually lets the fallbacks go.
|
||||
const LEGACY_LIVE_PATHS = ['.impeccable-live.json', '.impeccable-live'];
|
||||
|
||||
function finding({ id, artifact, filePath = null, severity, summary, fix }) {
|
||||
return { id, artifact, path: filePath, severity, summary, fix };
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toRelative(filePath, root) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(root, filePath);
|
||||
return rel && !rel.startsWith('..') && !path.isAbsolute(rel)
|
||||
? rel.split(path.sep).join('/')
|
||||
: filePath;
|
||||
}
|
||||
|
||||
function git(args, cwd) {
|
||||
try {
|
||||
return execFileSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DESIGN.md truth drift ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* How much UI work has landed since DESIGN.md was last touched, measured in
|
||||
* commits to the visual source directories. A proxy, and reported as one: a
|
||||
* large number means the document is worth re-reading, not that it is wrong.
|
||||
* Silent outside a git repo, on an untracked DESIGN.md, and when the count is
|
||||
* small enough to be ordinary maintenance.
|
||||
*/
|
||||
export function checkDesignDrift({ designPath, projectRoot, threshold = 25 }) {
|
||||
if (!designPath || !projectRoot) return [];
|
||||
if (!git(['rev-parse', '--is-inside-work-tree'], projectRoot)) return [];
|
||||
|
||||
const relDesign = toRelative(designPath, projectRoot);
|
||||
const lastDesignCommit = git(['log', '-1', '--format=%H', '--', relDesign], projectRoot);
|
||||
if (!lastDesignCommit) return [];
|
||||
|
||||
const dirs = VISUAL_SOURCE_DIRS.filter((dir) => fs.existsSync(path.join(projectRoot, dir)));
|
||||
if (!dirs.length) return [];
|
||||
|
||||
const log = git(
|
||||
['log', '--oneline', `${lastDesignCommit}..HEAD`, '--', ...dirs],
|
||||
projectRoot,
|
||||
);
|
||||
if (log === null) return [];
|
||||
const commits = log ? log.split('\n').filter(Boolean).length : 0;
|
||||
if (commits < threshold) return [];
|
||||
|
||||
const when = git(['log', '-1', '--format=%ad', '--date=short', '--', relDesign], projectRoot);
|
||||
return [finding({
|
||||
id: 'design-md-drift',
|
||||
artifact: 'DESIGN.md',
|
||||
filePath: relDesign,
|
||||
severity: 'route',
|
||||
summary: `${commits} commits have touched ${dirs.join(', ')} since ${relDesign} was last edited`
|
||||
+ `${when ? ` (${when})` : ''}. This counts commits, not contradictions: it says the document is worth `
|
||||
+ 're-reading, not that it is wrong.',
|
||||
fix: 'Read DESIGN.md against the current tokens and components before trusting it as authority. '
|
||||
+ 'If it has genuinely drifted, `document` regenerates it from the code.',
|
||||
})];
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical DESIGN.md sections that carry nothing. Distinct from truth drift:
|
||||
* a section can be absent because it never applied, so this is reported as a
|
||||
* documentation gap for a human to judge, never as an error.
|
||||
*/
|
||||
export function checkDesignCoverage({ design, designPath, parseDesignMd }) {
|
||||
if (!design || typeof parseDesignMd !== 'function') return [];
|
||||
let model;
|
||||
try {
|
||||
model = parseDesignMd(design);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const missing = ['colors', 'typography', 'components']
|
||||
.filter((section) => !model[section]);
|
||||
if (!missing.length) return [];
|
||||
return [finding({
|
||||
id: 'design-md-coverage',
|
||||
artifact: 'DESIGN.md',
|
||||
filePath: designPath,
|
||||
severity: 'mention',
|
||||
summary: `${designPath || 'DESIGN.md'} has no ${missing.join(', ')} section. `
|
||||
+ 'Agents generating new screens get no normative guidance for those, and the live design panel renders '
|
||||
+ 'generic approximations in their place.',
|
||||
fix: 'Ask whether the section never applied or was never written. `document` fills it from the code if the '
|
||||
+ 'project has the answer in its CSS.',
|
||||
})];
|
||||
}
|
||||
|
||||
// ─── detector ignore lists ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ignore entries that no longer match anything: rule ids the engine dropped or
|
||||
* renamed, and file paths that are gone. Both read as working suppressions
|
||||
* until someone checks, and a dead rule ignore also hides that the rule left.
|
||||
*/
|
||||
export function checkDetectorIgnores({ projectRoot, knownRuleIds = null }) {
|
||||
const findings = [];
|
||||
if (!projectRoot) return findings;
|
||||
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const filePath = path.join(projectRoot, '.impeccable', name);
|
||||
const raw = readJson(filePath);
|
||||
const detector = raw?.detector;
|
||||
if (!detector || typeof detector !== 'object') continue;
|
||||
const rel = toRelative(filePath, projectRoot);
|
||||
|
||||
if (knownRuleIds && Array.isArray(detector.ignoreRules)) {
|
||||
const unknown = detector.ignoreRules
|
||||
.map((rule) => String(rule || '').trim().toLowerCase())
|
||||
.filter((rule) => rule && rule !== '*' && !knownRuleIds.has(rule));
|
||||
if (unknown.length) {
|
||||
findings.push(finding({
|
||||
id: 'detector-ignore-rules-unknown',
|
||||
artifact: 'config.json',
|
||||
filePath: rel,
|
||||
severity: 'mention',
|
||||
summary: `${rel} ignores rule id(s) the detector does not have: `
|
||||
+ `${unknown.map((rule) => `\`${rule}\``).join(', ')}. Either the rule was renamed or removed, or the `
|
||||
+ 'id was mistyped and has never suppressed anything.',
|
||||
fix: 'Report the exact ids. Removing them is safe; keeping a dead ignore hides that the rule is gone.',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(detector.ignoreFiles)) {
|
||||
const missing = detector.ignoreFiles
|
||||
.map((entry) => String(entry || '').trim())
|
||||
.filter((entry) => entry && !entry.includes('*') && !fs.existsSync(path.join(projectRoot, entry)));
|
||||
if (missing.length) {
|
||||
findings.push(finding({
|
||||
id: 'detector-ignore-files-missing',
|
||||
artifact: 'config.json',
|
||||
filePath: rel,
|
||||
severity: 'mention',
|
||||
summary: `${rel} ignores file path(s) that no longer exist: `
|
||||
+ `${missing.map((entry) => `\`${entry}\``).join(', ')}.`,
|
||||
fix: 'Ask whether the file moved (repoint the entry) or was deleted (drop it). '
|
||||
+ 'A stale entry silently stops covering the file that replaced it.',
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ─── hook installation ─────────────────────────────────────────────────────
|
||||
|
||||
function collectHookCommands(value, out = []) {
|
||||
if (typeof value === 'string') {
|
||||
if (HOOK_SCRIPT_MARKERS.some((marker) => value.includes(marker))) out.push(value);
|
||||
return out;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) collectHookCommands(entry, out);
|
||||
return out;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
for (const entry of Object.values(value)) collectHookCommands(entry, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/;
|
||||
|
||||
// Pull the script-path token out of a hook command line, placeholders intact.
|
||||
// The forms our manifests ship:
|
||||
// * bare: node "${CLAUDE_PROJECT_DIR}/.../hook.mjs"
|
||||
// * bundle-relative: node ".agents/.../hook.mjs"
|
||||
// * legacy unquoted: node .claude/.../hook.mjs
|
||||
// * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical)
|
||||
// * absolute: node "/Users/.../hook.mjs" (user-level installs)
|
||||
// * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs"
|
||||
// A quoted path wins; the guard's two occurrences are identical, so the first
|
||||
// quoted match is the path. Otherwise fall back to the whitespace/metachar-
|
||||
// delimited token that ends at the marker, so we don't absorb `node`, `[`, `!`
|
||||
// or `||`. Returns the token verbatim; resolution happens separately.
|
||||
function hookScriptTokenFrom(command) {
|
||||
const str = String(command);
|
||||
if (!HOOK_MARKER.test(str)) return null;
|
||||
const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/);
|
||||
if (quoted) return quoted[1];
|
||||
const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/);
|
||||
return bare ? bare[1] : null;
|
||||
}
|
||||
|
||||
// Resolve a script token to an absolute path the doctor can existsSync, or null
|
||||
// when the doctor cannot know where it points — in which case the caller must
|
||||
// NOT report it missing (a doctor never asserts a negative it cannot verify).
|
||||
//
|
||||
// Per-placeholder policy, mirroring what each runtime actually expands:
|
||||
// ${CLAUDE_PROJECT_DIR} → the project root being scanned. This is exactly the
|
||||
// runtime mapping (Claude Code sets it to the project
|
||||
// dir at hook time), so we EXPAND it against `root`.
|
||||
// Not doing so was the #402 bug: the literal
|
||||
// `${CLAUDE_PROJECT_DIR}/...` string never exists.
|
||||
// ${CLAUDE_PLUGIN_ROOT} → plugin-package install dir, set by the harness to
|
||||
// ${PLUGIN_ROOT} wherever the plugin/codex/grok bundle was unpacked
|
||||
// ${GROK_PLUGIN_ROOT} (grok aliases CLAUDE_PLUGIN_ROOT). The doctor has no
|
||||
// way to know that location → SKIP (return null).
|
||||
// $(...) / backticks → command substitution, e.g. GitHub's
|
||||
// `$(git rev-parse --show-toplevel)`. Not statically
|
||||
// resolvable → SKIP.
|
||||
// any other ${VAR}/$VAR → unknown to the doctor → SKIP.
|
||||
// A token with no placeholder is a literal path: absolute as-is, else relative
|
||||
// to `root`.
|
||||
function resolveHookScriptPath(token, root) {
|
||||
if (!token) return null;
|
||||
// Command substitution or backtick expansion we can't evaluate.
|
||||
if (token.includes('$(') || token.includes('`')) return null;
|
||||
const expanded = token.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, root);
|
||||
// Any placeholder or shell variable still present is one we can't map.
|
||||
if (/\$\{[^}]*\}|\$[A-Za-z_]/.test(expanded)) return null;
|
||||
return path.isAbsolute(expanded) ? expanded : path.join(root, expanded);
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook whose script path does not resolve is a silent no-op, and the user
|
||||
* believes the project is covered. Also catches the contradiction of an
|
||||
* installed manifest against `hook.enabled: false`.
|
||||
*/
|
||||
export function checkHookInstallation({ projectRoot, repoRoot, providerId }) {
|
||||
const findings = [];
|
||||
const manifests = HOOK_MANIFESTS_BY_PROVIDER[providerId] || [];
|
||||
if (!manifests.length) return findings;
|
||||
|
||||
const roots = [...new Set([projectRoot, repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
|
||||
let installedAt = null;
|
||||
|
||||
for (const root of roots) {
|
||||
for (const rel of manifests) {
|
||||
const manifestPath = path.join(root, rel);
|
||||
const raw = readJson(manifestPath);
|
||||
if (!raw?.hooks) continue;
|
||||
const commands = collectHookCommands(raw.hooks);
|
||||
if (!commands.length) continue;
|
||||
installedAt = toRelative(manifestPath, projectRoot || root);
|
||||
|
||||
const broken = commands.filter((command) => {
|
||||
const token = hookScriptTokenFrom(command);
|
||||
if (!token) return false;
|
||||
const abs = resolveHookScriptPath(token, root);
|
||||
// Unresolvable placeholder or command substitution: never assert missing.
|
||||
if (!abs) return false;
|
||||
return !fs.existsSync(abs);
|
||||
});
|
||||
if (broken.length) {
|
||||
findings.push(finding({
|
||||
id: 'hook-script-missing',
|
||||
artifact: 'hook manifest',
|
||||
filePath: installedAt,
|
||||
severity: 'mention',
|
||||
summary: `${installedAt} installs the design hook, but its script path does not exist: `
|
||||
+ `${broken.map((command) => `\`${command}\``).join(', ')}. The hook runs as a no-op, so UI edits `
|
||||
+ 'have been going unscanned while the project looks covered.',
|
||||
fix: `Reinstall with \`impeccable hooks on\`, which rewrites the manifest against the skill's current location.`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (installedAt) {
|
||||
for (const root of roots) {
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const raw = readJson(path.join(root, '.impeccable', name));
|
||||
if (raw?.hook && raw.hook.enabled === false) {
|
||||
findings.push(finding({
|
||||
id: 'hook-enabled-conflict',
|
||||
artifact: 'config.json',
|
||||
filePath: toRelative(path.join(root, '.impeccable', name), projectRoot || root),
|
||||
severity: 'mention',
|
||||
summary: `${installedAt} installs the design hook while this config sets \`hook.enabled: false\`, `
|
||||
+ 'so the hook fires and then declines to scan.',
|
||||
fix: 'Ask which was intended: `impeccable hooks on` to enable, or `impeccable hooks off` to uninstall '
|
||||
+ 'the manifest entry as well.',
|
||||
}));
|
||||
return findings;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ─── retired locations ─────────────────────────────────────────────────────
|
||||
|
||||
export function checkLegacyLiveState({ projectRoot }) {
|
||||
if (!projectRoot) return [];
|
||||
const present = LEGACY_LIVE_PATHS.filter((rel) => fs.existsSync(path.join(projectRoot, rel)));
|
||||
if (!present.length) return [];
|
||||
return [finding({
|
||||
id: 'legacy-live-state',
|
||||
artifact: 'live state',
|
||||
filePath: present.join(', '),
|
||||
severity: 'auto',
|
||||
summary: `Live-mode state sits in retired location(s): ${present.map((rel) => `\`${rel}\``).join(', ')}. `
|
||||
+ 'Current live mode writes under `.impeccable/live/`.',
|
||||
fix: 'These are read only through backward-compatible fallbacks and are safe to delete once no live session '
|
||||
+ 'is running. No user decision is needed.',
|
||||
})];
|
||||
}
|
||||
|
||||
// ─── monorepo sweep ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Per-workspace context, plus the case worth acting on: a workspace with
|
||||
* native build files inheriting a repo-root PRODUCT.md that says web. Each
|
||||
* such app gets web guidance and never loads the native references, and
|
||||
* nothing at boot reports it because the root record parses cleanly.
|
||||
*
|
||||
* `candidates` comes from context.mjs's discovery so the walk is not repeated.
|
||||
*/
|
||||
export function checkWorkspaces({ repoRoot, candidates = [], checkNativePlatformEvidence, extractPlatform, readFile }) {
|
||||
if (!repoRoot || !candidates.length) return { findings: [], workspaces: [] };
|
||||
const findings = [];
|
||||
const workspaces = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const workspaceRoot = path.join(repoRoot, candidate.path);
|
||||
const productPath = candidate.productPath ? path.join(repoRoot, candidate.productPath) : null;
|
||||
const product = productPath && readFile ? readFile(productPath) : null;
|
||||
const platform = extractPlatform ? extractPlatform(product) : null;
|
||||
|
||||
workspaces.push({
|
||||
name: candidate.name,
|
||||
path: candidate.path,
|
||||
productStatus: candidate.productStatus,
|
||||
productPath: candidate.productPath,
|
||||
designStatus: candidate.designStatus,
|
||||
designPath: candidate.designPath,
|
||||
platform: platform || (product ? 'web (default)' : null),
|
||||
});
|
||||
|
||||
if (!checkNativePlatformEvidence) continue;
|
||||
const native = checkNativePlatformEvidence({
|
||||
projectRoot: workspaceRoot,
|
||||
platform,
|
||||
product,
|
||||
productPath: candidate.productPath,
|
||||
});
|
||||
for (const entry of native) {
|
||||
findings.push(finding({
|
||||
id: 'workspace-platform-native-evidence',
|
||||
artifact: 'PRODUCT.md',
|
||||
filePath: candidate.productPath || `${candidate.path}/PRODUCT.md`,
|
||||
severity: 'mention',
|
||||
summary: `Workspace \`${candidate.path}\` ${
|
||||
candidate.productStatus === 'inherited'
|
||||
? 'inherits the repo-root PRODUCT.md'
|
||||
: 'has a PRODUCT.md'
|
||||
} that resolves to web, but the workspace itself carries native build files. ${entry.summary}`,
|
||||
fix: candidate.productStatus === 'inherited'
|
||||
? `Give \`${candidate.path}\` its own PRODUCT.md with the right \`## Platform\`. `
|
||||
+ 'An inherited record cannot describe two platforms at once.'
|
||||
: entry.fix,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const inherited = workspaces.filter((entry) => entry.productStatus === 'inherited');
|
||||
if (inherited.length) {
|
||||
findings.push(finding({
|
||||
id: 'workspace-context-inherited',
|
||||
artifact: 'PRODUCT.md',
|
||||
filePath: null,
|
||||
severity: 'mention',
|
||||
summary: `${inherited.length} of ${workspaces.length} workspace(s) inherit the repo-root PRODUCT.md: `
|
||||
+ `${inherited.map((entry) => `\`${entry.path}\``).join(', ')}. Inheritance is intended; whether one `
|
||||
+ 'record truthfully describes these apps is not something this check can tell.',
|
||||
fix: 'Ask the user whether the inherited record describes each app. Where it does not, `init` in that '
|
||||
+ 'workspace writes a child PRODUCT.md that overrides it.',
|
||||
}));
|
||||
}
|
||||
|
||||
return { findings, workspaces };
|
||||
}
|
||||
|
||||
// ─── rule registry ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Rule ids from the bundled detector, or null when it cannot be resolved (a
|
||||
* partial install, or a harness that ships the skill without the engine).
|
||||
* Null means "cannot check", which the ignore-rule check treats as skip rather
|
||||
* than as every id being unknown.
|
||||
*/
|
||||
export async function loadKnownRuleIds(scriptsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')) {
|
||||
// Same two locations detect.mjs resolves: the bundled copy in an installed
|
||||
// skill, then the source-repo engine when running from a checkout.
|
||||
const candidates = [
|
||||
path.join(scriptsDir, 'detector', 'detect-antipatterns.mjs'),
|
||||
path.join(scriptsDir, '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
|
||||
];
|
||||
const detectorPath = candidates.find((candidate) => fs.existsSync(candidate));
|
||||
if (!detectorPath) return null;
|
||||
try {
|
||||
const { ANTIPATTERNS } = await import(pathToFileURL(detectorPath).href);
|
||||
if (!Array.isArray(ANTIPATTERNS)) return null;
|
||||
return new Set(ANTIPATTERNS.map((rule) => String(rule.id).toLowerCase()));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
169
.agents/skills/impeccable/scripts/lib/staleness-notice.mjs
Normal file
169
.agents/skills/impeccable/scripts/lib/staleness-notice.mjs
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Notice throttling and directive rendering for staleness findings.
|
||||
*
|
||||
* The boot path already carries PRODUCT.md, DESIGN.md, a surface brief,
|
||||
* RESOLVED_CONTEXT, the detector fallback, native platform references, and the
|
||||
* update directive. An unthrottled staleness block would push real context out
|
||||
* of attention and train the agent to open every session with housekeeping, so
|
||||
* the rules here are deliberately strict:
|
||||
*
|
||||
* - One directive for the whole set, never one per finding.
|
||||
* - A 'mention' or 'route' finding surfaces at most once a week per project,
|
||||
* mirroring the update check's anti-nag window. A finding the user has
|
||||
* already declined to act on must not reappear tomorrow.
|
||||
* - 'auto' findings are not throttled and are not shown to the user. They are
|
||||
* migrations the next write performs anyway, so the agent needs the note
|
||||
* every session until the write happens, and the user needs it never.
|
||||
*
|
||||
* State lives in the user's home dir alongside the update cache rather than in
|
||||
* the project, so no gitignore entry is owed and a clone does not inherit
|
||||
* someone else's dismissals.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Resolved per call rather than at import so a test (or a sandboxed run) can
|
||||
// redirect the cache without reloading the module.
|
||||
function cachePath() {
|
||||
return process.env.IMPECCABLE_STALENESS_CACHE
|
||||
|| path.join(os.homedir(), '.impeccable', 'staleness-check.json');
|
||||
}
|
||||
|
||||
function readCache() {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(cachePath(), 'utf-8'));
|
||||
return raw && typeof raw === 'object' && raw.projects ? raw : { projects: {} };
|
||||
} catch {
|
||||
return { projects: {} };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop project entries whose newest stamp has aged past the renotify window.
|
||||
* They would be re-notified on the next boot anyway, so keeping them only lets
|
||||
* the file accumulate one entry per directory Impeccable has ever booted in
|
||||
* (scratch dirs and test fixtures included).
|
||||
*/
|
||||
function pruneCache(cache, now) {
|
||||
const projects = {};
|
||||
for (const [key, entries] of Object.entries(cache.projects || {})) {
|
||||
if (!entries || typeof entries !== 'object') continue;
|
||||
const stamps = Object.values(entries).filter((value) => typeof value === 'number');
|
||||
if (stamps.length && now - Math.max(...stamps) < RENOTIFY_INTERVAL_MS) projects[key] = entries;
|
||||
}
|
||||
return { projects };
|
||||
}
|
||||
|
||||
function writeCache(cache) {
|
||||
try {
|
||||
const filePath = cachePath();
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(cache));
|
||||
} catch {
|
||||
// Best-effort. A read-only home dir means the notice repeats next session,
|
||||
// which is strictly better than failing the boot.
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt out with IMPECCABLE_NO_STALENESS_CHECK=1 or `"stalenessCheck": false` in
|
||||
* .impeccable/config.json. Local config overrides shared, matching how
|
||||
* updateCheck resolves.
|
||||
*/
|
||||
export function stalenessCheckDisabled(roots = [process.cwd()]) {
|
||||
if (process.env.IMPECCABLE_NO_STALENESS_CHECK) return true;
|
||||
let value;
|
||||
for (const root of roots) {
|
||||
if (!root) continue;
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const raw = readJson(path.join(root, '.impeccable', name));
|
||||
if (raw && typeof raw === 'object' && typeof raw.stalenessCheck === 'boolean') {
|
||||
value = raw.stalenessCheck;
|
||||
}
|
||||
}
|
||||
}
|
||||
return value === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop findings already surfaced for this project inside the renotify window,
|
||||
* and stamp the ones that survive. 'auto' findings pass through untouched and
|
||||
* unstamped: they are for the agent, not the user, and repeat until fixed.
|
||||
*/
|
||||
export function filterFreshFindings(findings, { projectRoot, now = Date.now() } = {}) {
|
||||
if (!findings.length) return [];
|
||||
const auto = findings.filter((entry) => entry.severity === 'auto');
|
||||
const notifiable = findings.filter((entry) => entry.severity !== 'auto');
|
||||
if (!notifiable.length) return auto;
|
||||
|
||||
const key = path.resolve(projectRoot || process.cwd());
|
||||
const cache = readCache();
|
||||
const seen = cache.projects[key] && typeof cache.projects[key] === 'object' ? cache.projects[key] : {};
|
||||
|
||||
const fresh = notifiable.filter((entry) => {
|
||||
const last = seen[entry.id];
|
||||
return !(typeof last === 'number' && now - last < RENOTIFY_INTERVAL_MS);
|
||||
});
|
||||
|
||||
// Forget stamps for findings that no longer fire, so a recurrence after a
|
||||
// real fix is reported again instead of being suppressed by an old stamp.
|
||||
// This has to run even when nothing is fresh: the common shape is one
|
||||
// finding fixed while another is still inside its window.
|
||||
const live = new Set(notifiable.map((entry) => entry.id));
|
||||
const next = Object.fromEntries(
|
||||
Object.entries(seen).filter(([id]) => live.has(id)),
|
||||
);
|
||||
for (const entry of fresh) next[entry.id] = now;
|
||||
|
||||
const changed = JSON.stringify(next) !== JSON.stringify(seen);
|
||||
if (changed) {
|
||||
const pruned = pruneCache(cache, now);
|
||||
pruned.projects[key] = next;
|
||||
writeCache(pruned);
|
||||
}
|
||||
return [...auto, ...fresh];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the single boot directive, or null when nothing survived throttling.
|
||||
*/
|
||||
export function buildStalenessDirective(findings) {
|
||||
if (!findings.length) return null;
|
||||
const payload = findings.map((entry) => ({
|
||||
id: entry.id,
|
||||
artifact: entry.artifact,
|
||||
path: entry.path,
|
||||
severity: entry.severity,
|
||||
summary: entry.summary,
|
||||
fix: entry.fix,
|
||||
}));
|
||||
|
||||
const hasReportable = findings.some((entry) => entry.severity !== 'auto');
|
||||
const lines = [
|
||||
`CONTEXT_STALE:\n${JSON.stringify(payload, null, 2)}`,
|
||||
"Impeccable's own project files have drifted from what this version reads. "
|
||||
+ 'Do not stop, reorder, or expand the requested task for any of this.',
|
||||
'By severity: `auto` is a migration the next write to that file performs anyway, so apply it then and do not '
|
||||
+ 'raise it with the user. `mention` gets one short line in your reply with the offered fix. `route` names the '
|
||||
+ 'command that owns the repair; offer it, and run it only if the user asks.',
|
||||
'A finding that reports a deprecated field is binding: treat that field as absent for every decision in this '
|
||||
+ 'session, whatever value it holds.',
|
||||
];
|
||||
if (hasReportable) {
|
||||
lines.push('Surface the reportable findings once, after the task response, in at most two sentences. '
|
||||
+ 'They are already throttled, so say them plainly rather than hedging about whether they matter.');
|
||||
}
|
||||
return lines.join(' ');
|
||||
}
|
||||
457
.agents/skills/impeccable/scripts/lib/staleness.mjs
Normal file
457
.agents/skills/impeccable/scripts/lib/staleness.mjs
Normal file
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Staleness detection for Impeccable's own project artifacts: PRODUCT.md,
|
||||
* DESIGN.md and its `.impeccable/design.json` sidecar, `.impeccable/config.json`,
|
||||
* and persisted surface briefs.
|
||||
*
|
||||
* Three kinds of drift live under "out of date", and they want different
|
||||
* handling:
|
||||
*
|
||||
* 1. Tool version drift. The installed skill is older than the published one.
|
||||
* Owned by computeUpdateDirective in context.mjs, not by this module.
|
||||
* 2. Schema drift. An artifact was written by an older Impeccable: fields it
|
||||
* no longer reads, fields it now expects, files in retired locations.
|
||||
* Deterministic, and mostly fixable without asking anyone.
|
||||
* 3. Truth drift. The code moved on and the document no longer describes it.
|
||||
* Not mechanical. `document` and `init` own the rewrite; the most this
|
||||
* module does is measure a proxy and name it as a proxy.
|
||||
*
|
||||
* Two tiers, because the boot path runs on every session:
|
||||
*
|
||||
* Tier 1 (collectBootFindings) spends only what a boot already spends. It
|
||||
* parses markdown context.mjs has in memory, stats a bounded set of paths,
|
||||
* and reads the two small JSON files the boot reads anyway. No directory
|
||||
* walks, no git, no cross-workspace sweep.
|
||||
*
|
||||
* Tier 2 (the doctor pass) is on demand and may walk, shell out to git, and
|
||||
* compare declared tokens against real CSS.
|
||||
*
|
||||
* Findings are data, not prose, so both tiers and the JSON output render the
|
||||
* same set. Severity says what should happen, not how bad it is:
|
||||
*
|
||||
* 'auto' fix it silently the next time that file is written anyway
|
||||
* 'mention' state it once, offer the fix, carry on with the user's task
|
||||
* 'route' needs a specific command, so name the command and the gap
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
PRODUCT_SCHEMA_VERSION,
|
||||
PRODUCT_DEPRECATED_SECTIONS,
|
||||
PRODUCT_V4_SECTIONS,
|
||||
DESIGN_SIDECAR_SCHEMA_VERSION,
|
||||
readProductSchemaVersion,
|
||||
readSidecarSchemaVersion,
|
||||
} from './artifact-schema.mjs';
|
||||
|
||||
// Top-level keys any reader honors: `hook` and `detector` subtrees (hook-lib's
|
||||
// readConfig), `updateCheck` (context.mjs), `projectRoots` (context.mjs's
|
||||
// monorepo resolution), plus `stalenessCheck` below. `$schema` and `version`
|
||||
// are allowed as conventional metadata nobody reads.
|
||||
const KNOWN_CONFIG_KEYS = new Set([
|
||||
'hook',
|
||||
'detector',
|
||||
'updateCheck',
|
||||
'stalenessCheck',
|
||||
'projectRoots',
|
||||
'$schema',
|
||||
'version',
|
||||
]);
|
||||
|
||||
// `detector` is a closed set, so a typo here is worth reporting. `hook` is not
|
||||
// checked: it carries runtime settings from several writers and the false
|
||||
// positive rate would outweigh the catch.
|
||||
const KNOWN_DETECTOR_KEYS = new Set([
|
||||
'ignoreRules',
|
||||
'ignoreFiles',
|
||||
'ignoreValues',
|
||||
'designSystem',
|
||||
'extensions',
|
||||
]);
|
||||
|
||||
// Evidence that a project ships a native app. Checked only to catch a
|
||||
// PRODUCT.md that says web (or says nothing, which resolves to web) on a
|
||||
// project that is plainly not: that combination silently skips the iOS and
|
||||
// Android references for the whole session.
|
||||
const NATIVE_EVIDENCE_PATHS = Object.freeze([
|
||||
{ rel: 'pubspec.yaml', platform: 'adaptive', reason: 'a Flutter pubspec.yaml' },
|
||||
{ rel: 'ios/Podfile', platform: 'ios', reason: 'an ios/Podfile' },
|
||||
{ rel: 'android/build.gradle', platform: 'android', reason: 'an android/build.gradle' },
|
||||
{ rel: 'android/build.gradle.kts', platform: 'android', reason: 'an android/build.gradle.kts' },
|
||||
{ rel: 'ios/Runner.xcodeproj', platform: 'ios', reason: 'an ios/Runner.xcodeproj' },
|
||||
]);
|
||||
|
||||
const NATIVE_EVIDENCE_DEPENDENCIES = Object.freeze([
|
||||
{ name: 'react-native', platform: 'adaptive', reason: 'a react-native dependency' },
|
||||
{ name: 'expo', platform: 'adaptive', reason: 'an expo dependency' },
|
||||
{ name: '@react-native/metro-config', platform: 'adaptive', reason: 'a React Native metro config dependency' },
|
||||
]);
|
||||
|
||||
function finding({ id, artifact, filePath = null, severity, summary, fix }) {
|
||||
return { id, artifact, path: filePath, severity, summary, fix };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every location a design sidecar may live, canonical first. Pure so that both
|
||||
* impeccable-paths (which resolves the project root) and context.mjs (which
|
||||
* cannot import impeccable-paths without a cycle) share one definition of
|
||||
* where the retired locations are.
|
||||
*/
|
||||
export function designSidecarCandidatesFor(projectRoot, contextDir = projectRoot) {
|
||||
const candidates = [
|
||||
path.join(projectRoot, '.impeccable', 'design.json'),
|
||||
path.join(projectRoot, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir || projectRoot, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mtimeMs(filePath) {
|
||||
try {
|
||||
return fs.statSync(filePath).mtimeMs;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasSection(markdown, heading) {
|
||||
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`^##\\s+${escaped}\\s*$`, 'im').test(String(markdown || ''));
|
||||
}
|
||||
|
||||
function toRelative(filePath, root) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(root, filePath);
|
||||
return rel && !rel.startsWith('..') && !path.isAbsolute(rel)
|
||||
? rel.split(path.sep).join('/')
|
||||
: filePath;
|
||||
}
|
||||
|
||||
// ─── PRODUCT.md ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pure: schema drift visible in a PRODUCT.md body. `productPath` is used for
|
||||
* reporting only.
|
||||
*/
|
||||
export function checkProduct(product, productPath = 'PRODUCT.md') {
|
||||
if (!product) return [];
|
||||
const findings = [];
|
||||
|
||||
for (const [heading, reason] of Object.entries(PRODUCT_DEPRECATED_SECTIONS)) {
|
||||
if (!hasSection(product, heading)) continue;
|
||||
findings.push(finding({
|
||||
id: `product-deprecated-${heading.toLowerCase()}`,
|
||||
artifact: 'PRODUCT.md',
|
||||
filePath: productPath,
|
||||
severity: 'mention',
|
||||
summary: `PRODUCT.md still carries a \`## ${heading}\` section. ${reason}`,
|
||||
fix: `Treat \`## ${heading}\` as absent for every decision this session. `
|
||||
+ 'Offer to delete the section; do not let its value influence the work either way.',
|
||||
}));
|
||||
}
|
||||
|
||||
const stamped = readProductSchemaVersion(product);
|
||||
if (stamped === null && !PRODUCT_V4_SECTIONS.some((section) => hasSection(product, section))) {
|
||||
findings.push(finding({
|
||||
id: 'product-schema-legacy',
|
||||
artifact: 'PRODUCT.md',
|
||||
filePath: productPath,
|
||||
severity: 'route',
|
||||
summary: 'PRODUCT.md has no schema stamp and none of the sections the current record adds '
|
||||
+ `(${PRODUCT_V4_SECTIONS.join(', ')}), so it predates this version of the product record.`,
|
||||
fix: 'Offer `init`, which preserves confirmed answers and fills the gaps by interview. '
|
||||
+ 'Do not rewrite the file from inference.',
|
||||
}));
|
||||
} else if (stamped !== null && stamped < PRODUCT_SCHEMA_VERSION) {
|
||||
findings.push(finding({
|
||||
id: 'product-schema-outdated',
|
||||
artifact: 'PRODUCT.md',
|
||||
filePath: productPath,
|
||||
severity: 'route',
|
||||
summary: `PRODUCT.md is stamped product-schema ${stamped}; the current record is ${PRODUCT_SCHEMA_VERSION}.`,
|
||||
fix: 'Offer `init` to bring the record current, preserving confirmed answers.',
|
||||
}));
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* A project that resolves to web while carrying native build files. Bounded:
|
||||
* a handful of stats plus one package.json read at the project root.
|
||||
*/
|
||||
export function checkNativePlatformEvidence({ projectRoot, platform, product, productPath }) {
|
||||
if (!projectRoot) return [];
|
||||
// Only the web resolution is worth checking. An explicit native value is
|
||||
// already honored, and an unrecognized value already gets its own warning.
|
||||
if (platform && platform !== 'web') return [];
|
||||
|
||||
const evidence = [];
|
||||
for (const entry of NATIVE_EVIDENCE_PATHS) {
|
||||
if (fs.existsSync(path.join(projectRoot, entry.rel))) evidence.push(entry);
|
||||
}
|
||||
const pkg = readJson(path.join(projectRoot, 'package.json'));
|
||||
if (pkg) {
|
||||
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
||||
for (const entry of NATIVE_EVIDENCE_DEPENDENCIES) {
|
||||
if (deps[entry.name]) evidence.push(entry);
|
||||
}
|
||||
}
|
||||
if (!evidence.length) return [];
|
||||
|
||||
const platforms = new Set(evidence.map((entry) => entry.platform));
|
||||
const suggested = platforms.size > 1 || platforms.has('adaptive')
|
||||
? 'adaptive'
|
||||
: [...platforms][0];
|
||||
const declared = platform === 'web'
|
||||
? 'PRODUCT.md declares `## Platform: web`'
|
||||
: product
|
||||
? 'PRODUCT.md has no `## Platform` section, so the project resolves to web'
|
||||
: 'no PRODUCT.md declares a platform, so the project resolves to web';
|
||||
|
||||
return [finding({
|
||||
id: 'platform-native-evidence',
|
||||
artifact: 'PRODUCT.md',
|
||||
filePath: productPath || null,
|
||||
severity: 'mention',
|
||||
summary: `${declared}, but the project carries ${evidence.map((entry) => entry.reason).join(' and ')}. `
|
||||
+ 'Web guidance is being applied to a native codebase, and the iOS and Android references never load.',
|
||||
fix: `Ask the user whether \`## Platform\` should be \`${suggested}\`. `
|
||||
+ 'If it should, write the value and load the matching native reference before designing.',
|
||||
})];
|
||||
}
|
||||
|
||||
// ─── DESIGN.md and the design.json sidecar ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sidecar drift: retired location, schema version behind, or older than the
|
||||
* DESIGN.md it extends. Costs three stats and one small JSON read.
|
||||
*
|
||||
* `sidecarCandidates` comes from impeccable-paths' resolver so this module
|
||||
* stays out of the business of knowing where sidecars may live; the first
|
||||
* entry is the canonical location.
|
||||
*/
|
||||
export function checkDesignSidecar({ designPath, sidecarCandidates = [], projectRoot }) {
|
||||
const findings = [];
|
||||
const canonical = sidecarCandidates[0] || null;
|
||||
const present = sidecarCandidates.find((candidate) => fs.existsSync(candidate)) || null;
|
||||
if (!present) return findings;
|
||||
|
||||
const relPresent = toRelative(present, projectRoot);
|
||||
|
||||
if (canonical && path.resolve(present) !== path.resolve(canonical)) {
|
||||
findings.push(finding({
|
||||
id: 'design-sidecar-legacy-path',
|
||||
artifact: 'design.json',
|
||||
filePath: relPresent,
|
||||
severity: 'auto',
|
||||
summary: `The design sidecar sits at ${relPresent}, a location kept only for backward compatibility.`,
|
||||
fix: `Move it to ${toRelative(canonical, projectRoot)} the next time the sidecar is written. `
|
||||
+ 'No user decision is needed.',
|
||||
}));
|
||||
}
|
||||
|
||||
const sidecar = readJson(present);
|
||||
const schemaVersion = readSidecarSchemaVersion(sidecar);
|
||||
if (sidecar && (schemaVersion === null || schemaVersion < DESIGN_SIDECAR_SCHEMA_VERSION)) {
|
||||
findings.push(finding({
|
||||
id: 'design-sidecar-schema-outdated',
|
||||
artifact: 'design.json',
|
||||
filePath: relPresent,
|
||||
severity: 'route',
|
||||
summary: `${relPresent} is schemaVersion ${schemaVersion === null ? 'unset' : schemaVersion}; `
|
||||
+ `the current sidecar is ${DESIGN_SIDECAR_SCHEMA_VERSION}. Token primitives moved to the DESIGN.md `
|
||||
+ 'frontmatter, so the old shape carries values that are now read from two places.',
|
||||
fix: 'Offer `document` to regenerate the sidecar. It reads the existing DESIGN.md, so no interview is needed.',
|
||||
}));
|
||||
}
|
||||
|
||||
if (designPath) {
|
||||
const designMtime = mtimeMs(designPath);
|
||||
const sidecarMtime = mtimeMs(present);
|
||||
if (designMtime !== null && sidecarMtime !== null && designMtime > sidecarMtime) {
|
||||
findings.push(finding({
|
||||
id: 'design-sidecar-stale',
|
||||
artifact: 'design.json',
|
||||
filePath: relPresent,
|
||||
severity: 'mention',
|
||||
summary: `DESIGN.md was edited after ${relPresent} was generated, so the sidecar's ramps, `
|
||||
+ 'shadows, motion tokens, and component snippets may contradict it.',
|
||||
fix: 'Offer `document` to refresh the sidecar, preserving DESIGN.md.',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ─── .impeccable/config.json ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Unrecognized keys in the shared and local configs. A key nothing reads is
|
||||
* indistinguishable from a working setting until someone checks, which is how
|
||||
* a singular `ignoreRule` silences nothing for months.
|
||||
*/
|
||||
export function checkConfig({ projectRoot, repoRoot }) {
|
||||
const findings = [];
|
||||
const roots = [...new Set([projectRoot, repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
|
||||
for (const root of roots) {
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const filePath = path.join(root, '.impeccable', name);
|
||||
const raw = readJson(filePath);
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
|
||||
const rel = toRelative(filePath, projectRoot || root);
|
||||
|
||||
const unknownTop = Object.keys(raw).filter((key) => !KNOWN_CONFIG_KEYS.has(key));
|
||||
if (unknownTop.length) {
|
||||
findings.push(finding({
|
||||
id: 'config-unknown-keys',
|
||||
artifact: 'config.json',
|
||||
filePath: rel,
|
||||
severity: 'mention',
|
||||
summary: `${rel} has top-level key(s) nothing reads: ${unknownTop.map((key) => `\`${key}\``).join(', ')}. `
|
||||
+ `Recognized keys are ${[...KNOWN_CONFIG_KEYS].map((key) => `\`${key}\``).join(', ')}.`,
|
||||
fix: 'Report the exact keys to the user. A near-miss of a real key is a setting that has never applied.',
|
||||
}));
|
||||
}
|
||||
|
||||
const detector = raw.detector;
|
||||
if (detector && typeof detector === 'object' && !Array.isArray(detector)) {
|
||||
const unknownDetector = Object.keys(detector).filter((key) => !KNOWN_DETECTOR_KEYS.has(key));
|
||||
if (unknownDetector.length) {
|
||||
findings.push(finding({
|
||||
id: 'config-unknown-detector-keys',
|
||||
artifact: 'config.json',
|
||||
filePath: rel,
|
||||
severity: 'mention',
|
||||
summary: `${rel} has \`detector\` key(s) nothing reads: ${unknownDetector.map((key) => `\`${key}\``).join(', ')}. `
|
||||
+ `Recognized keys are ${[...KNOWN_DETECTOR_KEYS].map((key) => `\`${key}\``).join(', ')}.`,
|
||||
fix: 'Report the exact keys. `ignoreRule` for `ignoreRules` is the common one, and it silences nothing.',
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ─── Surface briefs ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A brief whose primary target no longer exists still resolves and still gets
|
||||
* injected as authority for a surface that is gone. Route and URL targets have
|
||||
* no file to check and are skipped.
|
||||
*/
|
||||
export function checkSurfaceBriefs({ candidates = [], projectRoot }) {
|
||||
if (!projectRoot) return [];
|
||||
const orphaned = [];
|
||||
for (const brief of candidates) {
|
||||
const target = brief?.primaryTarget;
|
||||
if (!target || typeof target !== 'string') continue;
|
||||
if (/^https?:\/\//i.test(target) || target.startsWith('route:')) continue;
|
||||
if (!fs.existsSync(path.join(projectRoot, target))) orphaned.push(brief);
|
||||
}
|
||||
if (!orphaned.length) return [];
|
||||
return [finding({
|
||||
id: 'surface-brief-orphaned',
|
||||
artifact: 'surface brief',
|
||||
filePath: orphaned.map((brief) => brief.path).filter(Boolean).join(', ') || null,
|
||||
severity: 'mention',
|
||||
summary: `${orphaned.length} persisted surface brief(s) name a primary target that no longer exists: `
|
||||
+ `${orphaned.map((brief) => `${brief.path} → ${brief.primaryTarget}`).join('; ')}.`,
|
||||
fix: 'Ask whether the surface moved (repoint the brief) or was removed (delete the brief). '
|
||||
+ 'Until then the brief is authority for a file that is gone.',
|
||||
})];
|
||||
}
|
||||
|
||||
// ─── Monorepo structure ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `projectRoots` globs that match no directory. When every pattern misses,
|
||||
* candidate discovery returns nothing, the repo root silently becomes the
|
||||
* active project, and no other signal fires.
|
||||
*
|
||||
* Takes the candidate list rather than computing it: the boot path has already
|
||||
* paid for that walk, and this module must not pay for it twice.
|
||||
*/
|
||||
export function checkProjectRoots({ patterns = [], candidates = [], configuredIn = '.impeccable/config.json' }) {
|
||||
const positive = patterns.filter((pattern) => pattern && !String(pattern).trim().startsWith('!'));
|
||||
if (!positive.length || candidates.length) return [];
|
||||
return [finding({
|
||||
id: 'config-project-roots-match-nothing',
|
||||
artifact: 'config.json',
|
||||
filePath: configuredIn,
|
||||
severity: 'mention',
|
||||
summary: `\`projectRoots\` declares ${positive.map((pattern) => `\`${pattern}\``).join(', ')}, `
|
||||
+ 'but no directory matches any of them, so the repo root is being treated as the active project.',
|
||||
fix: 'Report the patterns and ask which directories they should name. A renamed workspace folder is the usual cause.',
|
||||
})];
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspaces that inherit the repo-root PRODUCT.md. Inheritance is a feature,
|
||||
* not a defect, so this is reported as information for the doctor pass rather
|
||||
* than emitted at boot: the judgment call is whether the inherited record
|
||||
* actually describes that app.
|
||||
*/
|
||||
export function describeWorkspaceContext(candidates = []) {
|
||||
return candidates.map((candidate) => ({
|
||||
name: candidate.name,
|
||||
path: candidate.path,
|
||||
productStatus: candidate.productStatus,
|
||||
productPath: candidate.productPath,
|
||||
designStatus: candidate.designStatus,
|
||||
designPath: candidate.designPath,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Tier 1 orchestration ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Everything a boot can afford. `ctx` is the loadContext result; `extras`
|
||||
* carries values the caller already computed so nothing is recomputed here.
|
||||
*/
|
||||
export function collectBootFindings(ctx, extras = {}) {
|
||||
if (!ctx) return [];
|
||||
const projectRoot = ctx.projectRoot || process.cwd();
|
||||
const absProductPath = extras.absProductPath || null;
|
||||
const absDesignPath = extras.absDesignPath || null;
|
||||
|
||||
return [
|
||||
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
|
||||
// Only checked once a PRODUCT.md exists. Without one the boot already
|
||||
// emits NO_PRODUCT_MD and routes into init, which asks for the platform
|
||||
// directly; a second signal saying the same thing is noise.
|
||||
...(ctx.product
|
||||
? checkNativePlatformEvidence({
|
||||
projectRoot,
|
||||
platform: ctx.platform,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
})
|
||||
: []),
|
||||
...checkDesignSidecar({
|
||||
designPath: absDesignPath,
|
||||
sidecarCandidates: extras.sidecarCandidates || [],
|
||||
projectRoot,
|
||||
}),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...(extras.projectRootPatterns
|
||||
? checkProjectRoots({
|
||||
patterns: extras.projectRootPatterns,
|
||||
candidates: extras.targetCandidates || [],
|
||||
})
|
||||
: []),
|
||||
];
|
||||
}
|
||||
151
.agents/skills/impeccable/scripts/lib/surface-briefs.mjs
Normal file
151
.agents/skills/impeccable/scripts/lib/surface-briefs.mjs
Normal file
@@ -0,0 +1,151 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { slugFromTarget } from './target-slug.mjs';
|
||||
|
||||
export const SURFACE_BRIEF_VERSION = 1;
|
||||
|
||||
export function getSurfaceBriefDir(projectRoot) {
|
||||
return path.join(projectRoot, '.impeccable', 'surfaces');
|
||||
}
|
||||
|
||||
export function normalizeSurfaceTarget(target, { projectRoot = process.cwd() } = {}) {
|
||||
if (!target || typeof target !== 'string' || !target.trim()) return null;
|
||||
const trimmed = target.trim();
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
url.hash = '';
|
||||
url.search = '';
|
||||
return url.toString().replace(/\/$/, '') || url.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (/^route:/i.test(trimmed)) {
|
||||
const route = trimmed.slice(trimmed.indexOf(':') + 1).trim();
|
||||
if (!route.startsWith('/') || route.includes('..')) return null;
|
||||
const normalizedRoute = route.split(/[?#]/, 1)[0].replace(/\/{2,}/g, '/').replace(/\/$/, '') || '/';
|
||||
return `route:${normalizedRoute}`;
|
||||
}
|
||||
if (trimmed === '/') return 'route:/';
|
||||
if (trimmed.startsWith('/')) {
|
||||
const absolute = path.resolve(trimmed);
|
||||
const relativeToProject = path.relative(projectRoot, absolute);
|
||||
const isProjectFile = relativeToProject && !relativeToProject.startsWith('..') && !path.isAbsolute(relativeToProject);
|
||||
if (!isProjectFile && !fs.existsSync(absolute) && !trimmed.includes('..')) {
|
||||
const normalizedRoute = trimmed.split(/[?#]/, 1)[0].replace(/\/{2,}/g, '/').replace(/\/$/, '') || '/';
|
||||
return `route:${normalizedRoute}`;
|
||||
}
|
||||
}
|
||||
const abs = path.isAbsolute(trimmed) ? trimmed : path.resolve(projectRoot, trimmed);
|
||||
const rel = path.relative(projectRoot, abs);
|
||||
if (!rel || rel === '.' || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
||||
return rel.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
export function surfaceBriefPathForTarget(target, { projectRoot = process.cwd() } = {}) {
|
||||
const normalized = normalizeSurfaceTarget(target, { projectRoot });
|
||||
if (!normalized) return null;
|
||||
const slugInput = normalized.startsWith('route:') ? `route${normalized.slice('route:'.length)}` : normalized;
|
||||
const slug = slugFromTarget(slugInput, { cwd: projectRoot });
|
||||
return slug ? path.join(getSurfaceBriefDir(projectRoot), `${slug}.md`) : null;
|
||||
}
|
||||
|
||||
export function parseSurfaceBrief(text, filePath = null) {
|
||||
const match = String(text || '').match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
||||
const meta = {};
|
||||
if (match) {
|
||||
for (const line of match[1].split(/\r?\n/)) {
|
||||
const colon = line.indexOf(':');
|
||||
if (colon < 0) continue;
|
||||
const key = line.slice(0, colon).trim();
|
||||
const raw = line.slice(colon + 1).trim();
|
||||
if (!key) continue;
|
||||
if (/^(?:\[|\{|\")/.test(raw) || /^(?:true|false|null|-?\d+(?:\.\d+)?)$/.test(raw)) {
|
||||
try { meta[key] = JSON.parse(raw); continue; } catch { /* keep string */ }
|
||||
}
|
||||
meta[key] = raw.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
}
|
||||
const primaryTarget = typeof meta.primary_target === 'string' ? meta.primary_target : null;
|
||||
const relatedTargets = Array.isArray(meta.related_targets)
|
||||
? meta.related_targets.filter((value) => typeof value === 'string')
|
||||
: [];
|
||||
return {
|
||||
path: filePath,
|
||||
text: String(text || ''),
|
||||
body: match ? String(text || '').slice(match[0].length).trim() : String(text || '').trim(),
|
||||
meta,
|
||||
slug: typeof meta.slug === 'string' ? meta.slug : filePath ? path.basename(filePath, '.md') : null,
|
||||
primaryTarget,
|
||||
relatedTargets,
|
||||
targets: [primaryTarget, ...relatedTargets].filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
export function listSurfaceBriefs(projectRoot = process.cwd()) {
|
||||
const dir = getSurfaceBriefDir(projectRoot);
|
||||
let names;
|
||||
try {
|
||||
names = fs.readdirSync(dir).filter((name) => name.endsWith('.md')).sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return names.flatMap((name) => {
|
||||
const filePath = path.join(dir, name);
|
||||
try {
|
||||
return [parseSurfaceBrief(fs.readFileSync(filePath, 'utf-8'), filePath)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveSurfaceBrief(projectRoot = process.cwd(), target = null) {
|
||||
const briefs = listSurfaceBriefs(projectRoot);
|
||||
if (!target) {
|
||||
return {
|
||||
brief: briefs.length === 1 ? briefs[0] : null,
|
||||
candidates: briefs,
|
||||
reason: briefs.length === 1 ? 'only-brief' : briefs.length > 1 ? 'ambiguous' : 'none',
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = normalizeSurfaceTarget(target, { projectRoot });
|
||||
if (!normalized) return { brief: null, candidates: briefs, reason: 'invalid-target' };
|
||||
const exactPath = surfaceBriefPathForTarget(normalized, { projectRoot });
|
||||
const exact = briefs.find((brief) => brief.path === exactPath && (!brief.targets.length || brief.targets.includes(normalized)));
|
||||
if (exact) return { brief: exact, candidates: briefs, reason: 'slug' };
|
||||
const mapped = briefs.filter((brief) => brief.targets.includes(normalized));
|
||||
return {
|
||||
brief: mapped.length === 1 ? mapped[0] : null,
|
||||
candidates: mapped.length > 1 ? mapped : briefs,
|
||||
reason: mapped.length === 1 ? 'mapping' : mapped.length > 1 ? 'ambiguous-target' : 'not-found',
|
||||
};
|
||||
}
|
||||
|
||||
export function writeSurfaceBrief({
|
||||
projectRoot = process.cwd(),
|
||||
primaryTarget,
|
||||
relatedTargets = [],
|
||||
body,
|
||||
}) {
|
||||
const normalizedPrimary = normalizeSurfaceTarget(primaryTarget, { projectRoot });
|
||||
if (!normalizedPrimary) throw new Error('surface brief requires a concrete project-relative primary target or URL');
|
||||
const normalizedRelated = [...new Set(relatedTargets
|
||||
.map((target) => normalizeSurfaceTarget(target, { projectRoot }))
|
||||
.filter((target) => target && target !== normalizedPrimary))];
|
||||
const slug = slugFromTarget(normalizedPrimary, { cwd: projectRoot });
|
||||
const filePath = surfaceBriefPathForTarget(normalizedPrimary, { projectRoot });
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const frontmatter = [
|
||||
'---',
|
||||
`version: ${SURFACE_BRIEF_VERSION}`,
|
||||
`slug: ${JSON.stringify(slug)}`,
|
||||
`primary_target: ${JSON.stringify(normalizedPrimary)}`,
|
||||
`related_targets: ${JSON.stringify(normalizedRelated)}`,
|
||||
'---',
|
||||
].join('\n');
|
||||
fs.writeFileSync(filePath, `${frontmatter}\n\n${String(body || '').trim()}\n`, 'utf-8');
|
||||
return filePath;
|
||||
}
|
||||
42
.agents/skills/impeccable/scripts/lib/target-args.mjs
Normal file
42
.agents/skills/impeccable/scripts/lib/target-args.mjs
Normal file
@@ -0,0 +1,42 @@
|
||||
class TargetArgError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'TargetArgError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTargetPath(args = [], { strict = false } = {}) {
|
||||
let targetPath = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i]);
|
||||
if (arg === '--target' || arg === '-t') {
|
||||
const next = args[i + 1];
|
||||
if (next && !String(next).startsWith('-')) {
|
||||
targetPath = String(next);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--target=')) {
|
||||
const value = arg.slice('--target='.length);
|
||||
if (value) {
|
||||
targetPath = value;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export function parseTargetOptions(args = [], options = {}) {
|
||||
const targetPath = parseTargetPath(args, options);
|
||||
return targetPath ? { targetPath } : {};
|
||||
}
|
||||
33
.agents/skills/impeccable/scripts/lib/target-slug.mjs
Normal file
33
.agents/skills/impeccable/scripts/lib/target-slug.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
import path from 'node:path';
|
||||
|
||||
const SLUG_MAX = 50;
|
||||
|
||||
/** Derive one clone-stable slug from a concrete file path or URL. */
|
||||
export function slugFromTarget(resolved, { cwd = process.cwd() } = {}) {
|
||||
if (!resolved || typeof resolved !== 'string') return null;
|
||||
const trimmed = resolved.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
let url;
|
||||
try { url = new URL(trimmed); } catch { return null; }
|
||||
return kebab(`${url.hostname}${url.pathname}`);
|
||||
}
|
||||
|
||||
const abs = path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
let rel = path.relative(cwd, abs);
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) rel = path.basename(abs);
|
||||
if (!rel || rel === '.') return null;
|
||||
return kebab(rel);
|
||||
}
|
||||
|
||||
export function kebab(value) {
|
||||
const slug = String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[/\\.]+/g, '-')
|
||||
.replace(/[^a-z0-9-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
if (!slug) return null;
|
||||
return slug.length <= SLUG_MAX ? slug : slug.slice(slug.length - SLUG_MAX).replace(/^-/, '');
|
||||
}
|
||||
146
.agents/skills/impeccable/scripts/lib/template-extensions.mjs
Normal file
146
.agents/skills/impeccable/scripts/lib/template-extensions.mjs
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* One owner for "which file extensions hold UI markup".
|
||||
*
|
||||
* Before this module the answer was spelled out separately in hook-lib.mjs
|
||||
* (`detector.extensions` config, issue #316) and in live-wrap.mjs /
|
||||
* live-accept.mjs (a hardcoded `EXTENSIONS` array, duplicated verbatim in both).
|
||||
* The lists drifted: the hook learned configurable server-template extensions
|
||||
* while Live kept its six frontend defaults, so a Phoenix project got design
|
||||
* findings on `.heex` files but `Session markers not found` on Accept (#374).
|
||||
*
|
||||
* Extensions are matched against the END OF THE FILENAME, not `path.extname`,
|
||||
* so double extensions like `.blade.php`, `.html.erb`, and `.html.heex` work.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Built-in markup extensions for Live's wrap/accept source search.
|
||||
*
|
||||
* Elixir's `.ex` is here because Phoenix function components put `~H"""`
|
||||
* templates directly in `lib/**\/*.ex`; `.heex` and `.eex` cover standalone
|
||||
* templates. `.exs` is deliberately absent: those are Elixir *scripts*
|
||||
* (`mix.exs`, `config/*.exs`, tests) and never hold markup, so including them
|
||||
* only gives the wrap query a chance to match build config by accident.
|
||||
*/
|
||||
export const LIVE_TEMPLATE_EXTENSIONS = Object.freeze([
|
||||
'.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro',
|
||||
'.ex', '.heex', '.eex',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Normalize `detector.extensions` entries to `{ ext, engine }`.
|
||||
*
|
||||
* Accepts `{ ext, engine }` objects (engine 'html' | 'text', default 'html' —
|
||||
* the common case for server-side templates) or bare strings as shorthand.
|
||||
*/
|
||||
export function normalizeExtensionEntries(entries) {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
const out = [];
|
||||
for (const entry of entries) {
|
||||
const raw = typeof entry === 'string' ? entry : entry?.ext;
|
||||
if (typeof raw !== 'string') continue;
|
||||
let ext = raw.trim().toLowerCase();
|
||||
if (!ext) continue;
|
||||
if (!ext.startsWith('.')) ext = `.${ext}`;
|
||||
const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html';
|
||||
out.push({ ext, engine });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function mergeExtensions(existing, incoming) {
|
||||
const map = new Map();
|
||||
for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry);
|
||||
for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry);
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
export function matchConfiguredExtension(filePath, extensions) {
|
||||
if (!Array.isArray(extensions) || extensions.length === 0) return null;
|
||||
const name = path.basename(String(filePath || '')).toLowerCase();
|
||||
if (!name) return null;
|
||||
// The longest matching suffix wins, so `.blade.php` beats a broader `.php`
|
||||
// entry regardless of config order.
|
||||
let best = null;
|
||||
for (const entry of normalizeExtensionEntries(extensions)) {
|
||||
if (name.length > entry.ext.length && name.endsWith(entry.ext)
|
||||
&& (!best || entry.ext.length > best.ext.length)) {
|
||||
best = entry;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this filename end in one of `extensions`?
|
||||
*
|
||||
* Suffix matching rather than `path.extname` equality, so a configured
|
||||
* `.html.erb` matches `show.html.erb` (whose extname is only `.erb`). The
|
||||
* `name.length > ext.length` guard keeps a file literally named `.heex` from
|
||||
* counting as a template.
|
||||
*/
|
||||
export function matchesTemplateExtension(filePath, extensions) {
|
||||
const name = path.basename(String(filePath || '')).toLowerCase();
|
||||
if (!name) return false;
|
||||
for (const ext of extensions) {
|
||||
if (name.length > ext.length && name.endsWith(ext)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in Live extensions plus any the project configured for the detector.
|
||||
*
|
||||
* Reading `detector.extensions` here is the point: a user who taught the design
|
||||
* hook about `.blade.php` should not have to teach Live separately. Config
|
||||
* parsing is intentionally minimal (own the shape, not the whole hook config)
|
||||
* so this module stays importable from the Live CLI without pulling in
|
||||
* hook-lib.mjs.
|
||||
*/
|
||||
export function resolveLiveTemplateExtensions(cwd = process.cwd()) {
|
||||
const cached = extensionCache.get(cwd);
|
||||
if (cached) return cached;
|
||||
const resolved = readLiveTemplateExtensions(cwd);
|
||||
extensionCache.set(cwd, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// live-wrap calls the resolver once per candidate query per pass (up to eight
|
||||
// times in one CLI run), and every call would otherwise re-read and re-parse
|
||||
// both config files. Keyed by cwd; a single CLI process never rewrites its own
|
||||
// config mid-run.
|
||||
const extensionCache = new Map();
|
||||
|
||||
/** Test seam: drop the memoized config so a fixture can rewrite config.json. */
|
||||
export function clearTemplateExtensionCache() {
|
||||
extensionCache.clear();
|
||||
}
|
||||
|
||||
function readLiveTemplateExtensions(cwd) {
|
||||
const configured = [];
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const raw = safeReadJson(path.join(cwd, '.impeccable', name));
|
||||
const detector = raw?.detector;
|
||||
if (detector && typeof detector === 'object' && !Array.isArray(detector)) {
|
||||
configured.push(...normalizeExtensionEntries(detector.extensions));
|
||||
}
|
||||
}
|
||||
const seen = new Set(LIVE_TEMPLATE_EXTENSIONS);
|
||||
const out = [...LIVE_TEMPLATE_EXTENSIONS];
|
||||
for (const { ext } of configured) {
|
||||
if (seen.has(ext)) continue;
|
||||
seen.add(ext);
|
||||
out.push(ext);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function safeReadJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user