@nurix/etna 0.20.14 → 0.20.16
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/data/catalog/rules/frontend.md +7 -3
- package/data/harness-index.json +1 -1
- package/data/harnesses/code/CLAUDE.md +8 -2
- package/data/harnesses/code/agents/bookkeeper.md +1 -0
- package/data/harnesses/code/agents/polygraph-finder.md +4 -0
- package/data/harnesses/code/harness.json +2 -2
- package/data/harnesses/code/hooks/awareness.mjs +15 -2
- package/data/harnesses/code/hooks/lib/state.mjs +1 -0
- package/data/harnesses/code/hooks/lib/sweep.mjs +20 -2
- package/data/harnesses/code/rules/frontend.md +7 -3
- package/data/harnesses/code/skills/design/SKILL.md +2 -2
- package/data/harnesses/code/skills/design/references/compose.md +18 -0
- package/data/harnesses/code/skills/design/references/emission.md +34 -8
- package/data/harnesses/code/skills/design/references/existing-apps.md +2 -2
- package/data/harnesses/code/skills/design/references/foundations.md +3 -3
- package/data/harnesses/code/skills/design/references/intake.md +5 -4
- package/data/harnesses/code/skills/design/references/ship-gate.md +9 -3
- package/data/harnesses/code/skills/design/scripts/validate-design-md.mjs +293 -14
- package/data/harnesses/code/skills/documentation/SKILL.md +28 -0
- package/data/harnesses/code/skills/documentation/scripts/comment-strip.mjs +339 -0
- package/data/harnesses/code/skills/documentation/scripts/doc-lint.mjs +41 -10
- package/data/harnesses/code/skills/documentation/scripts/source-lint.mjs +225 -0
- package/data/harnesses/code/skills/documentation/templates/behavioral-checklist.md +1 -2
- package/data/harnesses/code/skills/documentation/templates/bookkeeping.md +1 -1
- package/data/harnesses/code/skills/polygraph/SKILL.md +1 -1
- package/data/harnesses/code/skills/testing/SKILL.md +1 -1
- package/data/harnesses/delivery/harness.json +2 -2
- package/data/harnesses/delivery/skills/documentation/SKILL.md +28 -0
- package/data/harnesses/delivery/skills/documentation/scripts/comment-strip.mjs +339 -0
- package/data/harnesses/delivery/skills/documentation/scripts/doc-lint.mjs +41 -10
- package/data/harnesses/delivery/skills/documentation/scripts/source-lint.mjs +225 -0
- package/data/harnesses/delivery/skills/documentation/templates/behavioral-checklist.md +1 -2
- package/data/harnesses/delivery/skills/documentation/templates/bookkeeping.md +1 -1
- package/data/harnesses/etna/harness.json +2 -2
- package/data/harnesses/etna/skills/documentation/SKILL.md +28 -0
- package/data/harnesses/etna/skills/documentation/scripts/comment-strip.mjs +339 -0
- package/data/harnesses/etna/skills/documentation/scripts/doc-lint.mjs +41 -10
- package/data/harnesses/etna/skills/documentation/scripts/source-lint.mjs +225 -0
- package/data/harnesses/etna/skills/documentation/templates/behavioral-checklist.md +1 -2
- package/data/harnesses/etna/skills/documentation/templates/bookkeeping.md +1 -1
- package/package.json +2 -2
|
@@ -6,19 +6,37 @@
|
|
|
6
6
|
// block) and its own oklch → sRGB → WCAG-contrast math. FAIL closes; WARN never does.
|
|
7
7
|
//
|
|
8
8
|
// CLI:
|
|
9
|
-
// node validate-design-md.mjs <file|folder> [--mode admission|build|ship] [--json]
|
|
9
|
+
// node validate-design-md.mjs (<file|folder> | --project [dir]) [--mode admission|build|ship] [--json]
|
|
10
|
+
// [--emit] [--emit-personality] [--write] [--lib <folder>]
|
|
10
11
|
//
|
|
11
12
|
// <file> validate one design.md.
|
|
13
|
+
// --project [dir]
|
|
14
|
+
// validate the project's own fork, located by the repo's shape and never by search: a
|
|
15
|
+
// multi-package workspace (pnpm-workspace.yaml, or package.json `workspaces`) keeps it at
|
|
16
|
+
// packages/common/design.md, a single-package repo at src/common/design.md. dir defaults
|
|
17
|
+
// to the cwd. Fails closed when the file is absent, naming the expected path and any copy
|
|
18
|
+
// found at the other conventional spot or at the repo base.
|
|
19
|
+
// --write with --emit / --emit-personality: write the artifact beside the design.md
|
|
20
|
+
// (design.tokens.css / personality.css) instead of printing it.
|
|
12
21
|
// <folder> validate every .md in the folder tree (skipping SCHEMA.md) plus index.csv integrity (dmv-07)
|
|
13
22
|
// and the tree-wide naming scan (dmv-09).
|
|
14
23
|
// --mode admission and build run the same check set; ship additionally runs dmv-11 (drift:
|
|
15
|
-
// re-emit and byte-compare against a design.tokens.css sitting beside the input file)
|
|
24
|
+
// re-emit and byte-compare against a design.tokens.css sitting beside the input file) and
|
|
25
|
+
// dmv-12's drift half (the same for personality.css when a personality block is declared).
|
|
16
26
|
// Default: build.
|
|
17
27
|
// --json machine report to stdout: {file, fails:[{id,detail}], warns:[{id,detail}]}
|
|
18
28
|
// (an array of those objects in folder mode; folder-level checks attach to the folder path).
|
|
19
29
|
// --emit compile the token artifact from the design-values block to stdout (byte-stable across
|
|
20
30
|
// runs; redirect to design.tokens.css). Requires a single file; validation runs first and
|
|
21
31
|
// any FAIL suppresses the artifact (report goes to stderr). Warns go to stderr.
|
|
32
|
+
// --emit-personality
|
|
33
|
+
// compile the personality artifact to stdout (redirect to personality.css): the design.md's
|
|
34
|
+
// `personality:` traits handed to @nurix/components' own compiler (`compileToCss` from
|
|
35
|
+
// `@nurix/components/lib/personality`, primitives: nurix only). Same gating as --emit.
|
|
36
|
+
// --lib <folder>
|
|
37
|
+
// where @nurix/components is installed, when the resolver cannot tell: it walks up from the
|
|
38
|
+
// design.md, then one and two levels into the workspace (packages/<app>); a pnpm root hoists
|
|
39
|
+
// nothing, and several installed versions is an ambiguity only the app package resolves.
|
|
22
40
|
//
|
|
23
41
|
// Exit codes: 0 pass (warns allowed), 1 any FAIL.
|
|
24
42
|
//
|
|
@@ -27,7 +45,8 @@
|
|
|
27
45
|
// schema: design-md/v2; required keys per tier; id matches filename.
|
|
28
46
|
// dmv-02 the 12 numbered sections present, in order, non-empty; one trailing `yaml design-values` block.
|
|
29
47
|
// dmv-03 the design-values block parses as YAML.
|
|
30
|
-
// dmv-04 coverage: all 36 color roles × both themes (values parseable)
|
|
48
|
+
// dmv-04 coverage: all 36 color roles × both themes (values parseable), plus the optional `info` role
|
|
49
|
+
// declared in both themes or neither; type ramp steps
|
|
31
50
|
// display/h1/h2/body/small/caption/data each with size+line+weight+family; register-scope;
|
|
32
51
|
// elevation (radius, hairline, shadow, dark-ladder); motion (duration, easing, entrance-never,
|
|
33
52
|
// identity-moments); iconography (set, stroke, grid); charts (categorical, sequential,
|
|
@@ -42,6 +61,15 @@
|
|
|
42
61
|
// dmv-10 (tier: project only) primitives present, exactly nurix|native; source-package present.
|
|
43
62
|
// dmv-11 (--mode ship) drift: the token artifact recompiled from the design.md byte-matches the
|
|
44
63
|
// design.tokens.css beside it.
|
|
64
|
+
// dmv-12 the personality block (primitives: nurix): a mapping over the six closed-vocabulary traits
|
|
65
|
+
// density|pace|edge|depth|texture|tone (pace a number 0..1); absent under native. In --mode
|
|
66
|
+
// ship, personality.css beside the design.md byte-matches the recompiled artifact, and no
|
|
67
|
+
// personality.css exists without a block (a hand-authored layer).
|
|
68
|
+
//
|
|
69
|
+
// WARN checks (never close): dmw-01 anti-cream backgrounds · dmw-02 neutral tint · dmw-03 untinted shadows ·
|
|
70
|
+
// dmw-04 motion budgets · dmw-05 personality block absent on a nurix project (the library's neutral
|
|
71
|
+
// traits ship) · dmw-06 `info` role undeclared on a nurix project (--tone-info falls back to the
|
|
72
|
+
// library's own hue).
|
|
45
73
|
//
|
|
46
74
|
// dmv-05 — the exact contrast pair table (both themes; WCAG 2.1 relative-luminance contrast; colors
|
|
47
75
|
// resolved oklch → OKLab → linear sRGB, gamut-clipped to [0,1], gamma-encoded, then the WCAG formula;
|
|
@@ -77,8 +105,8 @@
|
|
|
77
105
|
// background (checked only where chroma ≥ 0.01 — hue is noise near gray).
|
|
78
106
|
// dmw-07 accent chroma delta between themes > 0.06 (identity likely not held across themes).
|
|
79
107
|
//
|
|
80
|
-
// Emitted artifact shape (deterministic, byte-stable): a `:root
|
|
81
|
-
// `:root
|
|
108
|
+
// Emitted artifact shape (deterministic, byte-stable): a `:root:not(.dark)` block and a
|
|
109
|
+
// `:root.dark` block (specificity armor — both selectors reach 0,2,0 via the attribute
|
|
82
110
|
// form, so a component library's later bare `:root` cannot silently defeat a theme). The light block
|
|
83
111
|
// carries the 36 roles as --<role> plus every theme-invariant group: --font-display/sans/mono (registered
|
|
84
112
|
// names quoted, declared fallback stacks appended), --text-<step>-size/-line/-weight (+ -family/-tracking/
|
|
@@ -86,9 +114,9 @@
|
|
|
86
114
|
// (--space-base, --sidebar-width[-collapsed], --topbar-height, --side-panel-width[-wide],
|
|
87
115
|
// --content-max-width; unmapped keys emit as --layout-<key>). The dark block carries the 36 dark roles.
|
|
88
116
|
|
|
89
|
-
import { readFileSync, existsSync, statSync, readdirSync, realpathSync } from "node:fs";
|
|
117
|
+
import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, realpathSync } from "node:fs";
|
|
90
118
|
import { join, resolve, basename, dirname, relative, sep } from "node:path";
|
|
91
|
-
import { fileURLToPath } from "node:url";
|
|
119
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
92
120
|
import process from "node:process";
|
|
93
121
|
|
|
94
122
|
// ---------------------------------------------------------------------------
|
|
@@ -114,6 +142,20 @@ const SECTIONS = [
|
|
|
114
142
|
|
|
115
143
|
const RAMP_STEPS = ["display", "h1", "h2", "body", "small", "caption", "data"];
|
|
116
144
|
const FONT_SLOTS = ["display", "sans", "mono"];
|
|
145
|
+
// Optional color roles: emitted only when declared (both themes or neither). `info` completes the
|
|
146
|
+
// success/warning/info trio @nurix/components' status dots read through --tone-*.
|
|
147
|
+
const OPTIONAL_COLOR_ROLES = ["info"];
|
|
148
|
+
// The personality block — six closed-vocabulary traits @nurix/components compiles into the token
|
|
149
|
+
// families its components read (lib/personality). The library owns the vocabulary; this mirrors it.
|
|
150
|
+
const PERSONALITY_ORDER = ["density", "pace", "edge", "depth", "texture", "tone"];
|
|
151
|
+
const PERSONALITY_TRAITS = {
|
|
152
|
+
density: ["compact", "regular", "airy"],
|
|
153
|
+
pace: null, // the one scalar: 0 still · 0.5 the library's neutral timing · 1 lively
|
|
154
|
+
edge: ["sharp", "regular", "soft"],
|
|
155
|
+
depth: ["flat", "layered", "elevated"],
|
|
156
|
+
texture: ["clean", "grainy"],
|
|
157
|
+
tone: ["neutral", "expressive"],
|
|
158
|
+
};
|
|
117
159
|
const THEMES = ["light", "dark"];
|
|
118
160
|
const TIERS = ["default", "variant", "brand", "project"];
|
|
119
161
|
const FRONT_KEYS_BASE = ["schema", "id", "tier", "segments", "keywords", "sourced", "sources", "license"];
|
|
@@ -695,6 +737,48 @@ function checkCoverage(values, report) {
|
|
|
695
737
|
else if (layout.base == null) missing("layout.base");
|
|
696
738
|
}
|
|
697
739
|
|
|
740
|
+
// dmv-12 — the personality block, and the two nurix-project warns (dmw-05 block absent, dmw-06 info absent).
|
|
741
|
+
function checkPersonality(front, values, report) {
|
|
742
|
+
const isNurixProject = !!front && front.tier === "project" && front.primitives === "nurix";
|
|
743
|
+
const p = values.personality;
|
|
744
|
+
if (p === undefined) {
|
|
745
|
+
if (isNurixProject) {
|
|
746
|
+
warn(report, "dmw-05", "personality block absent — @nurix/components ships its neutral traits (regular · pace 0.5 · regular · layered · grainy · neutral); declare the traits §4–§7 imply so density, motion, radius and depth reach the components");
|
|
747
|
+
}
|
|
748
|
+
} else if (!isObj(p)) {
|
|
749
|
+
fail(report, "dmv-12", "personality must be a mapping of traits");
|
|
750
|
+
} else {
|
|
751
|
+
if (!(front && front.primitives === "nurix")) {
|
|
752
|
+
fail(report, "dmv-12", "personality is compiled by @nurix/components — under primitives: native nothing reads it; drop the block or switch primitives");
|
|
753
|
+
}
|
|
754
|
+
for (const [k, v] of Object.entries(p)) {
|
|
755
|
+
if (!(k in PERSONALITY_TRAITS)) {
|
|
756
|
+
fail(report, "dmv-12", `personality.${k} is not a trait (${PERSONALITY_ORDER.join("|")})`);
|
|
757
|
+
} else if (k === "pace") {
|
|
758
|
+
const n = typeof v === "number" ? v : Number(v);
|
|
759
|
+
if (typeof v === "boolean" || !Number.isFinite(n) || n < 0 || n > 1) {
|
|
760
|
+
fail(report, "dmv-12", `personality.pace must be a number 0..1 (0 still · 0.5 neutral · 1 lively), got "${v}"`);
|
|
761
|
+
}
|
|
762
|
+
} else if (!PERSONALITY_TRAITS[k].includes(v)) {
|
|
763
|
+
fail(report, "dmv-12", `personality.${k} must be one of ${PERSONALITY_TRAITS[k].join("|")}, got "${v}"`);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const color = isObj(values.color) ? values.color : {};
|
|
769
|
+
for (const role of OPTIONAL_COLOR_ROLES) {
|
|
770
|
+
const declared = THEMES.map((t) => isObj(color[t]) && color[t][role] != null);
|
|
771
|
+
if (declared[0] !== declared[1]) {
|
|
772
|
+
fail(report, "dmv-04", `color.${role} is declared in one theme only — a role is declared in both themes or neither`);
|
|
773
|
+
} else if (declared[0]) {
|
|
774
|
+
const bad = THEMES.filter((t) => !parseColor(color[t][role]));
|
|
775
|
+
if (bad.length) fail(report, "dmv-04", `color.${role} unparseable in ${bad.join(", ")}`);
|
|
776
|
+
} else if (isNurixProject) {
|
|
777
|
+
warn(report, "dmw-06", `color.${role} undeclared — @nurix/components' --tone-${role} falls back to the library's own hue beside your success/warning; declare it in both themes`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
698
782
|
// dmv-05 — contrast on every declared pairing, both themes (pair table in the header comment).
|
|
699
783
|
function checkContrast(values, report) {
|
|
700
784
|
const color = isObj(values.color) ? values.color : {};
|
|
@@ -891,14 +975,35 @@ function req(values, path) {
|
|
|
891
975
|
|
|
892
976
|
const cssValue = (v) => String(v);
|
|
893
977
|
|
|
978
|
+
// The project fork's one path, decided by the repo's shape and never by search: a multi-package
|
|
979
|
+
// workspace (pnpm-workspace.yaml, or package.json `workspaces`) keeps it at packages/common/design.md;
|
|
980
|
+
// a single-package repo at src/common/design.md. `path` is null when absent; `misplaced` lists copies
|
|
981
|
+
// at the other conventional spot or the repo base, so the caller can name what to move.
|
|
982
|
+
export function resolveProjectDesignMd(root) {
|
|
983
|
+
const abs = resolve(root);
|
|
984
|
+
let hasWorkspaces = existsSync(join(abs, "pnpm-workspace.yaml"));
|
|
985
|
+
if (!hasWorkspaces && existsSync(join(abs, "package.json"))) {
|
|
986
|
+
try { hasWorkspaces = JSON.parse(readFileSync(join(abs, "package.json"), "utf8")).workspaces != null; } catch { /* an unreadable manifest reads as single-package */ }
|
|
987
|
+
}
|
|
988
|
+
const shape = hasWorkspaces ? "multi-package" : "single-package";
|
|
989
|
+
const expected = join(abs, hasWorkspaces ? "packages/common/design.md" : "src/common/design.md");
|
|
990
|
+
const other = join(abs, hasWorkspaces ? "src/common/design.md" : "packages/common/design.md");
|
|
991
|
+
const misplaced = [join(abs, "design.md"), other].filter((f) => existsSync(f));
|
|
992
|
+
return { path: existsSync(expected) ? expected : null, expected, shape, misplaced };
|
|
993
|
+
}
|
|
994
|
+
|
|
894
995
|
export function emitTokens(front, values) {
|
|
895
996
|
const id = front && typeof front.id === "string" ? front.id : "unknown";
|
|
896
997
|
const out = [];
|
|
897
998
|
out.push(`/* design.tokens.css — compiled from ${id} (design-md/v2) by validate-design-md.mjs — derived artifact: edit the design.md, never this file */`);
|
|
898
999
|
out.push("");
|
|
899
|
-
out.push(':root
|
|
1000
|
+
out.push(':root:not(.dark) {');
|
|
900
1001
|
out.push(" /* color roles */");
|
|
901
1002
|
for (const role of COLOR_ROLES) out.push(` --${role}: ${cssValue(req(values, ["color", "light", role]))};`);
|
|
1003
|
+
for (const role of OPTIONAL_COLOR_ROLES) {
|
|
1004
|
+
const v = values.color.light[role];
|
|
1005
|
+
if (v != null) out.push(` --${role}: ${cssValue(v)};`);
|
|
1006
|
+
}
|
|
902
1007
|
out.push("");
|
|
903
1008
|
out.push(" /* typography */");
|
|
904
1009
|
for (const slot of FONT_SLOTS) {
|
|
@@ -939,13 +1044,135 @@ export function emitTokens(front, values) {
|
|
|
939
1044
|
}
|
|
940
1045
|
out.push("}");
|
|
941
1046
|
out.push("");
|
|
942
|
-
out.push(':root
|
|
1047
|
+
out.push(':root.dark {');
|
|
943
1048
|
out.push(" /* color roles */");
|
|
944
1049
|
for (const role of COLOR_ROLES) out.push(` --${role}: ${cssValue(req(values, ["color", "dark", role]))};`);
|
|
1050
|
+
for (const role of OPTIONAL_COLOR_ROLES) {
|
|
1051
|
+
const v = values.color.dark[role];
|
|
1052
|
+
if (v != null) out.push(` --${role}: ${cssValue(v)};`);
|
|
1053
|
+
}
|
|
945
1054
|
out.push("}");
|
|
946
1055
|
return out.join("\n") + "\n";
|
|
947
1056
|
}
|
|
948
1057
|
|
|
1058
|
+
// The personality spec exactly as the library's compiler takes it: version 1 plus the declared traits,
|
|
1059
|
+
// in trait order, pace numeric. Null when the design.md declares no block.
|
|
1060
|
+
export function personalitySpec(values) {
|
|
1061
|
+
const p = values && isObj(values.personality) ? values.personality : null;
|
|
1062
|
+
if (!p) return null;
|
|
1063
|
+
const spec = { version: 1 };
|
|
1064
|
+
for (const k of PERSONALITY_ORDER) {
|
|
1065
|
+
if (p[k] === undefined) continue;
|
|
1066
|
+
spec[k] = k === "pace" ? Number(p[k]) : p[k];
|
|
1067
|
+
}
|
|
1068
|
+
return spec;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// Every @nurix/components install a design.md can see: the upward walk from `fromDir` (a plain install,
|
|
1072
|
+
// or an app package inside a workspace), then one and two levels down into the workspace (packages/<app>)
|
|
1073
|
+
// — a pnpm root hoists nothing, so a repo-base design.md sees the library only through its packages.
|
|
1074
|
+
// Deduped by real path; distinct versions surface as an ambiguity for --lib.
|
|
1075
|
+
function findLibraryInstalls(fromDir) {
|
|
1076
|
+
const found = new Map();
|
|
1077
|
+
const consider = (dir) => {
|
|
1078
|
+
const pkgPath = join(dir, "package.json");
|
|
1079
|
+
if (!existsSync(pkgPath)) return;
|
|
1080
|
+
let real;
|
|
1081
|
+
try { real = realpathSync(dir); } catch { return; }
|
|
1082
|
+
if (found.has(real)) return;
|
|
1083
|
+
found.set(real, { dir: real, pkg: JSON.parse(readFileSync(pkgPath, "utf8")) });
|
|
1084
|
+
};
|
|
1085
|
+
const roots = []; // every package/workspace root passed on the way up, nearest first
|
|
1086
|
+
let dir = fromDir;
|
|
1087
|
+
for (;;) {
|
|
1088
|
+
consider(join(dir, "node_modules", "@nurix", "components"));
|
|
1089
|
+
if (found.size) return [...found.values()];
|
|
1090
|
+
if (existsSync(join(dir, "package.json")) || existsSync(join(dir, "pnpm-workspace.yaml"))) roots.push(dir);
|
|
1091
|
+
const parent = dirname(dir);
|
|
1092
|
+
if (parent === dir) break;
|
|
1093
|
+
dir = parent;
|
|
1094
|
+
}
|
|
1095
|
+
const children = (base) => {
|
|
1096
|
+
try { return readdirSync(base).filter((n) => !n.startsWith(".") && n !== "node_modules" && statSync(join(base, n)).isDirectory()); }
|
|
1097
|
+
catch { return []; }
|
|
1098
|
+
};
|
|
1099
|
+
for (const root of roots) {
|
|
1100
|
+
for (const a of children(root)) {
|
|
1101
|
+
consider(join(root, a, "node_modules", "@nurix", "components"));
|
|
1102
|
+
for (const b of children(join(root, a))) consider(join(root, a, b, "node_modules", "@nurix", "components"));
|
|
1103
|
+
}
|
|
1104
|
+
if (found.size) break;
|
|
1105
|
+
}
|
|
1106
|
+
return [...found.values()];
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// Resolve @nurix/components' personality compiler from the project that owns the design.md — the
|
|
1110
|
+
// library owns the spec and the compiler; this script only carries the traits to it. Follows the
|
|
1111
|
+
// package's ./lib/personality export; returns the module and the library version it came from.
|
|
1112
|
+
async function loadPersonalityCompiler(fromDir, libDir) {
|
|
1113
|
+
const installs = findLibraryInstalls(libDir || fromDir);
|
|
1114
|
+
if (!installs.length) {
|
|
1115
|
+
throw new Error(`@nurix/components is not installed above or below ${libDir || fromDir} — the personality artifact needs the library's compiler (primitives: nurix); pass --lib <app package folder> if it lives elsewhere`);
|
|
1116
|
+
}
|
|
1117
|
+
const versions = [...new Set(installs.map((i) => i.pkg.version))];
|
|
1118
|
+
if (versions.length > 1) {
|
|
1119
|
+
throw new Error(`several @nurix/components versions are installed under ${libDir || fromDir} (${versions.join(", ")}) — pass --lib <app package folder> to name the one the app builds with`);
|
|
1120
|
+
}
|
|
1121
|
+
const { dir, pkg } = installs[0];
|
|
1122
|
+
const entry = pkg.exports && pkg.exports["./lib/personality"];
|
|
1123
|
+
const rel = typeof entry === "string" ? entry : entry && (entry.import || entry.default);
|
|
1124
|
+
if (!rel) throw new Error(`@nurix/components at ${dir} exports no ./lib/personality`);
|
|
1125
|
+
const mod = await import(pathToFileURL(join(dir, rel)).href);
|
|
1126
|
+
if (typeof mod.compileToCss !== "function") throw new Error("@nurix/components/lib/personality exports no compileToCss");
|
|
1127
|
+
return { mod, version: pkg.version };
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// The personality artifact: the library's compileToCss over the design.md's traits, behind one header
|
|
1131
|
+
// line naming the source. Null when no block is declared; throws when the library rejects the spec or
|
|
1132
|
+
// cannot be resolved.
|
|
1133
|
+
export async function compilePersonalityArtifact(filePath, front, values, opts = {}) {
|
|
1134
|
+
const spec = personalitySpec(values);
|
|
1135
|
+
if (!spec) return null;
|
|
1136
|
+
const { mod: lib, version } = await loadPersonalityCompiler(dirname(filePath), opts.lib);
|
|
1137
|
+
if (typeof lib.validatePersonality === "function") {
|
|
1138
|
+
const verdict = lib.validatePersonality(spec);
|
|
1139
|
+
if (verdict && verdict.ok === false) {
|
|
1140
|
+
const issues = (verdict.issues || []).map((i) => `${i.path} ${i.message}`).join("; ");
|
|
1141
|
+
throw new Error(`personality rejected by @nurix/components: ${issues}`);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
const id = front && typeof front.id === "string" ? front.id : "unknown";
|
|
1145
|
+
return `/* personality.css — compiled from ${id} (design-md/v2) by @nurix/components@${version} compileToCss via validate-design-md.mjs --emit-personality — derived artifact: edit the design.md, never this file */\n` + lib.compileToCss(spec);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// dmv-12's drift half (--mode ship, single file): personality.css beside the design.md byte-matches the
|
|
1149
|
+
// recompiled artifact; an artifact with no block is a hand-authored layer and fails too.
|
|
1150
|
+
async function checkPersonalityDrift(filePath, report, opts = {}) {
|
|
1151
|
+
const sibling = join(dirname(filePath), "personality.css");
|
|
1152
|
+
const values = report._values;
|
|
1153
|
+
const declared = !!(values && isObj(values.personality));
|
|
1154
|
+
if (!declared) {
|
|
1155
|
+
if (existsSync(sibling)) fail(report, "dmv-12", "personality.css sits beside the design.md but no personality block is declared — the artifact is derived from the design.md, never authored");
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
let fresh;
|
|
1159
|
+
try {
|
|
1160
|
+
fresh = await compilePersonalityArtifact(filePath, report._front, values, opts);
|
|
1161
|
+
} catch (e) {
|
|
1162
|
+
fail(report, "dmv-12", `cannot recompile personality.css: ${e.message}`);
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
if (!existsSync(sibling)) {
|
|
1166
|
+
fail(report, "dmv-12", `personality.css not found beside ${basename(filePath)} — emit it with --emit-personality and commit it`);
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
const disk = readFileSync(sibling);
|
|
1170
|
+
const want = Buffer.from(fresh, "utf8");
|
|
1171
|
+
if (!disk.equals(want)) {
|
|
1172
|
+
fail(report, "dmv-12", `drift: personality.css does not byte-match the recompiled artifact (first divergence at byte ${firstDiff(disk, want)})`);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
949
1176
|
function firstDiff(a, b) {
|
|
950
1177
|
const n = Math.min(a.length, b.length);
|
|
951
1178
|
for (let i = 0; i < n; i++) if (a[i] !== b[i]) return i;
|
|
@@ -984,6 +1211,7 @@ export function validateFile(filePath, mode = "build", opts = {}) {
|
|
|
984
1211
|
}
|
|
985
1212
|
if (front) checkFrontmatter(front, filePath, report);
|
|
986
1213
|
}
|
|
1214
|
+
report._front = front;
|
|
987
1215
|
|
|
988
1216
|
const body = fm ? fm.body : raw;
|
|
989
1217
|
const { valueBlock } = checkSections(body, report);
|
|
@@ -998,8 +1226,10 @@ export function validateFile(filePath, mode = "build", opts = {}) {
|
|
|
998
1226
|
}
|
|
999
1227
|
}
|
|
1000
1228
|
|
|
1229
|
+
report._values = values;
|
|
1001
1230
|
if (values) {
|
|
1002
1231
|
checkCoverage(values, report);
|
|
1232
|
+
checkPersonality(front, values, report);
|
|
1003
1233
|
checkContrast(values, report);
|
|
1004
1234
|
checkFonts(values, report);
|
|
1005
1235
|
runWarnChecks(values, report);
|
|
@@ -1128,7 +1358,7 @@ export function validateFolder(root, mode = "build") {
|
|
|
1128
1358
|
// ---------------------------------------------------------------------------
|
|
1129
1359
|
|
|
1130
1360
|
function usage(stream) {
|
|
1131
|
-
stream.write("usage: node validate-design-md.mjs <file|folder> [--mode admission|build|ship] [--json] [--emit]\n");
|
|
1361
|
+
stream.write("usage: node validate-design-md.mjs (<file|folder> | --project [dir]) [--mode admission|build|ship] [--json] [--emit] [--emit-personality] [--write] [--lib <folder>]\n");
|
|
1132
1362
|
}
|
|
1133
1363
|
|
|
1134
1364
|
function printHuman(reports, stream) {
|
|
@@ -1147,22 +1377,41 @@ function printHuman(reports, stream) {
|
|
|
1147
1377
|
|
|
1148
1378
|
const toJsonReport = (r) => ({ file: r.file, fails: r.fails, warns: r.warns });
|
|
1149
1379
|
|
|
1150
|
-
function main() {
|
|
1380
|
+
async function main() {
|
|
1151
1381
|
const args = process.argv.slice(2);
|
|
1152
1382
|
let target = null;
|
|
1153
1383
|
let mode = "build";
|
|
1154
1384
|
let json = false;
|
|
1155
1385
|
let emit = false;
|
|
1386
|
+
let emitPersonality = false;
|
|
1387
|
+
let write = false;
|
|
1388
|
+
let lib = null;
|
|
1389
|
+
let project = null;
|
|
1156
1390
|
for (let i = 0; i < args.length; i++) {
|
|
1157
1391
|
const a = args[i];
|
|
1158
1392
|
if (a === "--mode") {
|
|
1159
1393
|
mode = args[++i];
|
|
1160
1394
|
} else if (a === "--json") json = true;
|
|
1161
1395
|
else if (a === "--emit") emit = true;
|
|
1396
|
+
else if (a === "--emit-personality") emitPersonality = true;
|
|
1397
|
+
else if (a === "--write") write = true;
|
|
1398
|
+
else if (a === "--lib") lib = resolve(args[++i] || "");
|
|
1399
|
+
else if (a === "--project") project = args[i + 1] && !args[i + 1].startsWith("--") ? args[++i] : ".";
|
|
1162
1400
|
else if (a === "--help" || a === "-h") { usage(process.stdout); process.exit(0); }
|
|
1163
1401
|
else if (!a.startsWith("--") && target === null) target = a;
|
|
1164
1402
|
else { process.stderr.write(`unknown argument: ${a}\n`); usage(process.stderr); process.exit(1); }
|
|
1165
1403
|
}
|
|
1404
|
+
if (project !== null && target !== null) { process.stderr.write("--project resolves the design.md itself — drop the positional path\n"); process.exit(1); }
|
|
1405
|
+
if (project !== null) {
|
|
1406
|
+
const found = resolveProjectDesignMd(project);
|
|
1407
|
+
if (!found.path) {
|
|
1408
|
+
process.stderr.write(`no project design.md at ${found.expected} (${found.shape} repo: ${found.shape === "multi-package" ? "packages/common/" : "src/common/"} holds the design files)\n`);
|
|
1409
|
+
for (const stray of found.misplaced) process.stderr.write(` found ${stray} — move it to ${found.expected}\n`);
|
|
1410
|
+
process.exit(1);
|
|
1411
|
+
}
|
|
1412
|
+
target = found.path;
|
|
1413
|
+
}
|
|
1414
|
+
if (write && !emit && !emitPersonality) { process.stderr.write("--write needs --emit or --emit-personality\n"); process.exit(1); }
|
|
1166
1415
|
if (!target) { usage(process.stderr); process.exit(1); }
|
|
1167
1416
|
if (!["admission", "build", "ship"].includes(mode)) {
|
|
1168
1417
|
process.stderr.write(`--mode must be admission|build|ship, got "${mode}"\n`);
|
|
@@ -1171,17 +1420,45 @@ function main() {
|
|
|
1171
1420
|
const abs = resolve(target);
|
|
1172
1421
|
if (!existsSync(abs)) { process.stderr.write(`no such file or folder: ${abs}\n`); process.exit(1); }
|
|
1173
1422
|
const isDir = statSync(abs).isDirectory();
|
|
1174
|
-
if (emit && isDir) { process.stderr.write("--emit
|
|
1423
|
+
if ((emit || emitPersonality) && isDir) { process.stderr.write("--emit / --emit-personality require a single design.md file\n"); process.exit(1); }
|
|
1175
1424
|
|
|
1176
1425
|
const reports = isDir ? validateFolder(abs, mode) : [validateFile(abs, mode, { checkOwnName: true })];
|
|
1426
|
+
if (mode === "ship" && !isDir) await checkPersonalityDrift(abs, reports[0], { lib });
|
|
1177
1427
|
const anyFail = reports.some((r) => r.fails.length > 0);
|
|
1178
1428
|
|
|
1429
|
+
if (emitPersonality) {
|
|
1430
|
+
if (anyFail) { printHuman(reports, process.stderr); process.exit(1); }
|
|
1431
|
+
let css = null;
|
|
1432
|
+
try {
|
|
1433
|
+
css = await compilePersonalityArtifact(abs, reports[0]._front, reports[0]._values, { lib });
|
|
1434
|
+
} catch (e) {
|
|
1435
|
+
process.stderr.write(`${e.message}\n`);
|
|
1436
|
+
process.exit(1);
|
|
1437
|
+
}
|
|
1438
|
+
if (css == null) { process.stderr.write("no personality block in the design-values block — nothing to emit\n"); process.exit(1); }
|
|
1439
|
+
if (write) {
|
|
1440
|
+
const out = join(dirname(abs), "personality.css");
|
|
1441
|
+
writeFileSync(out, css);
|
|
1442
|
+
process.stdout.write(`wrote ${out}\n`);
|
|
1443
|
+
} else {
|
|
1444
|
+
process.stdout.write(css);
|
|
1445
|
+
}
|
|
1446
|
+
if (reports[0].warns.length) printHuman(reports, process.stderr);
|
|
1447
|
+
process.exit(0);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1179
1450
|
if (emit) {
|
|
1180
1451
|
if (anyFail || !reports[0]._artifact) {
|
|
1181
1452
|
printHuman(reports, process.stderr);
|
|
1182
1453
|
process.exit(1);
|
|
1183
1454
|
}
|
|
1184
|
-
|
|
1455
|
+
if (write) {
|
|
1456
|
+
const out = join(dirname(abs), "design.tokens.css");
|
|
1457
|
+
writeFileSync(out, reports[0]._artifact);
|
|
1458
|
+
process.stdout.write(`wrote ${out}\n`);
|
|
1459
|
+
} else {
|
|
1460
|
+
process.stdout.write(reports[0]._artifact);
|
|
1461
|
+
}
|
|
1185
1462
|
if (reports[0].warns.length) printHuman(reports, process.stderr);
|
|
1186
1463
|
process.exit(0);
|
|
1187
1464
|
}
|
|
@@ -1197,4 +1474,6 @@ function main() {
|
|
|
1197
1474
|
|
|
1198
1475
|
// realpath both sides: argv[1] keeps the invoking symlink (e.g. a ~/.claude/skills link),
|
|
1199
1476
|
// while import.meta.url is the resolved file — a plain path compare silently skips main().
|
|
1200
|
-
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)))
|
|
1477
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
1478
|
+
main().catch((e) => { process.stderr.write(`${e.message}\n`); process.exit(1); });
|
|
1479
|
+
}
|
|
@@ -11,6 +11,26 @@ Document contracts and architecture, not implementation details. Show function s
|
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
14
|
+
## Where a file's documentation lives
|
|
15
|
+
|
|
16
|
+
**In `docs/`, never in the file's comments** — and this skill is the half of that rule that makes it safe. A source file carries a repo-relative path header of two or three present-tense sentences and one third-person sentence above each non-obvious function; the shape is the harness's `CLAUDE.md` § File Structure, which is canonical and is not restated here. What lands **here** is everything that shape displaces: design rationale, the alternatives weighed, the measurement behind a constant, the reason an obvious-looking change is wrong.
|
|
17
|
+
|
|
18
|
+
**Cutting a comment is a move, not a deletion.** A claim taken out of a source file is owed one of two things before the cut is done, and the choice is per claim, not per file:
|
|
19
|
+
|
|
20
|
+
| The claim is | What it earns |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| Worth keeping, and its doc does not state it | A section in the **owning feature doc** (the ownership index resolves which), carrying the claim and the argument for why the codebase is worse without it — never the comment's wording, which was written for a reader holding the code |
|
|
23
|
+
| Already stated in its doc | Nothing — but you **open that doc and find the sentence** before saying so. An assumed duplicate is how a claim is lost |
|
|
24
|
+
| History, a ticket or decision-record reference, a dated note, a divider, or narration of code that no longer exists | Nothing, and no record. A record lives in `docs/history/`; these are not claims about the present |
|
|
25
|
+
|
|
26
|
+
**Count what you cut, and prove the cut was only comments.** A comment sweep states comment lines before and after per file, so a drop nobody accounted for is visible as a number rather than as a feeling. [`scripts/comment-strip.mjs`](scripts/comment-strip.mjs) turns that from a claim into a check: it strips comments from both revisions — honouring strings, template and character literals, regex literals, raw strings and JSX comment braces — and diffs what is left, so a sweep that quietly changed executable code cannot pass as a cleanup. `comment-strip.mjs --rev <before> --to <after> <paths…>` exits 0 for "no executable code changed" and 1 naming the file and the first diverging line. Run it over any pass that touches more than a handful of files; a sweep without it is an assertion.
|
|
27
|
+
|
|
28
|
+
**The mechanical half of the shape is checked, not remembered.** [`scripts/source-lint.mjs`](scripts/source-lint.mjs) `--changed` reports a header that names another path or none, a change whose added lines are over a quarter comments, and the four shapes the convention deletes outright — narration, ALL-CAPS emphasis, an over-long header, a comment block that has become a document. Block-severity findings must be fixed; the escape hatch is a `source-lint-disable: <id>` comment with the reason in a second comment beside it, and the only finding that legitimately takes one is a booby trap. The harness detector raises a bookkeeping row for the same condition, so an unfixed breach outlives the session that wrote it.
|
|
29
|
+
|
|
30
|
+
**Never convert a booby trap into a one-liner.** A comment whose subject is a danger on an edge that looks safe — a pinned dependency's limitation, a measurement that justifies a narrow allowlist, a two-clock interaction — is load-bearing where it sits, because the reader who needs it is mid-edit and will not go looking. Compress it if it rambles; do not relocate it. The tell is that the comment names a version, a measurement, or a condition under which it expires.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
14
34
|
## Present tense, always
|
|
15
35
|
|
|
16
36
|
**A document states what is — never how it got there, and never how far along it is.** Write as if the current design had always been the plan. This is the most-violated rule in the skill, so it sits at the top; it applies to every document written or edited through this skill, whatever the template.
|
|
@@ -24,6 +44,12 @@ Document contracts and architecture, not implementation details. Show function s
|
|
|
24
44
|
|
|
25
45
|
The journey — dates, migrations, progress, supersessions — lives only in records (`docs/history/`); when reality changes, a truth document simply becomes correct. The full strip-list and the record-side rules: [`references/style-guide.md`](references/style-guide.md) § The Two Natures — Tense & History.
|
|
26
46
|
|
|
47
|
+
**A date in a truth document is a defect, and the list of exceptions is closed.** Not a softened preference: a truth document carries no dated line at all, because a date answers *when did this happen*, which is a record's question, and a reader who needs the answer is reading the wrong file. The four exceptions, and there are no others: the frontmatter's tooling keys (`last-reviewed:`, `last-updated:`, `rev:` — written by tooling, never by you), an Architecture Decision's **Decision Changelog** tail, `docs/structure.md`'s **Health Log** tail, and a dated path inside a link or code span, which is a *citation* of a record rather than a claim about the present.
|
|
48
|
+
|
|
49
|
+
**Cite a record by what it is, never by when it was.** `the dev reset runbook` links to the file; "the 2026-09-13 reset" states a date the reader must now keep in their head and that goes stale as soon as a second reset happens. The link carries the date in its path, where it belongs, and the prose stays true forever.
|
|
50
|
+
|
|
51
|
+
**A verification stamp is a record, not a status line.** "Driven 2026-09-13 · PASS" in a behavioural checklist is the clearest instance of this defect: it reads as documentation and is actually a dated event. The section states what must be true and which suite test guards it; *that* somebody drove it on a Tuesday belongs to the changelog entry of the unit that drove it, and staleness is then visible from something that re-checks itself — a pin that runs in CI — rather than from a date nobody re-stamped. A section with no automated guard says so in its trace, which is a stronger signal than an old date, because it cannot silently become wrong.
|
|
52
|
+
|
|
27
53
|
The mechanical subset of these rules is checked by [`scripts/doc-lint.mjs`](scripts/doc-lint.mjs) — run it over changed docs before finishing. Block-severity findings must be fixed; `<!-- doc-lint-disable: <id> -->` with a reason beside it is the escape hatch. Two invocation traps: the disable comment takes **bare rule ids only** — prose inside the comment silently breaks the parse, so put the reason in a second comment beside it — and the script grades truth-vs-record scope by matching `docs/(history|research|memory)/` against **the path string it is given**, so always hand it a repo-root-relative path; linting a record from inside its own folder grades it as a truth document.
|
|
28
54
|
|
|
29
55
|
---
|
|
@@ -155,3 +181,5 @@ update, and the `maintenance` agent's baseline pass, route there.
|
|
|
155
181
|
| `references/untemplated.md` | How to write a document no template covers (nature, placement, shape) + the co-authoring workflow |
|
|
156
182
|
| `references/examples.md` | Good vs bad documentation examples |
|
|
157
183
|
| `scripts/doc-lint.mjs` | Mechanical checks over written docs (tense narration, dates, stubs, redirects) — run over changed docs before finishing |
|
|
184
|
+
| `scripts/source-lint.mjs` | Mechanical checks over source **comments** (header path identity, the added-line comment ratio, narration, shouting) — run `--changed` before finishing any change that writes source |
|
|
185
|
+
| `scripts/comment-strip.mjs` | The proof a comment sweep changed no executable code: strips comments from two revisions and diffs what is left. Exit 0 is the claim; exit 1 names the file and the first diverging line |
|