@starci/hfs 4.8.0 → 4.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/emit/operation-schemas.mjs +3 -0
- package/lint/bound-sys.cjs +2 -2
- package/package.json +1 -1
- package/runtime/engine/admission.mjs +1 -1
- package/runtime/engine/config.mjs +2 -2
- package/runtime/engine/db/machine.mjs +2 -3
- package/runtime/engine/invalid-config.mjs +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +22 -14
- package/runtime/knowledge/hfs/rules.yaml +23 -0
- package/runtime/modules/kernel/failure-codes.yaml +10 -0
- package/runtime/scripts/guards/git-policy.mjs +2 -3
- package/runtime/scripts/hfs/rules/pipeline.mjs +5 -5
- package/runtime/scripts/lib/package-at.mjs +5 -6
- package/runtime/scripts/lib/roots.mjs +2 -2
- package/runtime/scripts/work/draw/draw-dna-findings.mjs +23 -12
- package/runtime/scripts/work/draw/draw-dna.mjs +14 -8
- package/scaffold/app.mjs +4 -4
- package/src/main.mjs +3 -3
- package/sync/index.mjs +14 -1
- package/sync/managed.mjs +3 -2
- package/templates/app/codegen-graphql.mjs.partial +4 -4
- package/templates/app/quality-config/sonar-project.properties +4 -2
- package/runtime/engine/canonical-json.mjs +0 -14
- package/runtime/engine/db/ledger-open.mjs +0 -38
- package/runtime/engine/db/ledger-paths.mjs +0 -87
- package/runtime/engine/db/ledger.mjs +0 -1068
- package/runtime/engine/db/provision-authority.mjs +0 -76
- package/runtime/engine/db/provision-operations.mjs +0 -146
- package/runtime/packages/grammar/scripts/build-stamp.mjs +0 -98
- package/runtime/scripts/agent/codex-app-server.mjs +0 -79
- package/runtime/scripts/agent/codex-mirror-source.mjs +0 -34
- package/runtime/scripts/agent/host-agents.mjs +0 -80
- package/runtime/scripts/agent/launch-trust-policy.mjs +0 -53
- package/runtime/scripts/agent/trust-launch.mjs +0 -191
- package/runtime/scripts/agent/trust.mjs +0 -523
- package/runtime/scripts/api/fs/temp-path.mjs +0 -8
- package/runtime/scripts/api/git/diff-names.mjs +0 -8
- package/runtime/scripts/api/git/head-time.mjs +0 -10
- package/runtime/scripts/api/git/index-lock.mjs +0 -78
- package/runtime/scripts/api/git/log.mjs +0 -5
- package/runtime/scripts/api/git/merge-tree.mjs +0 -5
- package/runtime/scripts/api/git/rev-list.mjs +0 -5
- package/runtime/scripts/api/git/rev-parse.mjs +0 -13
- package/runtime/scripts/api/git/status-query.mjs +0 -5
- package/runtime/scripts/api/git/worktree-list-query.mjs +0 -5
- package/runtime/scripts/api/node/spawn-node.mjs +0 -6
- package/runtime/scripts/api/npm/dist-tag.mjs +0 -8
- package/runtime/scripts/api/npm/lib.mjs +0 -32
- package/runtime/scripts/api/npm/pack-dry-run.mjs +0 -9
- package/runtime/scripts/api/npm/run-npm.mjs +0 -6
- package/runtime/scripts/api/npm/view.mjs +0 -30
- package/runtime/scripts/api/npm/whoami.mjs +0 -8
- package/runtime/scripts/api/process/hide-child-windows.mjs +0 -31
- package/runtime/scripts/api/process/kill-tree.mjs +0 -22
- package/runtime/scripts/api/process/lib.mjs +0 -125
- package/runtime/scripts/api/process/probe-guard-command.mjs +0 -74
- package/runtime/scripts/api/process/process-list.mjs +0 -28
- package/runtime/scripts/api/process/system-tool.mjs +0 -37
- package/runtime/scripts/gates/canon-digest.mjs +0 -81
- package/runtime/scripts/gates/canon-pins.mjs +0 -119
- package/runtime/scripts/gates/gate-input.mjs +0 -98
- package/runtime/scripts/gates/gate.mjs +0 -636
- package/runtime/scripts/gates/grammar-dist.mjs +0 -206
- package/runtime/scripts/gates/output.mjs +0 -40
- package/runtime/scripts/gates/runtime-package-selection.mjs +0 -27
- package/runtime/scripts/gates/test-world-run.mjs +0 -252
- package/runtime/scripts/gates/type-impact.mjs +0 -277
- package/runtime/scripts/gates/type-program.mjs +0 -113
- package/runtime/scripts/guards/assigned-command.mjs +0 -12
- package/runtime/scripts/guards/call-rights.mjs +0 -47
- package/runtime/scripts/guards/command-guard.mjs +0 -468
- package/runtime/scripts/guards/command-policy.mjs +0 -342
- package/runtime/scripts/guards/critic-reach.mjs +0 -82
- package/runtime/scripts/guards/deps-guard.mjs +0 -150
- package/runtime/scripts/guards/env-dump-verdict.mjs +0 -89
- package/runtime/scripts/guards/guards-root.mjs +0 -27
- package/runtime/scripts/guards/hook-install.mjs +0 -343
- package/runtime/scripts/guards/hook-verdict.mjs +0 -12
- package/runtime/scripts/guards/install-verdict.mjs +0 -41
- package/runtime/scripts/guards/kernel-seat.mjs +0 -63
- package/runtime/scripts/guards/launch-verdict.mjs +0 -74
- package/runtime/scripts/guards/op-starci.mjs +0 -16
- package/runtime/scripts/guards/op-write-scope.mjs +0 -27
- package/runtime/scripts/guards/orca-self-lifecycle.mjs +0 -39
- package/runtime/scripts/guards/process-kill-verdict.mjs +0 -106
- package/runtime/scripts/guards/protected-zone.mjs +0 -56
- package/runtime/scripts/guards/refusals.mjs +0 -27
- package/runtime/scripts/guards/rights.mjs +0 -268
- package/runtime/scripts/guards/seat-tools.mjs +0 -47
- package/runtime/scripts/guards/shell-commands.mjs +0 -328
- package/runtime/scripts/guards/supervisor-seat.mjs +0 -80
- package/runtime/scripts/guards/verdict-process.mjs +0 -81
- package/runtime/scripts/lib/app-data.mjs +0 -11
- package/runtime/scripts/lib/canon-binding-text.mjs +0 -129
- package/runtime/scripts/lib/check-format.mjs +0 -7
- package/runtime/scripts/lib/failure-code-reads.mjs +0 -100
- package/runtime/scripts/lib/failure-code-template-errors.mjs +0 -161
- package/runtime/scripts/lib/git-dir.mjs +0 -120
- package/runtime/scripts/lib/guard-command.mjs +0 -24
- package/runtime/scripts/lib/interrupt-cleanup.mjs +0 -38
- package/runtime/scripts/lib/json-formats.mjs +0 -34
- package/runtime/scripts/lib/managed-json.mjs +0 -51
- package/runtime/scripts/lib/menu-parts.mjs +0 -11
- package/runtime/scripts/lib/op-gate.mjs +0 -15
- package/runtime/scripts/lib/provision-control-failure.mjs +0 -32
- package/runtime/scripts/lib/release-version.mjs +0 -134
- package/runtime/scripts/lib/runtime-location.mjs +0 -21
- package/runtime/scripts/lib/runtime-package-inventory.mjs +0 -21
- package/runtime/scripts/lib/runtime-package-manifests.mjs +0 -84
- package/runtime/scripts/lib/runtime-publication-tags.mjs +0 -56
- package/runtime/scripts/lib/seat-env.mjs +0 -43
- package/runtime/scripts/lib/sonar-new-code.mjs +0 -58
- package/runtime/scripts/lib/source-package-imports.mjs +0 -111
- package/runtime/scripts/lib/tar-files.mjs +0 -39
- package/runtime/scripts/lib/uat-run-folder.mjs +0 -118
- package/runtime/scripts/lib/worktree-record.mjs +0 -14
- package/runtime/scripts/machine/ask-receipts.mjs +0 -113
- package/runtime/scripts/machine/evidence-store.mjs +0 -223
- package/runtime/scripts/machine/home.mjs +0 -216
- package/runtime/scripts/machine/host-lock.mjs +0 -184
- package/runtime/scripts/machine/lock-recovery.mjs +0 -55
- package/runtime/scripts/machine/roles-contract.mjs +0 -90
- package/runtime/scripts/machine/runtime-change.mjs +0 -20
- package/runtime/scripts/machine/workflow-tree.mjs +0 -122
- package/runtime/scripts/reconciler/checklist-items.mjs +0 -7
- package/runtime/scripts/reconciler/guard-row.mjs +0 -25
- package/runtime/scripts/work/brand/brand-colour.mjs +0 -125
- package/runtime/scripts/work/brand/brand-tokens.mjs +0 -167
- package/runtime/scripts/work/brand/brand-value-source.mjs +0 -106
- package/runtime/scripts/work/brand/brand.mjs +0 -892
- package/runtime/scripts/work/draw/draw-layer.mjs +0 -306
- package/runtime/scripts/work/draw/draw-rationale-page.mjs +0 -237
- package/runtime/scripts/work/draw/draw-rationale-ref.mjs +0 -14
- package/runtime/scripts/work/draw/draw-rationale.mjs +0 -353
- package/runtime/scripts/work/draw/draw-source.mjs +0 -412
- package/runtime/scripts/work/draw/draw-taste.mjs +0 -278
- package/runtime/scripts/work/draw/draw-typecheck.mjs +0 -72
- package/runtime/scripts/work/draw/rationale-findings.mjs +0 -205
- package/runtime/scripts/work/draw-grammar.mjs +0 -175
- package/runtime/scripts/work/draw-render-page.mjs +0 -188
- package/runtime/scripts/work/draw-render.mjs +0 -626
- package/runtime/scripts/work/png.mjs +0 -237
- package/runtime/scripts/work/render-tools.mjs +0 -64
|
@@ -1,892 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import {openLedgerReader,ledgerFileFor} from '../../../engine/db/ledger.mjs';
|
|
4
|
-
import {receiptsAnswering,receiptFileOf,receiptRefOf} from '../../machine/ask-receipts.mjs';
|
|
5
|
-
import {sha256} from '../../../engine/digest.mjs';
|
|
6
|
-
import { isMain } from '../../lib/is-main.mjs';
|
|
7
|
-
import {parseYaml} from '../../../engine/yaml.mjs';
|
|
8
|
-
import {skillRoot} from '../../../engine/runtime-root.mjs';
|
|
9
|
-
import {slash, sameOrUnder} from '../../lib/path-key.mjs';
|
|
10
|
-
import {objectList} from '../../lib/list.mjs';
|
|
11
|
-
import {formatCheckLines} from '../../lib/check-format.mjs';
|
|
12
|
-
import { isInside as inside } from '../../lib/walk.mjs';
|
|
13
|
-
import {readJsonFile as readJson} from '../../lib/json.mjs';
|
|
14
|
-
import {parseColor,contrastRatio,deltaEOk,TOKEN_TOLERANCE} from './brand-colour.mjs';
|
|
15
|
-
import {OWED_TO,checkValueSource as checkSource} from './brand-value-source.mjs';
|
|
16
|
-
import {readText,readSourceTokens,lookupToken} from './brand-tokens.mjs';
|
|
17
|
-
import {text as trimmedText} from '../../lib/stack-declaration.mjs';
|
|
18
|
-
export {TOKEN_TOLERANCE,parseColor,contrastRatio,deltaEOk,rgbToOklab,oklabToRgb,oklabToOklch,oklchToOklab,formatHex} from './brand-colour.mjs';
|
|
19
|
-
export {parseCssCustomProperties,parseTokenData,readSourceTokens} from './brand-tokens.mjs';
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* The brand is proven, not stated. A brand record says what the product's colour, mascot and icon law is;
|
|
23
|
-
* this module re-derives each of those claims from the artefacts that actually ship - the frontend's CSS
|
|
24
|
-
* custom properties, the asset bytes under the tree, the import lines of the source, the grammar family's
|
|
25
|
-
* DNA - and reports every claim it could not reproduce.
|
|
26
|
-
*
|
|
27
|
-
* Nothing here renders, installs, commits or edits: every check reads, and the colour mathematics is
|
|
28
|
-
* implemented in brand-colour.mjs so a brand is never proven by a dependency that may not be installed.
|
|
29
|
-
*
|
|
30
|
-
* A check that cannot be performed is `skip` with the reason, never `pass`: a missing source root, an
|
|
31
|
-
* absent grammar snapshot or a source file this repository does not carry leaves the claim unproven, and an
|
|
32
|
-
* unproven claim must not read as a proven one.
|
|
33
|
-
*/
|
|
34
|
-
const BRAND_CHECKS='starci/brand-checks@1';
|
|
35
|
-
export const CHECK_IDS=['tokens-match-source','contrast-aa','primary-danger-distinct','mascot-assets-present','icon-set-only','tokens-in-grammar','direction'];
|
|
36
|
-
/** WCAG 2.x: text needs 4.5:1, a non-text indicator (primary on its surface) needs 3:1. */
|
|
37
|
-
const DEFAULT_MIN_CONTRAST=4.5;
|
|
38
|
-
const NON_TEXT_MIN_CONTRAST=3;
|
|
39
|
-
/**
|
|
40
|
-
* HeroUI status tones: a solid pair (`--<tone>` + `--<tone>-foreground`) and a soft pair (`--<tone>-soft` +
|
|
41
|
-
* `--<tone>-soft-foreground`, the Chip/Badge/Alert `soft` and `flat` variants). Owner decision 2026-09-30: the
|
|
42
|
-
* soft foreground on its own soft tint is accepted at 3:1, and a bare status glyph on a page ground takes the
|
|
43
|
-
* soft foreground instead of a darkened solid tone. A soft pair is a token with role `<tone>-soft`.
|
|
44
|
-
*/
|
|
45
|
-
const SOFT_MIN_CONTRAST=3;
|
|
46
|
-
export const STATUS_TONES=['success','warning','info','danger'];
|
|
47
|
-
const softToneOf=token=>{const match=/^(success|warning|info|danger)-soft$/.exec(token.role??'');return match?match[1]:null;};
|
|
48
|
-
/**
|
|
49
|
-
* Every OKLab distance in this module is reported on the x100 scale (Euclidean distance in OKLab times
|
|
50
|
-
* 100), so black against white is 100 and a just-noticeable difference is roughly 2. A destructive colour
|
|
51
|
-
* must be 20 apart from the primary - a fifth of the whole perceptual range - or a user reads "delete" as
|
|
52
|
-
* "continue". The match tolerance is half a unit: below perception, wide enough for hex/oklch rounding.
|
|
53
|
-
*/
|
|
54
|
-
const MIN_PRIMARY_DANGER_DELTA=20;
|
|
55
|
-
const ASSET_EXTENSIONS=['.png','.svg','.webp','.jpg','.jpeg'];
|
|
56
|
-
export const OFFENDER_CAP=20;
|
|
57
|
-
const SCAN_EXCLUDED=new Set(['node_modules','dist','.next','.git','.dist','coverage','build','out','.turbo','.cache']);
|
|
58
|
-
const SCAN_FILE_LIMIT=5000;
|
|
59
|
-
const SCAN_BYTES_LIMIT=512*1024;
|
|
60
|
-
/** Packages whose name advertises icons; used only to decide what counts as an icon import to judge. */
|
|
61
|
-
const ICON_HINT=/icon|lucide|phosphor|feather|font-?awesome|material-symbols|tabler|remixicon|boxicons|ionicons|bootstrap-icons/i;
|
|
62
|
-
|
|
63
|
-
const round=(value,places=4)=>Number.parseFloat(Number(value).toFixed(places));
|
|
64
|
-
const digest=sha256;
|
|
65
|
-
|
|
66
|
-
// ---------------------------------------------------------------------------
|
|
67
|
-
// The brand record and the grammar canon.
|
|
68
|
-
// ---------------------------------------------------------------------------
|
|
69
|
-
|
|
70
|
-
/** The grammar canon the host installs: authored YAML under `knowledge/grammars`, read directly. */
|
|
71
|
-
export function defaultGrammarRoot(){
|
|
72
|
-
return path.join(skillRoot,'knowledge','grammars');
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Reads `<tree>/brand/index.yaml` (or `<tree>/.starciwork/brand/index.yaml`, so a repository root is also a
|
|
77
|
-
* usable argument). A missing or wrong-kind record throws: that is a broken input, not a failed check, and
|
|
78
|
-
* reporting it as a check would make "no brand" look like a brand with nothing wrong.
|
|
79
|
-
*/
|
|
80
|
-
export function readBrandRecord(tree){
|
|
81
|
-
const root=path.resolve(tree);
|
|
82
|
-
const candidates=[path.join(root,'brand','index.yaml'),path.join(root,'.starciwork','brand','index.yaml')];
|
|
83
|
-
const file=candidates.find(candidate=>fs.existsSync(candidate)&&fs.lstatSync(candidate).isFile());
|
|
84
|
-
if(!file)throw new Error(`No brand record: expected ${candidates.map(candidate=>slash(path.relative(root,candidate))).join(' or ')}.`);
|
|
85
|
-
const source=readText(file);
|
|
86
|
-
const record=parseYaml(source);
|
|
87
|
-
// work/brand@1 is the one family modules/schemas/work-layout.yaml allows for the brand record.
|
|
88
|
-
if(record?.schema!=='work/brand@1'||record?.kind!=='brand')throw new Error('A brand record must be a work/brand@1 record of kind brand.');
|
|
89
|
-
if(!record.brand||typeof record.brand!=='object'||Array.isArray(record.brand))throw new Error('The brand record carries no brand specification.');
|
|
90
|
-
const declared=record.rev??record.revision??record.brand.rev;
|
|
91
|
-
return {file,dir:path.dirname(file),record,brand:record.brand,
|
|
92
|
-
rev:declared===undefined?digest(source).slice(0,12):String(declared),
|
|
93
|
-
revSource:declared===undefined?'content-digest':'declared',
|
|
94
|
-
family:typeof record.brand.identity?.family==='string'?record.brand.identity.family:null};
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** The token names one grammar family's DNA declares. `.yaml` authored, `.json` built - both are the canon. */
|
|
98
|
-
function grammarTokenNames({family,grammarRoot=defaultGrammarRoot()}){
|
|
99
|
-
if(!family)return {file:null,names:[],error:'the brand declares no identity.family'};
|
|
100
|
-
if(!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(family))return {file:null,names:[],error:'the declared family is not a usable directory name'};
|
|
101
|
-
const candidates=['DNA.yaml','DNA.yml','DNA.json'].map(name=>path.join(grammarRoot,family,name));
|
|
102
|
-
const file=candidates.find(candidate=>fs.existsSync(candidate)&&fs.lstatSync(candidate).isFile());
|
|
103
|
-
if(!file)return {file:null,names:[],error:`the host carries no DNA snapshot for grammar family \`${family}\``};
|
|
104
|
-
try{
|
|
105
|
-
const text=readText(file);
|
|
106
|
-
const dna=path.extname(file).toLowerCase()==='.json'?JSON.parse(text):parseYaml(text);
|
|
107
|
-
const names=(Array.isArray(dna?.tokens)?dna.tokens:[]).map(token=>token?.name).filter(name=>typeof name==='string');
|
|
108
|
-
return {file,names,error:names.length?null:'the DNA snapshot declares no tokens'};
|
|
109
|
-
}catch(error){return {file,names:[],error:`unreadable DNA snapshot: ${String(error.message??error)}`};}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// ---------------------------------------------------------------------------
|
|
113
|
-
// The seven checks. Each takes a plain context and returns one check result.
|
|
114
|
-
// ---------------------------------------------------------------------------
|
|
115
|
-
|
|
116
|
-
const check=(id,outcome,detail,evidence={})=>({id,outcome,detail,evidence});
|
|
117
|
-
const brandTokens=brand=>objectList(brand?.color?.tokens).filter(token=>typeof token.token==='string');
|
|
118
|
-
const byRole=(tokens,role)=>tokens.find(token=>token.role===role)??null;
|
|
119
|
-
|
|
120
|
-
const STAGE_ALIASES=Object.freeze({decide:'decide','brand.decide':'decide',verify:'verify','review.verify':'verify'});
|
|
121
|
-
function brandStage(value){
|
|
122
|
-
if(value===undefined||value===null||value==='')return 'decide';
|
|
123
|
-
const stage=STAGE_ALIASES[String(value)];
|
|
124
|
-
if(!stage)throw new Error(`Unknown brand check stage ${JSON.stringify(value)}: expected one of ${Object.keys(STAGE_ALIASES).join(', ')}.`);
|
|
125
|
-
return stage;
|
|
126
|
-
}
|
|
127
|
-
/** The statuses tokens-match-source accepts: the app source carries the colour, or (decide only) its reference file or its ruling does. */
|
|
128
|
-
const TOKEN_PASS_STATUSES=Object.freeze(['match','planned-from-reference','planned-from-ruling']);
|
|
129
|
-
const isPlannedToken=token=>token?.valueSource!==undefined&&token?.valueSource!==null;
|
|
130
|
-
|
|
131
|
-
export const checkValueSource=options=>checkSource({findReceipt:findOwnerReceipt,...options});
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* 1. The brand is bound to the source, not copied from it. Every colour token the brand declares must exist
|
|
135
|
-
* in a declared source file with the same colour, compared in OKLab so `#7547ff` and its oklch spelling are
|
|
136
|
-
* one colour and a near-miss is still a miss. A token the app has not written yet passes at the `decide`
|
|
137
|
-
* stage as `planned-from-reference` when its `valueSource` reference declares exactly its value; once a
|
|
138
|
-
* declared source carries the token the normal match applies, and at the `verify` stage a planned token
|
|
139
|
-
* still absent from every declared source is `planned-source-missing`.
|
|
140
|
-
*/
|
|
141
|
-
function checkTokensMatchSource({brand,sourceRoot,stage='decide',brandDir=null}){
|
|
142
|
-
const id='tokens-match-source';
|
|
143
|
-
const phase=brandStage(stage);
|
|
144
|
-
const tokens=brandTokens(brand);
|
|
145
|
-
const planned=tokens.filter(isPlannedToken);
|
|
146
|
-
const declared=objectList(brand?.sources).filter(source=>['css','tokens'].includes(source.kind));
|
|
147
|
-
// At verify a planned token must be in the app's own source; a check that could read no source is not a pass.
|
|
148
|
-
const unprovenAtVerify=(why,evidence)=>{if(phase!=='verify'||!planned.length){return null;}const suffix=planned.length===1?'':'s';return check(id,'fail',`${why} At the verify stage the planned token${suffix} ${planned.map(token=>token.token).join(', ')} must be declared by the app's own source.`,{...evidence,stage:phase,tokens:planned.map(token=>({token:token.token,expected:token.value??null,actual:null,status:'planned-source-missing'}))});};
|
|
149
|
-
if(!sourceRoot)return check(id,'skip','No --source repository root was given, so the brand\'s colour claims were not compared against shipped source.',{tokens:tokens.length,sources:declared.length,stage:phase});
|
|
150
|
-
if(!tokens.length)return check(id,'skip','The brand declares no colour tokens to bind.',{sourceRoot:slash(sourceRoot),stage:phase});
|
|
151
|
-
if(!declared.length){
|
|
152
|
-
const why='The brand names no source file of kind css or tokens, so nothing binds its colours.';
|
|
153
|
-
return unprovenAtVerify(why,{sourceRoot:slash(sourceRoot)})??check(id,'skip',why,{sourceRoot:slash(sourceRoot),tokens:tokens.length,stage:phase});
|
|
154
|
-
}
|
|
155
|
-
const sources=declared.map(source=>readSourceTokens(sourceRoot,source));
|
|
156
|
-
const readable=sources.filter(source=>source.lookup);
|
|
157
|
-
const files=sources.map(({lookup,...rest})=>rest);
|
|
158
|
-
const refused=files.filter(source=>source.staleGrammarDist);
|
|
159
|
-
if(refused.length)return check(id,'fail',`Refusing to bind the brand to a stale grammar build: ${refused.map(source=>source.error).join(' ')}`,
|
|
160
|
-
{sourceRoot:slash(sourceRoot),files,stage:phase});
|
|
161
|
-
if(!readable.length){
|
|
162
|
-
const why='Not one declared colour source could be read from this repository root, so no token was compared.';
|
|
163
|
-
return unprovenAtVerify(why,{sourceRoot:slash(sourceRoot),files})??check(id,'skip',why,{sourceRoot:slash(sourceRoot),files,stage:phase});
|
|
164
|
-
}
|
|
165
|
-
const findings=tokens.map(token=>{
|
|
166
|
-
const expected=parseColor(token.value);
|
|
167
|
-
const isPlanned=isPlannedToken(token);
|
|
168
|
-
// A planned token's reference render is never its app source, even when sources[] also lists that file.
|
|
169
|
-
const referencePath=isPlanned&&typeof token.valueSource?.path==='string'?slash(token.valueSource.path):null;
|
|
170
|
-
const found=lookupToken(referencePath?readable.filter(source=>source.path!==referencePath):readable,token.token);
|
|
171
|
-
if(!expected)return {token:token.token,expected:token.value??null,actual:found?.value??null,status:'unparseable-brand-value'};
|
|
172
|
-
if(!found){
|
|
173
|
-
if(!isPlanned)return {token:token.token,expected:token.value,actual:null,status:'absent'};
|
|
174
|
-
const {status,...reference}=checkValueSource({sourceRoot,token,brandDir});
|
|
175
|
-
if(phase==='verify')return {token:token.token,expected:token.value,actual:null,status:'planned-source-missing',
|
|
176
|
-
valueSource:{...reference,referenceStatus:status,why:'the app source does not declare this planned token at the verify stage'}};
|
|
177
|
-
return {token:token.token,expected:token.value,actual:null,status,valueSource:reference};
|
|
178
|
-
}
|
|
179
|
-
const actual=parseColor(found.value);
|
|
180
|
-
if(!actual)return {token:token.token,expected:token.value,actual:found.value,file:found.file,scope:found.scope,status:'unparseable-source-value'};
|
|
181
|
-
const delta=round(deltaEOk(expected,actual),3);
|
|
182
|
-
let status='differs';if(found.scope==='dark')status='only-in-dark-scope';else if(delta<=TOKEN_TOLERANCE)status='match';
|
|
183
|
-
return {token:token.token,expected:token.value,actual:found.value,sourceValue:found.declaredValue??found.value,resolvedFrom:found.resolvedFrom??null,expectedHex:expected.hex,actualHex:actual.hex,
|
|
184
|
-
file:found.file,selector:found.selector,scope:found.scope,deltaE:delta,...(isPlanned?{plannedTokenWritten:true}:{}),
|
|
185
|
-
status};
|
|
186
|
-
});
|
|
187
|
-
const bad=findings.filter(finding=>!TOKEN_PASS_STATUSES.includes(finding.status));
|
|
188
|
-
const fromReference=findings.filter(finding=>finding.status==='planned-from-reference');
|
|
189
|
-
const fromRuling=findings.filter(finding=>finding.status==='planned-from-ruling');
|
|
190
|
-
const evidence={sourceRoot:slash(sourceRoot),tolerance:TOKEN_TOLERANCE,stage:phase,files,tokens:findings};
|
|
191
|
-
const planNote=fromReference.map(finding=>`${finding.token} (${finding.valueSource.path} ${finding.valueSource.token})`).join(', ');
|
|
192
|
-
const rulingNote=fromRuling.map(finding=>`${finding.token} (ruling ${finding.valueSource.ruling}, ${finding.valueSource.answeredBy})`).join(', ');
|
|
193
|
-
const rulingText=fromRuling.length?`; ${fromRuling.length} planned from the ledger's recorded ruling and owed to ${OWED_TO} as a declaration in the app theme: ${rulingNote}`:'';
|
|
194
|
-
const passed=fromReference.length||fromRuling.length
|
|
195
|
-
?`All ${findings.length} brand colour tokens are bound: ${findings.length-fromReference.length-fromRuling.length} present in the shipped source with the declared colour, ${fromReference.length} planned from their reference render until the app source declares them: ${planNote}${rulingText}.`
|
|
196
|
-
:`All ${findings.length} brand colour tokens are present in the shipped source with the declared colour.`;
|
|
197
|
-
return bad.length
|
|
198
|
-
?check(id,'fail',`${bad.length} of ${findings.length} brand colour tokens do not match the shipped source: ${bad.map(finding=>finding.token+' ('+finding.status+(finding.valueSource?.why?': '+finding.valueSource.why:'')+')').join(', ')}.`,evidence)
|
|
199
|
-
:check(id,'pass',passed,evidence);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* An owner-accepted contrast exception is bound to the ratio measured when the owner accepted it. Two
|
|
204
|
-
* decimals of WCAG ratio move by about 0.01 between a hex and its oklch spelling; a twentieth is below any
|
|
205
|
-
* visible change and far above that rounding, so a colour that moved after the answer is a new question.
|
|
206
|
-
*/
|
|
207
|
-
const CONTRAST_EXCEPTION_TOLERANCE=0.05;
|
|
208
|
-
/** The receipt serve-ask writes when an ask is answered (scripts/kernel/ask-server.mjs). */
|
|
209
|
-
export const OWNER_ANSWER_SCHEMA='starci/ask-answer@1';
|
|
210
|
-
const OWNER_ANSWERER='owner';
|
|
211
|
-
|
|
212
|
-
/** `<work>/brand` -> the Work root and the repository root the record's receipt paths are relative to. */
|
|
213
|
-
function workRootsOf(brandDir){
|
|
214
|
-
if(!brandDir)return null;
|
|
215
|
-
const work=path.dirname(path.resolve(brandDir));
|
|
216
|
-
return {work,repoRoot:path.basename(work)==='.starciwork'?path.dirname(work):work};
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/** The receipts answering `dispatchId` in the project ledger of `repoRoot`, newest first; [] when it cannot be read. */
|
|
220
|
-
function ledgerReceiptsOf(repoRoot,dispatchId){
|
|
221
|
-
let db=null;
|
|
222
|
-
try{db=openLedgerReader(ledgerFileFor(repoRoot));return receiptsAnswering(db,dispatchId);}
|
|
223
|
-
catch{return [];}
|
|
224
|
-
finally{try{db?.close();}catch{}}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* The owner's answer an exception cites, read from its starci/ask-answer@1 receipt: the named `receipt`
|
|
229
|
-
* (`blob:<sha256>` - the answer serve-ask stored, scripts/machine/ask-receipts.mjs - or a path inside the
|
|
230
|
-
* repository), else the newest receipt answering `acceptedBy` in the project ledger. Only an answer the owner
|
|
231
|
-
* gave counts: an auto-accepted recommendation is not the owner accepting a sub-AA pair.
|
|
232
|
-
*/
|
|
233
|
-
export function findOwnerReceipt({acceptedBy,receipt=null,brandDir=null,answerer=OWNER_ANSWERER,receiptsOf=ledgerReceiptsOf}){
|
|
234
|
-
const roots=workRootsOf(brandDir);
|
|
235
|
-
if(!roots)return {ok:false,why:'no Work tree was given, so no owner answer receipt could be read'};
|
|
236
|
-
const judge=(file,answer)=>{
|
|
237
|
-
const named=receiptRefOf(file,roots.repoRoot);
|
|
238
|
-
if(answer?.schema!==OWNER_ANSWER_SCHEMA)return {ok:false,why:`${named} is not a ${OWNER_ANSWER_SCHEMA} receipt`};
|
|
239
|
-
if(answer.dispatchId!==acceptedBy)return {ok:false,why:`${named} answers ${answer.dispatchId??'(no dispatch)'}, not ${acceptedBy}`};
|
|
240
|
-
if(answer.answeredBy!==answerer)return {ok:false,why:`${acceptedBy} was answered by ${answer.answeredBy??'(nobody)'}, not ${answerer===OWNER_ANSWERER?'the owner':answerer}`};
|
|
241
|
-
return {ok:true,provisional:answer.provisional===true,gatesOk:answer.acceptance?.receipt?.ok===true,file:named,dispatchId:answer.dispatchId,answeredBy:answer.answeredBy,at:answer.at??null,option:answer.option??null,optionIndex:answer.optionIndex??null,note:answer.note??null,picks:answer.picks??null,review:answer.review??null};
|
|
242
|
-
};
|
|
243
|
-
if(receipt!==null&&receipt!==undefined){
|
|
244
|
-
if(typeof receipt!=='string'||!receipt.trim())return {ok:false,why:'receipt must be blob:<sha256> or the path of the owner answer receipt'};
|
|
245
|
-
const stored=receipt.trim().startsWith('blob:')?receiptFileOf(receipt):null;
|
|
246
|
-
const file=stored??[path.resolve(roots.repoRoot,receipt),path.resolve(roots.work,receipt)]
|
|
247
|
-
.filter(candidate=>inside(roots.repoRoot,candidate))
|
|
248
|
-
.find(candidate=>fs.existsSync(candidate)&&fs.lstatSync(candidate).isFile());
|
|
249
|
-
if(!file)return {ok:false,why:`the receipt ${slash(receipt)} is not a stored answer and is not a file inside this repository`};
|
|
250
|
-
return judge(file,readJson(file));
|
|
251
|
-
}
|
|
252
|
-
const found=receiptsOf(roots.repoRoot,acceptedBy);
|
|
253
|
-
if(found.length)return judge(found[0].file,found[0].receipt);
|
|
254
|
-
return {ok:false,why:`no receipt answers ${acceptedBy} in the project ledger`};
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/** Why an exception entry is refused (its two tokens, pair, ratio, reason, answer and measured ratio checked in order), or null. */
|
|
258
|
-
function exceptionRefusal(fields,{foregroundToken,backgroundToken,measured,seen}){
|
|
259
|
-
if(!foregroundToken)return `foreground ${JSON.stringify(fields.foreground??null)} is not a colour token this brand declares`;
|
|
260
|
-
if(!backgroundToken)return `background ${JSON.stringify(fields.background??null)} is not a colour token this brand declares`;
|
|
261
|
-
if(fields.foreground===fields.background)return 'a token is not a contrast pair with itself';
|
|
262
|
-
const key=`${fields.foreground}\u0000${fields.background}`;
|
|
263
|
-
if(seen.has(key))return 'a second exception for the same pair; one pair carries one owner answer';
|
|
264
|
-
seen.add(key);
|
|
265
|
-
if(!(typeof fields.ratio==='number'&&Number.isFinite(fields.ratio)&&fields.ratio>=1))return 'ratio must be the contrast ratio the owner accepted, a number of at least 1';
|
|
266
|
-
if(!trimmedText(fields.reason))return 'reason must say why the owner accepted this pair below the floor';
|
|
267
|
-
if(!trimmedText(fields.acceptedBy))return 'acceptedBy names no owner answer; an exception without an owner receipt is refused';
|
|
268
|
-
if(measured===null)return 'a token of the pair is not a colour this runtime can parse, so the pair cannot be measured';
|
|
269
|
-
if(Math.abs(measured-fields.ratio)>CONTRAST_EXCEPTION_TOLERANCE)
|
|
270
|
-
return `the pair measures ${measured}:1, not the ${fields.ratio}:1 the owner accepted (tolerance ${CONTRAST_EXCEPTION_TOLERANCE}); a changed colour needs a new owner answer`;
|
|
271
|
-
return null;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/** One `contrastExceptions[]` entry judged: `valid` with its owner receipt, or `refused` with the reason. */
|
|
275
|
-
function judgeContrastException(entry,index,{byName,seen,brandDir}){
|
|
276
|
-
const fields=entry&&typeof entry==='object'&&!Array.isArray(entry)?entry:{};
|
|
277
|
-
const foregroundToken=byName.get(fields.foreground)??null,backgroundToken=byName.get(fields.background)??null;
|
|
278
|
-
const fgColor=foregroundToken?parseColor(foregroundToken.value):null,bgColor=backgroundToken?parseColor(backgroundToken.value):null;
|
|
279
|
-
const measured=fgColor&&bgColor?round(contrastRatio(bgColor,fgColor),2):null;
|
|
280
|
-
const base={index,foreground:fields.foreground??null,background:fields.background??null,ratio:fields.ratio??null,measured,
|
|
281
|
-
reason:fields.reason??null,acceptedBy:fields.acceptedBy??null,receipt:fields.receipt??null,fgColor,bgColor};
|
|
282
|
-
const refuse=why=>({...base,status:'refused',why});
|
|
283
|
-
const refusal=exceptionRefusal(fields,{foregroundToken,backgroundToken,measured,seen});
|
|
284
|
-
if(refusal!==null)return refuse(refusal);
|
|
285
|
-
const owner=findOwnerReceipt({acceptedBy:trimmedText(fields.acceptedBy),receipt:fields.receipt??null,brandDir});
|
|
286
|
-
if(!owner.ok)return refuse(`no owner receipt: ${owner.why}`);
|
|
287
|
-
return {...base,acceptedBy:trimmedText(fields.acceptedBy),receipt:owner.file,answeredBy:owner.answeredBy,answeredAt:owner.at,status:'valid'};
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* `color.policy.contrastExceptions[]`: each entry names one pair by its two brand token names, the ratio the
|
|
292
|
-
* owner accepted, why, and the owner answer that accepted it. An entry is `valid` only when both tokens are
|
|
293
|
-
* declared, the pair still measures within CONTRAST_EXCEPTION_TOLERANCE of the recorded ratio, and the
|
|
294
|
-
* owner's receipt for `acceptedBy` is on disk; anything else is `refused` with the reason.
|
|
295
|
-
*/
|
|
296
|
-
function readContrastExceptions({brand,tokens,brandDir}){
|
|
297
|
-
const raw=brand?.color?.policy?.contrastExceptions;
|
|
298
|
-
if(raw===undefined||raw===null)return [];
|
|
299
|
-
if(!Array.isArray(raw))return [{index:0,status:'refused',why:'color.policy.contrastExceptions must be a list of exceptions'}];
|
|
300
|
-
const context={byName:new Map(tokens.map(token=>[token.token,token])),seen:new Set(),brandDir};
|
|
301
|
-
return raw.map((entry,index)=>judgeContrastException(entry,index,context));
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/** The declared token a fill's foreground value is: `<token>-foreground` or the `<role>-foreground` role. */
|
|
305
|
-
function foregroundNameOf(tokens,byName,token,colour){
|
|
306
|
-
const names=[`${token.token}-foreground`,...tokens.filter(other=>token.role&&other.role===`${token.role}-foreground`).map(other=>other.token)];
|
|
307
|
-
return names.find(name=>{const value=byName.has(name)?parseColor(byName.get(name).value):null;return value&&deltaEOk(value,colour)<=TOKEN_TOLERANCE;})??null;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/** A bare status glyph or title takes the soft foreground; it must read as a non-text mark on every ground the brand declares. */
|
|
311
|
-
function statusGlyphPairs(token,tokens,foreground,softMinimum){
|
|
312
|
-
const pairs=[];
|
|
313
|
-
for(const ground of tokens.filter(other=>other.role==='surface'||other.role==='background'||other.role==='canvas')){
|
|
314
|
-
const groundColor=parseColor(ground.value);
|
|
315
|
-
if(!groundColor)continue;
|
|
316
|
-
const glyphRatio=round(contrastRatio(groundColor,foreground),2);
|
|
317
|
-
pairs.push({kind:'status-glyph',token:token.token,against:ground.token,ratio:glyphRatio,minimum:softMinimum,outcome:glyphRatio>=softMinimum?'pass':'fail'});
|
|
318
|
-
}
|
|
319
|
-
return pairs;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/** The pairs one token with a declared foreground puts under test: its text or soft pair and, for a soft tone, its glyph on each ground. */
|
|
323
|
-
function tokenContrastPairs(token,tokens,byName,textMinimum,softMinimum){
|
|
324
|
-
const soft=softToneOf(token)!==null;
|
|
325
|
-
const minimum=soft?softMinimum:textMinimum;
|
|
326
|
-
const background=parseColor(token.value),foreground=parseColor(token.foreground);
|
|
327
|
-
if(!background||!foreground)return [{kind:'text',token:token.token,background:token.value??null,foreground:token.foreground,minimum,outcome:'unparseable'}];
|
|
328
|
-
const ratio=round(contrastRatio(background,foreground),2);
|
|
329
|
-
const pairs=[{kind:soft?'soft':'text',token:token.token,foregroundToken:foregroundNameOf(tokens,byName,token,foreground),background:token.value,foreground:token.foreground,ratio,minimum,
|
|
330
|
-
outcome:ratio>=minimum?'pass':'fail',_bg:token.token,_fg:foreground}];
|
|
331
|
-
if(soft)pairs.push(...statusGlyphPairs(token,tokens,foreground,softMinimum));
|
|
332
|
-
return pairs;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/** The primary against a declared surface: a non-text indicator, or null when either token is missing. */
|
|
336
|
-
function primaryOnSurfacePair(tokens){
|
|
337
|
-
const primary=byRole(tokens,'primary'),surface=byRole(tokens,'surface');
|
|
338
|
-
if(!primary||!surface)return null;
|
|
339
|
-
const one=parseColor(primary.value),two=parseColor(surface.value);
|
|
340
|
-
if(!one||!two)return {kind:'non-text',token:primary.token,against:surface.token,minimum:NON_TEXT_MIN_CONTRAST,outcome:'unparseable'};
|
|
341
|
-
const ratio=round(contrastRatio(one,two),2);
|
|
342
|
-
return {kind:'non-text',token:primary.token,against:surface.token,ratio,minimum:NON_TEXT_MIN_CONTRAST,outcome:ratio>=NON_TEXT_MIN_CONTRAST?'pass':'fail',
|
|
343
|
-
_bg:surface.token,_fg:one};
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
const exceptionCovers=(exception,pair)=>Boolean(pair._fg&&exception.fgColor&&exception.background===pair._bg&&deltaEOk(exception.fgColor,pair._fg)<=TOKEN_TOLERANCE);
|
|
347
|
-
|
|
348
|
-
/** An exception pair no token declares as a fill is measured from its two tokens, so an accepted pair is never unmeasured. */
|
|
349
|
-
function addExceptionPairs(pairs,exceptions,byName,minimum){
|
|
350
|
-
for(const exception of exceptions){
|
|
351
|
-
if(!exception.fgColor||!exception.bgColor||pairs.some(pair=>exceptionCovers(exception,pair)))continue;
|
|
352
|
-
pairs.push({kind:'text',token:exception.background,foregroundToken:exception.foreground,declaredBy:'contrastExceptions',
|
|
353
|
-
background:byName.get(exception.background).value,foreground:byName.get(exception.foreground).value,ratio:exception.measured,minimum,
|
|
354
|
-
outcome:exception.measured>=minimum?'pass':'fail',_bg:exception.background,_fg:exception.fgColor});
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/** A valid exception turns the failing pair it covers into a pass; returns the pairs it lifted. */
|
|
359
|
-
function applyExceptions(pairs,exceptions){
|
|
360
|
-
const applied=[];
|
|
361
|
-
for(const pair of pairs){
|
|
362
|
-
const exception=exceptions.find(entry=>entry.status==='valid'&&exceptionCovers(entry,pair));
|
|
363
|
-
if(!exception)continue;
|
|
364
|
-
if(pair.outcome==='fail'){
|
|
365
|
-
pair.outcome='pass';
|
|
366
|
-
pair.belowFloor=true;
|
|
367
|
-
pair.exception={foreground:exception.foreground,background:exception.background,acceptedBy:exception.acceptedBy,ratio:exception.ratio,receipt:exception.receipt};
|
|
368
|
-
exception.status='applied';
|
|
369
|
-
applied.push(pair);
|
|
370
|
-
} else if(exception.status==='valid')exception.status='unneeded';
|
|
371
|
-
}
|
|
372
|
-
return applied;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
const contrastPairName=pair=>pair.declaredBy?pair.foregroundToken+' on '+pair.token:pair.token+(pair.against&&' on '+pair.against||'');
|
|
376
|
-
|
|
377
|
-
/** The failure sentences of a contrast check: pairs below their floor, then refused exceptions. */
|
|
378
|
-
function contrastFailures(pairs,bad,refused){
|
|
379
|
-
return [
|
|
380
|
-
...(bad.length?[`${bad.length} of ${pairs.length} declared colour pairs miss their contrast floor: ${bad.map(pair=>contrastPairName(pair)+' '+(pair.ratio??'(unparseable)')+':1 < '+pair.minimum+':1').join(', ')}.`]:[]),
|
|
381
|
-
...(refused.length?[`${refused.length} contrast exception${refused.length===1?' is':'s are'} refused: ${refused.map(entry=>(entry.foreground??'?')+' on '+(entry.background??'?')+' ('+entry.why+')').join('; ')}.`]:[])];
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/**
|
|
385
|
-
* 2. A colour pair the brand itself declares must be legible. Text pairs are held to `policy.minContrast`
|
|
386
|
-
* (WCAG AA, 4.5 by default); the primary against a declared surface is a non-text indicator and needs 3:1.
|
|
387
|
-
* A HeroUI status soft pair (a token with role `<tone>-soft` and its `foreground`) and that foreground as a
|
|
388
|
-
* bare glyph on each declared surface/background/canvas are held to `policy.softMinContrast` (3 by default),
|
|
389
|
-
* so a status tone is never darkened to reach 4.5 as a bare glyph.
|
|
390
|
-
* A pair below its floor passes only through `policy.contrastExceptions` - one owner-accepted pair, named by
|
|
391
|
-
* its exact tokens and bound to its measured ratio and the owner's receipt; every other pair still fails and
|
|
392
|
-
* a refused exception fails the check. An exception pair no token declares as a fill (muted text on a
|
|
393
|
-
* surface, link ink on the canvas) is measured from its two tokens, so an accepted pair is never unmeasured.
|
|
394
|
-
*/
|
|
395
|
-
function checkContrastAa({brand,brandDir=null}){
|
|
396
|
-
const id='contrast-aa';
|
|
397
|
-
const tokens=brandTokens(brand);
|
|
398
|
-
const minimum=Number.isFinite(brand?.color?.policy?.minContrast)?Number(brand.color.policy.minContrast):DEFAULT_MIN_CONTRAST;
|
|
399
|
-
const softMinimum=Number.isFinite(brand?.color?.policy?.softMinContrast)?Number(brand.color.policy.softMinContrast):SOFT_MIN_CONTRAST;
|
|
400
|
-
const byName=new Map(tokens.map(token=>[token.token,token]));
|
|
401
|
-
const pairs=[];
|
|
402
|
-
for(const token of tokens){
|
|
403
|
-
if(token.foreground===undefined||token.foreground===null)continue;
|
|
404
|
-
pairs.push(...tokenContrastPairs(token,tokens,byName,minimum,softMinimum));
|
|
405
|
-
}
|
|
406
|
-
const nonText=primaryOnSurfacePair(tokens);
|
|
407
|
-
if(nonText)pairs.push(nonText);
|
|
408
|
-
const exceptions=readContrastExceptions({brand,tokens,brandDir});
|
|
409
|
-
addExceptionPairs(pairs,exceptions,byName,minimum);
|
|
410
|
-
const applied=applyExceptions(pairs,exceptions);
|
|
411
|
-
for(const pair of pairs){delete pair._bg;delete pair._fg;}
|
|
412
|
-
const refused=exceptions.filter(exception=>exception.status==='refused');
|
|
413
|
-
const reported=exceptions.map(({fgColor,bgColor,...rest})=>rest);
|
|
414
|
-
if(!pairs.length&&!refused.length)return check(id,'skip','No brand token declares a foreground and no surface is declared, so no contrast pair exists to measure.',{minimum});
|
|
415
|
-
const bad=pairs.filter(pair=>pair.outcome!=='pass');
|
|
416
|
-
const evidence={minimum,nonTextMinimum:NON_TEXT_MIN_CONTRAST,softMinimum,exceptionTolerance:CONTRAST_EXCEPTION_TOLERANCE,pairs,
|
|
417
|
-
...(exceptions.length?{exceptions:reported}:{})};
|
|
418
|
-
const failures=contrastFailures(pairs,bad,refused);
|
|
419
|
-
const accepted=applied.length?` ${applied.length} of them below it by an owner-accepted exception: ${applied.map(pair=>pair.exception.foreground+' on '+pair.exception.background+' '+pair.ratio+':1 ('+pair.exception.acceptedBy+')').join(', ')}.`:'';
|
|
420
|
-
return failures.length
|
|
421
|
-
?check(id,'fail',failures.join(' '),evidence)
|
|
422
|
-
:check(id,'pass',`All ${pairs.length} declared colour pairs meet their contrast floor (text ${minimum}:1, non-text ${NON_TEXT_MIN_CONTRAST}:1, status soft pair and glyph ${softMinimum}:1).${accepted}`,evidence);
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/**
|
|
426
|
-
* 3. Destructive must not look like primary. The owner may overrule this - some brands are one hue - but
|
|
427
|
-
* then the affordance has to carry the meaning, and the check says so instead of going quiet.
|
|
428
|
-
*/
|
|
429
|
-
function checkPrimaryDangerDistinct({brand}){
|
|
430
|
-
const id='primary-danger-distinct';
|
|
431
|
-
const tokens=brandTokens(brand);
|
|
432
|
-
const primary=byRole(tokens,'primary'),danger=byRole(tokens,'danger');
|
|
433
|
-
const allowed=brand?.color?.policy?.dangerMayMatchPrimary===true;
|
|
434
|
-
if(!primary||!danger){let absent='danger';if(!primary&&!danger){absent='primary and no danger';}else if(!primary){absent='primary';}
|
|
435
|
-
return check(id,'skip',`The brand declares no ${absent} colour role, so the two cannot be compared.`,{threshold:MIN_PRIMARY_DANGER_DELTA,dangerMayMatchPrimary:allowed});}
|
|
436
|
-
const one=parseColor(primary.value),two=parseColor(danger.value);
|
|
437
|
-
const evidence={primary:{token:primary.token,value:primary.value??null},danger:{token:danger.token,value:danger.value??null},
|
|
438
|
-
threshold:MIN_PRIMARY_DANGER_DELTA,scale:'OKLab delta-E x100',dangerMayMatchPrimary:allowed};
|
|
439
|
-
if(!one||!two){return check(id,'fail','The primary or danger colour is not a colour this runtime can parse, so their distance is unknown.',evidence);}
|
|
440
|
-
const delta=round(deltaEOk(one,two),2);
|
|
441
|
-
const measured={...evidence,deltaE:delta};
|
|
442
|
-
if(delta>=MIN_PRIMARY_DANGER_DELTA)return check(id,'pass',`Primary and danger are ${delta} apart in OKLab (floor ${MIN_PRIMARY_DANGER_DELTA}).`,measured);
|
|
443
|
-
if(allowed)return check(id,'pass',`Primary and danger are only ${delta} apart in OKLab (floor ${MIN_PRIMARY_DANGER_DELTA}), but the owner allows danger to share the primary hue; destructive actions must carry an icon and a verb.`,
|
|
444
|
-
{...measured,note:'the owner allows danger to share the primary hue; destructive actions must carry an icon and a verb'});
|
|
445
|
-
return check(id,'fail',`Primary and danger are only ${delta} apart in OKLab (floor ${MIN_PRIMARY_DANGER_DELTA}), and the brand does not allow danger to share the primary hue.`,measured);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
/**
|
|
449
|
-
* 4. A mascot that is named but absent is a mascot nobody can draw with. Bytes are hashed: a declared
|
|
450
|
-
* sha256 is verified, an undeclared one is reported so the owner can pin the asset they reviewed.
|
|
451
|
-
*/
|
|
452
|
-
function checkMascotAssetsPresent({brand,tree,brandDir}){
|
|
453
|
-
const id='mascot-assets-present';
|
|
454
|
-
const assets=objectList(brand?.mascot?.assets).filter(asset=>typeof asset.path==='string'&&asset.path);
|
|
455
|
-
const declared=objectList(brand?.mascot?.assets);
|
|
456
|
-
if(!declared.length)return check(id,'skip','The brand declares no mascot asset.',{tree:slash(tree)});
|
|
457
|
-
const root=path.resolve(tree);
|
|
458
|
-
const findings=declared.map(asset=>{
|
|
459
|
-
const relative=slash(asset.path??'');
|
|
460
|
-
const entry={path:relative,purpose:asset.purpose??null,declaredSha256:asset.sha256??null};
|
|
461
|
-
if(!relative||path.isAbsolute(relative)||relative.split('/').includes('..'))return {...entry,exists:false,status:'escapes-tree'};
|
|
462
|
-
const workRoot=path.dirname(brandDir);
|
|
463
|
-
const candidates=[path.resolve(brandDir,relative),path.resolve(workRoot,relative),path.resolve(root,relative)];
|
|
464
|
-
const file=candidates.find(candidate=>fs.existsSync(candidate)&&fs.lstatSync(candidate).isFile());
|
|
465
|
-
if(!file)return {...entry,exists:false,status:'absent',searched:candidates.map(candidate=>slash(path.relative(root,candidate)))};
|
|
466
|
-
const resolved=slash(path.relative(root,file));
|
|
467
|
-
const extension=path.extname(file).toLowerCase();
|
|
468
|
-
const computed=digest(fs.readFileSync(file));
|
|
469
|
-
const base={...entry,exists:true,resolved,extension,computedSha256:computed,bytes:fs.statSync(file).size};
|
|
470
|
-
if(!ASSET_EXTENSIONS.includes(extension))return {...base,status:'unsupported-format'};
|
|
471
|
-
if(asset.sha256&&String(asset.sha256).toLowerCase()!==computed)return {...base,status:'sha256-mismatch'};
|
|
472
|
-
return {...base,status:asset.sha256?'verified':'present-unpinned'};
|
|
473
|
-
});
|
|
474
|
-
const bad=findings.filter(finding=>!['verified','present-unpinned'].includes(finding.status));
|
|
475
|
-
const evidence={tree:slash(root),allowedFormats:ASSET_EXTENSIONS,assets:findings};
|
|
476
|
-
if(bad.length)return check(id,'fail',`${bad.length} of ${findings.length} declared mascot assets are not usable: ${bad.map(finding=>finding.path+' ('+finding.status+')').join(', ')}.`,evidence);
|
|
477
|
-
const unpinned=findings.filter(finding=>finding.status==='present-unpinned').length;
|
|
478
|
-
return check(id,'pass',`All ${findings.length} declared mascot assets exist in a supported format${unpinned?'; '+unpinned+' carry no declared sha256, so the computed hash is reported instead':' and match their declared sha256'}.`,
|
|
479
|
-
{...evidence,unpinned,assetsChecked:assets.length});
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
/** Every module specifier this file imports, with the line it sits on. */
|
|
483
|
-
export function importSpecifiers(text){
|
|
484
|
-
const source=String(text??'');
|
|
485
|
-
const found=new Map();
|
|
486
|
-
const patterns=[/(?<![\w$.])(?:import|export)\b[\s\S]{0,400}?\bfrom\s*['"]([^'"\n]+)['"]/g,
|
|
487
|
-
/(?<![\w$.])(?:import|require)\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g,
|
|
488
|
-
/(?<![\w$.])import\s*['"]([^'"\n]+)['"]/g];
|
|
489
|
-
for(const pattern of patterns){
|
|
490
|
-
for(const match of source.matchAll(pattern)){
|
|
491
|
-
const at=match.index+match[0].lastIndexOf(match[1]);
|
|
492
|
-
const line=source.slice(0,at).split('\n').length;
|
|
493
|
-
found.set(`${line}:${match[1]}`,{specifier:match[1],line});
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
return [...found.values()].sort((first,second)=>first.line-second.line||first.specifier.localeCompare(second.specifier));
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
const covers=(entry,specifier)=>sameOrUnder(specifier,entry);
|
|
500
|
-
|
|
501
|
-
const isScannableSource=(entry,file)=>entry.isFile()&&/\.tsx?$/i.test(entry.name)&&!/\.d\.ts$/i.test(entry.name)&&fs.statSync(file).size<=SCAN_BYTES_LIMIT;
|
|
502
|
-
|
|
503
|
-
/** Collects the TypeScript sources under `directory` into `files`, up to SCAN_FILE_LIMIT, skipping dot and build directories. */
|
|
504
|
-
function collectSourceFiles(directory,files){
|
|
505
|
-
if(files.length>=SCAN_FILE_LIMIT)return;
|
|
506
|
-
let entries;
|
|
507
|
-
try{entries=fs.readdirSync(directory,{withFileTypes:true});}catch{return;}
|
|
508
|
-
for(const entry of entries){
|
|
509
|
-
if(files.length>=SCAN_FILE_LIMIT)return;
|
|
510
|
-
if(entry.isSymbolicLink())continue;
|
|
511
|
-
const file=path.join(directory,entry.name);
|
|
512
|
-
if(entry.isDirectory()){if(!SCAN_EXCLUDED.has(entry.name)&&!entry.name.startsWith('.')){collectSourceFiles(file,files);}continue;}
|
|
513
|
-
if(isScannableSource(entry,file))files.push(file);
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
/** The icon imports of `files` the brand does not allow: [{file, line, specifier, reason}]. */
|
|
518
|
-
function iconOffendersOf(files,root,allowed,forbidden){
|
|
519
|
-
const offenders=[];
|
|
520
|
-
for(const file of files){
|
|
521
|
-
let text;
|
|
522
|
-
try{text=fs.readFileSync(file,'utf8');}catch{continue;}
|
|
523
|
-
for(const {specifier,line} of importSpecifiers(text)){
|
|
524
|
-
if(specifier.startsWith('.')||specifier.startsWith('/')||specifier.startsWith('~'))continue;
|
|
525
|
-
const banned=forbidden.find(entry=>covers(entry,specifier)||specifier.includes(entry));
|
|
526
|
-
const permitted=allowed.some(entry=>covers(entry,specifier));
|
|
527
|
-
if(banned)offenders.push({file:slash(path.relative(root,file)),line,specifier,reason:`forbidden by the brand (${banned})`});
|
|
528
|
-
else if(!permitted&&ICON_HINT.test(specifier))offenders.push({file:slash(path.relative(root,file)),line,specifier,reason:'an icon package outside iconography.set and custom'});
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
return offenders;
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
/**
|
|
535
|
-
* 5. The glyph set is closed. A forbidden package is an offender wherever it appears; any other icon-looking
|
|
536
|
-
* package is an offender unless `iconography.set` or `custom` names it. Relative imports are the product's
|
|
537
|
-
* own files and are never judged here.
|
|
538
|
-
*/
|
|
539
|
-
function checkIconSetOnly({brand,sourceRoot}){
|
|
540
|
-
const id='icon-set-only';
|
|
541
|
-
const iconography=brand?.iconography??{};
|
|
542
|
-
const allowed=[...(Array.isArray(iconography.set)?iconography.set:[]),...(Array.isArray(iconography.custom)?iconography.custom:[])].filter(entry=>typeof entry==='string'&&entry);
|
|
543
|
-
const forbidden=(Array.isArray(iconography.forbidden)?iconography.forbidden:[]).filter(entry=>typeof entry==='string'&&entry);
|
|
544
|
-
if(!sourceRoot)return check(id,'skip','No --source repository root was given, so the frontend\'s icon imports were not scanned.',{allowed,forbidden});
|
|
545
|
-
if(!allowed.length&&!forbidden.length)return check(id,'skip','The brand declares no iconography set and nothing forbidden, so no icon law exists to enforce.',{sourceRoot:slash(sourceRoot)});
|
|
546
|
-
const root=path.resolve(sourceRoot);
|
|
547
|
-
const files=[];
|
|
548
|
-
collectSourceFiles(root,files);
|
|
549
|
-
const offenders=iconOffendersOf(files,root,allowed,forbidden);
|
|
550
|
-
const evidence={sourceRoot:slash(root),allowed,forbidden,filesScanned:files.length,
|
|
551
|
-
offenderCount:offenders.length,offenders:offenders.slice(0,OFFENDER_CAP),capped:offenders.length>OFFENDER_CAP};
|
|
552
|
-
if(!files.length)return check(id,'skip','No TypeScript source file was found under the given repository root, so no icon import was scanned.',evidence);
|
|
553
|
-
return offenders.length
|
|
554
|
-
?check(id,'fail',`${offenders.length} icon imports sit outside the brand's glyph set across ${files.length} scanned files.`,evidence)
|
|
555
|
-
:check(id,'pass',`Every icon import across ${files.length} scanned files comes from the brand's declared glyph set.`,evidence);
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/**
|
|
559
|
-
* 6. A brand overrides real tokens; it never invents names. A token name the grammar family's DNA does not
|
|
560
|
-
* declare would style nothing, so the record would read as applied while the product looked untouched.
|
|
561
|
-
*/
|
|
562
|
-
function checkTokensInGrammar({brand,family,grammarRoot}){
|
|
563
|
-
const id='tokens-in-grammar';
|
|
564
|
-
const tokens=brandTokens(brand);
|
|
565
|
-
const canon=grammarTokenNames({family,grammarRoot});
|
|
566
|
-
const evidence={family:family??null,dna:canon.file?slash(canon.file):null,declaredInDna:canon.names.length,tokens:tokens.length};
|
|
567
|
-
if(!tokens.length)return check(id,'skip','The brand declares no colour token to look up in the grammar canon.',evidence);
|
|
568
|
-
if(!canon.names.length)return check(id,'skip',`The grammar canon could not be read (${canon.error}), so no token name was verified against it.`,evidence);
|
|
569
|
-
const names=new Set(canon.names);
|
|
570
|
-
const missing=tokens.map(token=>token.token).filter(token=>!names.has(token));
|
|
571
|
-
return missing.length
|
|
572
|
-
?check(id,'fail',`${missing.length} of ${tokens.length} brand tokens are not token names the \`${family}\` grammar declares: ${missing.join(', ')}.`,{...evidence,missing})
|
|
573
|
-
:check(id,'pass',`All ${tokens.length} brand tokens are token names the \`${family}\` grammar declares.`,{...evidence,missing:[]});
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
// ---------------------------------------------------------------------------
|
|
577
|
-
// brand.direction: the composition taste, accepted by the owner one archetype at a time.
|
|
578
|
-
// ---------------------------------------------------------------------------
|
|
579
|
-
|
|
580
|
-
/** The page archetypes a direction settles (work/brand@1 $defs.direction.archetypes). */
|
|
581
|
-
export const DIRECTION_ARCHETYPES=Object.freeze(['dashboard','list','detail','form','wizard','empty']);
|
|
582
|
-
const DIRECTION_STATUSES=Object.freeze(['proposed','accepted']);
|
|
583
|
-
/** The owner ask that accepts a direction archetype (scripts/work/brand-direction.mjs question), and its receipt review. */
|
|
584
|
-
export const DIRECTION_REVIEW_KIND='brand-direction-review';
|
|
585
|
-
export const DIRECTION_REVIEW_SCHEMA='starci/brand-direction-review@1';
|
|
586
|
-
/** The ask's options: 0 accepts, 1 asks for a revision. */
|
|
587
|
-
export const DIRECTION_DECISIONS=Object.freeze(['accept','revise']);
|
|
588
|
-
const DIRECTION_ACCEPT_OPTION=0;
|
|
589
|
-
/** The review a draw-review receipt carries (scripts/work/draw-review.mjs DRAW_REVIEW_SCHEMA): a golden's owner accept. */
|
|
590
|
-
const DRAW_REVIEW_RECEIPT_SCHEMA='starci/draw-review@1';
|
|
591
|
-
const LEARNED_STATUSES=Object.freeze(['proposed','accepted']);
|
|
592
|
-
const ARCHETYPE_FIELDS=['regionOrder','grid1184','grid390','emphasis','primaryActionPlacement','never','whenNotToUse'];
|
|
593
|
-
|
|
594
|
-
/** The component names one grammar family's DNA renders (`renderers[].component`). */
|
|
595
|
-
export function grammarComponentNames({family,grammarRoot=defaultGrammarRoot()}){
|
|
596
|
-
if(!family)return {file:null,names:[],error:'the brand declares no identity.family'};
|
|
597
|
-
if(!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(family))return {file:null,names:[],error:'the declared family is not a usable directory name'};
|
|
598
|
-
const file=['DNA.yaml','DNA.yml','DNA.json'].map(name=>path.join(grammarRoot,family,name)).find(candidate=>fs.existsSync(candidate)&&fs.lstatSync(candidate).isFile());
|
|
599
|
-
if(!file)return {file:null,names:[],error:`the host carries no DNA snapshot for grammar family \`${family}\``};
|
|
600
|
-
try{
|
|
601
|
-
const text=readText(file);
|
|
602
|
-
const dna=path.extname(file).toLowerCase()==='.json'?JSON.parse(text):parseYaml(text);
|
|
603
|
-
const names=objectList(dna?.renderers).map(renderer=>renderer.component).filter(name=>typeof name==='string');
|
|
604
|
-
return {file,names,error:names.length?null:'the DNA snapshot declares no renderers'};
|
|
605
|
-
}catch(error){return {file,names:[],error:`unreadable DNA snapshot: ${String(error.message??error)}`};}
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
/** The accept of a golden by the owner's draw-review receipt: the very parts the golden holds, option accept. */
|
|
609
|
-
function judgeDrawReviewAcceptance(receipt,review,golden){
|
|
610
|
-
const seen=new Set(objectList(review.parts).map(entry=>entry?.sha256).filter(Boolean));
|
|
611
|
-
const unseen=golden.filter(entry=>!seen.has(entry.sha256));
|
|
612
|
-
if(!golden.length||unseen.length)return {ok:false,why:`golden ${unseen.map(entry=>entry.png).join(', ')||'(none)'} is not a drawing the owner accepted in ${receipt.file}`};
|
|
613
|
-
if(receipt.optionIndex!==DIRECTION_ACCEPT_OPTION)return {ok:false,why:`${receipt.file} chose option ${receipt.optionIndex}, not accept`};
|
|
614
|
-
return {ok:true,receipt:receipt.file};
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
/** Why a direction review receipt does not back the archetype (schema, archetype, golden bytes), or null. */
|
|
618
|
-
function directionReviewRefusal(receipt,review,archetype,golden){
|
|
619
|
-
if(review.schema!==DIRECTION_REVIEW_SCHEMA)return {ok:false,why:`${receipt.file} answers a ${review.schema??'non-direction'} review, not a ${DIRECTION_REVIEW_SCHEMA}`};
|
|
620
|
-
if(archetype&&review.archetype!==archetype)return {ok:false,why:`${receipt.file} reviews archetype ${review.archetype??'(none)'}, not ${archetype}`};
|
|
621
|
-
const seen=new Set(objectList(review.golden).map(entry=>entry.sha256));
|
|
622
|
-
const unseen=golden.filter(entry=>!seen.has(entry.sha256));
|
|
623
|
-
if(archetype&&unseen.length)return {ok:false,why:`golden ${unseen.map(entry=>entry.png).join(', ')} changed after the owner reviewed it`};
|
|
624
|
-
return null;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
/** Whether an acceptance block is the owner's answer to the direction rev it names: {ok, why?, receipt?}. */
|
|
628
|
-
function judgeAcceptance({acceptance,rev,brandDir,archetype=null,golden=[]}){
|
|
629
|
-
if(!acceptance||typeof acceptance!=='object')return {ok:false,why:'no acceptance names the owner answer'};
|
|
630
|
-
if(typeof acceptance.acceptedBy!=='string'||!acceptance.acceptedBy)return {ok:false,why:'acceptance.acceptedBy names no ask'};
|
|
631
|
-
if(acceptance.rev!==rev)return {ok:false,why:`accepted at direction rev ${acceptance.rev ?? '(none)'}, the direction is rev ${rev} - the owner has not seen this revision`};
|
|
632
|
-
const receipt=findOwnerReceipt({acceptedBy:acceptance.acceptedBy,receipt:acceptance.receipt??null,brandDir});
|
|
633
|
-
if(!receipt.ok)return {ok:false,why:receipt.why};
|
|
634
|
-
const review=receipt.review;
|
|
635
|
-
const reviewed=review&&typeof review==='object';
|
|
636
|
-
// An owner-accepted drawing promoted to the archetype's golden (draw-feedback.mjs, brand-direction.mjs promoteGolden):
|
|
637
|
-
// the owner's draw-review accept of the very parts the golden holds accepts the archetype.
|
|
638
|
-
if(archetype&&reviewed&&review.schema===DRAW_REVIEW_RECEIPT_SCHEMA)return judgeDrawReviewAcceptance(receipt,review,golden);
|
|
639
|
-
const refusal=reviewed?directionReviewRefusal(receipt,review,archetype,golden):null;
|
|
640
|
-
if(refusal)return refusal;
|
|
641
|
-
if(receipt.optionIndex!==null&&receipt.optionIndex!==DIRECTION_ACCEPT_OPTION)return {ok:false,why:`${receipt.file} chose option ${receipt.optionIndex}, not accept`};
|
|
642
|
-
return {ok:true,receipt:receipt.file};
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
/** Autopilot's provisional accept (scripts/kernel/autopilot-run.mjs; owner ruling 2026-09-28 autopilot-run-to-finish). */
|
|
646
|
-
const AUTOPILOT_ANSWERER='autopilot';
|
|
647
|
-
/**
|
|
648
|
-
* Whether an archetype's `provisional` block is backed by an autopilot receipt for the rev it names, with passing gate
|
|
649
|
-
* evidence, reviewing this archetype and the golden bytes on disk now: {ok, why?, receipt?}. Never an owner acceptance.
|
|
650
|
-
*/
|
|
651
|
-
function judgeProvisional({provisional,rev,brandDir,archetype,golden=[]}){
|
|
652
|
-
if(!provisional||typeof provisional!=='object')return {ok:false,why:'no provisional block'};
|
|
653
|
-
if(provisional.by!==AUTOPILOT_ANSWERER)return {ok:false,why:`provisional.by is ${provisional.by??'(none)'}, not autopilot`};
|
|
654
|
-
if(provisional.rev!==rev)return {ok:false,why:`provisional at direction rev ${provisional.rev??'(none)'}, the direction is rev ${rev}`};
|
|
655
|
-
if(typeof provisional.acceptedBy!=='string'||!provisional.acceptedBy)return {ok:false,why:'provisional.acceptedBy names no ask'};
|
|
656
|
-
const receipt=findOwnerReceipt({acceptedBy:provisional.acceptedBy,receipt:provisional.receipt??null,brandDir,answerer:AUTOPILOT_ANSWERER});
|
|
657
|
-
if(!receipt.ok)return {ok:false,why:receipt.why};
|
|
658
|
-
if(!receipt.provisional||!receipt.gatesOk)return {ok:false,why:`${receipt.file} is not a provisional autopilot answer with passing gates`};
|
|
659
|
-
if(receipt.optionIndex!==DIRECTION_ACCEPT_OPTION)return {ok:false,why:`${receipt.file} chose option ${receipt.optionIndex}, not accept`};
|
|
660
|
-
const review=receipt.review;
|
|
661
|
-
if(review?.schema!==DIRECTION_REVIEW_SCHEMA||review.archetype!==archetype)return {ok:false,why:`${receipt.file} does not review archetype ${archetype}`};
|
|
662
|
-
const seen=new Set(objectList(review.golden).map(entry=>entry.sha256));
|
|
663
|
-
const unseen=golden.filter(entry=>!seen.has(entry.sha256));
|
|
664
|
-
if(!golden.length||unseen.length)return {ok:false,why:`golden ${unseen.map(entry=>entry.png).join(', ')||'(none)'} changed after autopilot reviewed it`};
|
|
665
|
-
return {ok:true,receipt:receipt.file};
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
/** The shape problems of a direction record: rev, status and principles. */
|
|
669
|
-
function directionShapeProblems(direction,problems){
|
|
670
|
-
const rev=direction.rev;
|
|
671
|
-
if(!Number.isInteger(rev)||rev<1)problems.push('rev must be a positive integer');
|
|
672
|
-
if(!DIRECTION_STATUSES.includes(direction.status))problems.push(`status ${JSON.stringify(direction.status)} is not one of ${DIRECTION_STATUSES.join(', ')}`);
|
|
673
|
-
if(!objectList(direction.principles).length)problems.push('principles is empty');
|
|
674
|
-
for(const principle of objectList(direction.principles))if(!Array.isArray(principle.cites)||!principle.cites.length)problems.push(`principle ${principle.id??'?'} cites nothing`);
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
/** The file a golden entry's `png` or `html` names inside the brand directory, or null (the problem is pushed). */
|
|
678
|
-
function goldenFileOf(relative,key,brandDir,problems){
|
|
679
|
-
if(!relative||path.isAbsolute(relative)||relative.split('/').includes('..')){problems.push(`golden ${key} ${relative||'(none)'} is not a path inside the brand record's directory`);return null;}
|
|
680
|
-
const file=path.resolve(brandDir,relative);
|
|
681
|
-
if(fs.existsSync(file))return file;
|
|
682
|
-
problems.push(`golden ${key} ${relative} is not on disk`);
|
|
683
|
-
return null;
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
/** One path of a golden entry (`png` or `html`) checked on disk against its recorded digest; records its computed digest on `finding`. */
|
|
687
|
-
function goldenFileProblems(entry,key,brandDir,problems,finding){
|
|
688
|
-
const relative=slash(String(entry[key]??''));
|
|
689
|
-
const file=goldenFileOf(relative,key,brandDir,problems);
|
|
690
|
-
if(file===null)return;
|
|
691
|
-
const computed=digest(fs.readFileSync(file));
|
|
692
|
-
const declared=key==='png'?entry.sha256:entry.htmlSha256;
|
|
693
|
-
if(key==='png'&&!declared)problems.push(`golden ${relative} declares no sha256`);
|
|
694
|
-
if(declared&&String(declared).toLowerCase()!==computed)problems.push(`golden ${relative} no longer hashes to its recorded sha256`);
|
|
695
|
-
finding[`${key}Sha256`]=computed;
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
/** One golden render entry judged: its finding, with the problems it raises pushed in order. */
|
|
699
|
-
function goldenFindingOf(entry,archetypes,brandDir,problems){
|
|
700
|
-
const finding={archetype:entry.archetype??null,png:entry.png??null,html:entry.html??null};
|
|
701
|
-
for(const key of ['png','html'])goldenFileProblems(entry,key,brandDir,problems,finding);
|
|
702
|
-
if(!Object.hasOwn(archetypes,entry.archetype))problems.push(`golden ${entry.png??'?'} names archetype ${entry.archetype??'(none)'}, which the direction does not declare`);
|
|
703
|
-
return finding;
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
/** The shape problems of one archetype; returns its missing fields, or null when it is not an archetype to judge further. */
|
|
707
|
-
function archetypeShapeProblems(name,archetype,problems){
|
|
708
|
-
if(!DIRECTION_ARCHETYPES.includes(name)){problems.push(`archetype ${name} is not one of ${DIRECTION_ARCHETYPES.join(', ')}`);return null;}
|
|
709
|
-
if(!archetype||typeof archetype!=='object'){problems.push(`archetype ${name} is not an object`);return null;}
|
|
710
|
-
const missing=ARCHETYPE_FIELDS.filter(field=>archetype[field]===undefined||archetype[field]===null||archetype[field]===''||(Array.isArray(archetype[field])&&!archetype[field].length));
|
|
711
|
-
if(missing.length)problems.push(`archetype ${name} lacks ${missing.join(', ')}`);
|
|
712
|
-
if(!DIRECTION_STATUSES.includes(archetype.status))problems.push(`archetype ${name} status ${JSON.stringify(archetype.status)} is not one of ${DIRECTION_STATUSES.join(', ')}`);
|
|
713
|
-
return missing;
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
/** One archetype judged: its status into `ctx.summary`, `ctx.provisional` or `ctx.ready` when drawable, problems in order. */
|
|
717
|
-
function judgeArchetype(name,archetype,ctx){
|
|
718
|
-
const {problems,golden,rev,brandDir}=ctx;
|
|
719
|
-
const missing=archetypeShapeProblems(name,archetype,problems);
|
|
720
|
-
if(missing===null)return;
|
|
721
|
-
ctx.summary[name]=archetype.status??null;
|
|
722
|
-
const own=golden.filter(entry=>entry.archetype===name);
|
|
723
|
-
// Autopilot: a provisional block backed by its receipt makes the archetype drawable (evidence.provisional); a
|
|
724
|
-
// stale one is dropped silently - the next brand.decide direction review asks again.
|
|
725
|
-
if(archetype.status!=='accepted'){
|
|
726
|
-
if(archetype.provisional&&judgeProvisional({provisional:archetype.provisional,rev,brandDir,archetype:name,golden:own}).ok&&!missing.length)ctx.provisional.push(name);
|
|
727
|
-
return;
|
|
728
|
-
}
|
|
729
|
-
if(!own.length)problems.push(`archetype ${name} is accepted with no golden render the owner saw`);
|
|
730
|
-
const verdict=judgeAcceptance({acceptance:archetype.acceptance,rev,brandDir,archetype:name,golden:own});
|
|
731
|
-
if(!verdict.ok)problems.push(`archetype ${name} is accepted but ${verdict.why}`);
|
|
732
|
-
else if(!missing.length&&own.length)ctx.ready.push(name);
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
/** The rubric problems (checks, anchors, duplicate ids, missing group, test or cites); returns the rubric checks. */
|
|
736
|
-
function rubricProblems(direction,problems){
|
|
737
|
-
const checks=objectList(direction.rubric?.checks);
|
|
738
|
-
if(!checks.length)problems.push('rubric.checks is empty');
|
|
739
|
-
if(!objectList(direction.rubric?.beautyAnchors).length)problems.push('rubric.beautyAnchors is empty');
|
|
740
|
-
const seenChecks=new Set();
|
|
741
|
-
for(const entry of checks){
|
|
742
|
-
if(seenChecks.has(entry.id))problems.push(`rubric check ${entry.id} is declared twice`);
|
|
743
|
-
seenChecks.add(entry.id);
|
|
744
|
-
for(const field of ['group','test'])if(typeof entry[field]!=='string'||!entry[field].trim())problems.push(`rubric check ${entry.id??'?'} has no ${field}`);
|
|
745
|
-
if(!Array.isArray(entry.cites)||!entry.cites.length)problems.push(`rubric check ${entry.id??'?'} cites nothing`);
|
|
746
|
-
}
|
|
747
|
-
return checks;
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
/** Every vocabulary recipe mapped onto components the DNA renders; returns the recipes' grammar proposals. */
|
|
751
|
-
function vocabularyProposals(vocabulary,canon,family,problems){
|
|
752
|
-
const components=new Set(canon.names);
|
|
753
|
-
const unmapped=[];
|
|
754
|
-
const proposals=[];
|
|
755
|
-
for(const [name,recipe] of Object.entries(vocabulary)){
|
|
756
|
-
const dna=Array.isArray(recipe?.dna)?recipe.dna:[];
|
|
757
|
-
if(!dna.length)problems.push(`recipe ${name} maps onto no DNA component`);
|
|
758
|
-
if(canon.names.length)unmapped.push(...dna.filter(component=>!components.has(component)).map(component=>`${name}: ${component}`));
|
|
759
|
-
proposals.push(...objectList(recipe?.proposals).map(proposal=>({recipe:name,component:proposal.component??null,variant:proposal.variant??null,status:proposal.status??null})));
|
|
760
|
-
}
|
|
761
|
-
if(unmapped.length)problems.push(`recipes name components the \`${family}\` DNA does not render (record a grammar proposal instead): ${unmapped.join(', ')}`);
|
|
762
|
-
return proposals;
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
/** The components an archetype names must be components the DNA renders (when the DNA could be read). */
|
|
766
|
-
function archetypeComponentProblems(archetypes,canon,family,problems){
|
|
767
|
-
const components=new Set(canon.names);
|
|
768
|
-
for(const archetypeName of Object.keys(archetypes))for(const component of Array.isArray(archetypes[archetypeName]?.components)?archetypes[archetypeName].components:[])
|
|
769
|
-
if(canon.names.length&&!components.has(component))problems.push(`archetype ${archetypeName} names ${component}, which the \`${family}\` DNA does not render`);
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
/**
|
|
773
|
-
* Rulings learned from the owner's draw feedback (scripts/work/draw-feedback.mjs): proposed until the owner accepts
|
|
774
|
-
* the direction; an accepted one is backed by that owner answer. Returns {proposed, accepted} ids.
|
|
775
|
-
*/
|
|
776
|
-
function learnedRulings(direction,brandDir,problems){
|
|
777
|
-
const learned={proposed:[],accepted:[]};
|
|
778
|
-
const learnedIds=new Set();
|
|
779
|
-
for(const entry of objectList(direction.learned)){
|
|
780
|
-
if(!entry||typeof entry!=='object'||!entry.id){problems.push('a learned ruling has no id');continue;}
|
|
781
|
-
if(learnedIds.has(entry.id))problems.push(`learned ruling ${entry.id} is declared twice`);
|
|
782
|
-
learnedIds.add(entry.id);
|
|
783
|
-
if(!LEARNED_STATUSES.includes(entry.status)){problems.push(`learned ruling ${entry.id} status ${JSON.stringify(entry.status)} is not one of ${LEARNED_STATUSES.join(', ')}`);continue;}
|
|
784
|
-
if(entry.status==='accepted'){
|
|
785
|
-
const verdict=judgeAcceptance({acceptance:entry.acceptance,rev:entry.acceptance?.rev,brandDir});
|
|
786
|
-
if(!verdict.ok)problems.push(`learned ruling ${entry.id} is accepted but ${verdict.why}`);
|
|
787
|
-
}
|
|
788
|
-
learned[entry.status].push(entry.id);
|
|
789
|
-
}
|
|
790
|
-
return learned;
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
const directionArchetypesOf=direction=>direction.archetypes&&typeof direction.archetypes==='object'&&!Array.isArray(direction.archetypes)?direction.archetypes:{};
|
|
794
|
-
const directionVocabularyOf=direction=>direction.vocabulary&&typeof direction.vocabulary==='object'?direction.vocabulary:{};
|
|
795
|
-
|
|
796
|
-
/** An accepted direction is backed by the owner's answer; archetypes may be ready only under an accepted direction. */
|
|
797
|
-
function directionAcceptanceProblems(direction,ready,brandDir,problems){
|
|
798
|
-
if(direction.status!=='accepted'){
|
|
799
|
-
if(ready.length)problems.push(`archetypes ${ready.join(', ')} are accepted while the direction itself is ${direction.status}`);
|
|
800
|
-
return;
|
|
801
|
-
}
|
|
802
|
-
const verdict=judgeAcceptance({acceptance:direction.acceptance,rev:direction.rev,brandDir});
|
|
803
|
-
if(!verdict.ok)problems.push(`the direction is accepted but ${verdict.why}`);
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
/**
|
|
807
|
-
* 7. `brand.direction`, when the record carries one: its shape, every vocabulary recipe mapped onto components the
|
|
808
|
-
* grammar DNA renders (a missing one is a pending grammar proposal, never an invented element), unique rubric
|
|
809
|
-
* checks, golden renders on disk with the digest the owner saw, and every `accepted` archetype - and an accepted
|
|
810
|
-
* direction - backed by the owner's own starci/ask-answer@1 receipt for the rev it names. An auto-recommended
|
|
811
|
-
* answer never accepts a direction. A record with no direction passes: interface.draw then treats every archetype
|
|
812
|
-
* as not ready.
|
|
813
|
-
*/
|
|
814
|
-
export function checkDirection({brand,family,grammarRoot,brandDir}){
|
|
815
|
-
const id='direction';
|
|
816
|
-
const direction=brand?.direction;
|
|
817
|
-
if(direction===undefined||direction===null)return check(id,'pass','The brand declares no direction yet; no archetype is ready to draw from.',{archetypes:{},ready:[]});
|
|
818
|
-
const problems=[];
|
|
819
|
-
if(typeof direction!=='object'||Array.isArray(direction))return check(id,'fail','brand.direction is not an object.',{problems:['not an object']});
|
|
820
|
-
const rev=direction.rev;
|
|
821
|
-
directionShapeProblems(direction,problems);
|
|
822
|
-
const archetypes=directionArchetypesOf(direction);
|
|
823
|
-
if(!Object.keys(archetypes).length)problems.push('archetypes is empty');
|
|
824
|
-
const golden=objectList(direction.golden);
|
|
825
|
-
const goldenFindings=golden.map(entry=>goldenFindingOf(entry,archetypes,brandDir,problems));
|
|
826
|
-
const ctx={problems,golden,rev,brandDir,summary:{},ready:[],provisional:[]};
|
|
827
|
-
for(const [name,archetype] of Object.entries(archetypes))judgeArchetype(name,archetype,ctx);
|
|
828
|
-
const {summary,ready,provisional}=ctx;
|
|
829
|
-
directionAcceptanceProblems(direction,ready,brandDir,problems);
|
|
830
|
-
const checks=rubricProblems(direction,problems);
|
|
831
|
-
const canon=grammarComponentNames({family,grammarRoot});
|
|
832
|
-
const proposals=vocabularyProposals(directionVocabularyOf(direction),canon,family,problems);
|
|
833
|
-
archetypeComponentProblems(archetypes,canon,family,problems);
|
|
834
|
-
const pending=objectList(direction.pendingRulings).filter(entry=>entry.status!=='ruled').map(entry=>entry.id??'?');
|
|
835
|
-
const learned=learnedRulings(direction,brandDir,problems);
|
|
836
|
-
const evidence={rev:rev??null,status:direction.status??null,archetypes:summary,ready,provisional,golden:goldenFindings,dna:canon.file?slash(canon.file):null,
|
|
837
|
-
dnaNote:canon.error,proposals,pendingRulings:pending,rubricChecks:checks.length,learned};
|
|
838
|
-
if(problems.length)return check(id,'fail',`brand.direction rev ${rev??'?'} has ${problems.length} problem(s): ${problems.slice(0,OFFENDER_CAP).join('; ')}.`,{...evidence,problems});
|
|
839
|
-
const dnaNote=canon.names.length?'every recipe maps onto a DNA component':`the DNA could not be read (${canon.error}), so recipes were not mapped`;
|
|
840
|
-
return check(id,'pass',`brand.direction rev ${rev} (${direction.status}): ${Object.keys(summary).length} archetype(s), ready ${ready.length?ready.join(', '):'none'}; ${dnaNote}${pending.length?'; open owner rulings '+pending.join(', '):''}.`,{...evidence,problems:[]});
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
// ---------------------------------------------------------------------------
|
|
844
|
-
// The run.
|
|
845
|
-
// ---------------------------------------------------------------------------
|
|
846
|
-
|
|
847
|
-
/**
|
|
848
|
-
* Runs every check against one Work tree. `sourceRoot` is the frontend repository root the brand's
|
|
849
|
-
* `sources[]` paths are relative to; without it the two source-reading checks skip and say so. The result
|
|
850
|
-
* is `ok` only when no check failed - a skipped check never makes a brand proven.
|
|
851
|
-
*/
|
|
852
|
-
function runBrandChecks({tree,sourceRoot=null,grammarRoot=defaultGrammarRoot(),stage='decide'}={}){
|
|
853
|
-
if(!tree)throw new Error('runBrandChecks needs a Work tree.');
|
|
854
|
-
const phase=brandStage(stage);
|
|
855
|
-
const record=readBrandRecord(tree);
|
|
856
|
-
const context={brand:record.brand,tree:path.resolve(tree),brandDir:record.dir,
|
|
857
|
-
sourceRoot:sourceRoot?path.resolve(sourceRoot):null,family:record.family,grammarRoot,stage:phase};
|
|
858
|
-
const checks=[checkTokensMatchSource(context),checkContrastAa(context),checkPrimaryDangerDistinct(context),
|
|
859
|
-
checkMascotAssetsPresent(context),checkIconSetOnly(context),checkTokensInGrammar(context),checkDirection(context)];
|
|
860
|
-
return {schema:BRAND_CHECKS,ok:checks.every(result=>result.outcome!=='fail'),stage:phase,checks,
|
|
861
|
-
brand:{rev:record.rev,family:record.family,revSource:record.revSource,record:slash(path.relative(path.resolve(tree),record.file))}};
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
/** One line per check, for a person reading a terminal. */
|
|
865
|
-
function formatBrandChecks(result){
|
|
866
|
-
const header=`brand ${result.brand.family??'(no family)'} rev ${result.brand.rev}${result.stage?' ('+result.stage+' stage)':''}: ${result.ok?'no failing check':'failing checks'}`;
|
|
867
|
-
return formatCheckLines(header,result.checks);
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
/**
|
|
871
|
-
* `starci work brand <work-root> [--source <repository-root>] [--stage decide|verify] [--json]`.
|
|
872
|
-
* Exit 0 when no check failed, 1 when one did or the input is broken (no brand record, an unknown stage).
|
|
873
|
-
*/
|
|
874
|
-
function brandMain(argv=[]){
|
|
875
|
-
const usage='Usage: starci work brand <work-root> [--source <repository-root>] [--stage decide|verify] [--json]\n';
|
|
876
|
-
if(argv.includes('--help'))return {exitCode:0,text:usage};
|
|
877
|
-
const valueOf=flag=>{const at=argv.indexOf(flag);return at>=0?argv[at+1]:undefined;};
|
|
878
|
-
const flagged=new Set(['--source','--stage','--grammar-root'].flatMap(flag=>{const at=argv.indexOf(flag);return at>=0?[at,at+1]:[];}));
|
|
879
|
-
const tree=argv.find((arg,index)=>!flagged.has(index)&&!arg.startsWith('--'));
|
|
880
|
-
if(!tree)return {exitCode:1,text:usage};
|
|
881
|
-
try{
|
|
882
|
-
const result=runBrandChecks({tree,sourceRoot:valueOf('--source')??null,stage:valueOf('--stage'),
|
|
883
|
-
...(valueOf('--grammar-root')?{grammarRoot:path.resolve(valueOf('--grammar-root'))}:{})});
|
|
884
|
-
return {exitCode:result.ok?0:1,text:argv.includes('--json')?`${JSON.stringify(result,null,2)}\n`:`${formatBrandChecks(result)}\n`};
|
|
885
|
-
}catch(error){return {exitCode:1,text:`brand check: ${String(error?.message??error)}\n`};}
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
if(isMain(import.meta.url)){
|
|
889
|
-
const {exitCode,text}=brandMain(process.argv.slice(2));
|
|
890
|
-
(exitCode?process.stderr:process.stdout).write(text);
|
|
891
|
-
process.exitCode=exitCode;
|
|
892
|
-
}
|