@nurix/etna 0.18.19 → 0.18.20
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/harness-index.json +1 -1
- package/data/harnesses/code/harness.json +2 -2
- package/data/harnesses/code/skills/design/SKILL.md +65 -0
- package/data/harnesses/code/skills/design/data/catalog/rules.csv +132 -0
- package/data/harnesses/code/skills/design/data/design-md/SCHEMA.md +317 -0
- package/data/harnesses/code/skills/design/data/design-md/library/airbnb.md +235 -0
- package/data/harnesses/code/skills/design/data/design-md/library/airtable.md +232 -0
- package/data/harnesses/code/skills/design/data/design-md/library/anthropic-claude.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/apple.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/binance.md +223 -0
- package/data/harnesses/code/skills/design/data/design-md/library/bmw.md +219 -0
- package/data/harnesses/code/skills/design/data/design-md/library/cal.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/clay.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/clickhouse.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/cohere.md +227 -0
- package/data/harnesses/code/skills/design/data/design-md/library/coinbase.md +237 -0
- package/data/harnesses/code/skills/design/data/design-md/library/composio.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/cursor.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/elevenlabs.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/expo.md +228 -0
- package/data/harnesses/code/skills/design/data/design-md/library/figma.md +221 -0
- package/data/harnesses/code/skills/design/data/design-md/library/framer.md +236 -0
- package/data/harnesses/code/skills/design/data/design-md/library/hashicorp.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/ibm.md +234 -0
- package/data/harnesses/code/skills/design/data/design-md/library/intercom.md +240 -0
- package/data/harnesses/code/skills/design/data/design-md/library/kraken.md +236 -0
- package/data/harnesses/code/skills/design/data/design-md/library/linear-app.md +237 -0
- package/data/harnesses/code/skills/design/data/design-md/library/lovable.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/meta.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/minimax.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/mintlify.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/miro.md +233 -0
- package/data/harnesses/code/skills/design/data/design-md/library/mistral-ai.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/mongodb.md +236 -0
- package/data/harnesses/code/skills/design/data/design-md/library/notion.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/nvidia.md +222 -0
- package/data/harnesses/code/skills/design/data/design-md/library/ollama.md +226 -0
- package/data/harnesses/code/skills/design/data/design-md/library/opencode-ai.md +223 -0
- package/data/harnesses/code/skills/design/data/design-md/library/posthog.md +234 -0
- package/data/harnesses/code/skills/design/data/design-md/library/raycast.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/replicate.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/resend.md +285 -0
- package/data/harnesses/code/skills/design/data/design-md/library/revolut.md +225 -0
- package/data/harnesses/code/skills/design/data/design-md/library/runwayml.md +219 -0
- package/data/harnesses/code/skills/design/data/design-md/library/sanity.md +251 -0
- package/data/harnesses/code/skills/design/data/design-md/library/sentry.md +244 -0
- package/data/harnesses/code/skills/design/data/design-md/library/shopify.md +227 -0
- package/data/harnesses/code/skills/design/data/design-md/library/slack.md +240 -0
- package/data/harnesses/code/skills/design/data/design-md/library/spacex.md +252 -0
- package/data/harnesses/code/skills/design/data/design-md/library/spotify.md +248 -0
- package/data/harnesses/code/skills/design/data/design-md/library/stripe.md +241 -0
- package/data/harnesses/code/skills/design/data/design-md/library/supabase.md +255 -0
- package/data/harnesses/code/skills/design/data/design-md/library/superhuman.md +226 -0
- package/data/harnesses/code/skills/design/data/design-md/library/tesla.md +233 -0
- package/data/harnesses/code/skills/design/data/design-md/library/theverge.md +248 -0
- package/data/harnesses/code/skills/design/data/design-md/library/together-ai.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/uber.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/vercel.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/voltagent.md +245 -0
- package/data/harnesses/code/skills/design/data/design-md/library/warp.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/webflow.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/wired.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/wise.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/x-ai.md +220 -0
- package/data/harnesses/code/skills/design/data/design-md/library/zapier.md +238 -0
- package/data/harnesses/code/skills/design/data/design-md/nurix.md +217 -0
- package/data/harnesses/code/skills/design/data/operator-pack/exemplars/command-center.tsx +249 -0
- package/data/harnesses/code/skills/design/data/operator-pack/exemplars/create-edit.tsx +249 -0
- package/data/harnesses/code/skills/design/data/operator-pack/exemplars/dashboard.tsx +248 -0
- package/data/harnesses/code/skills/design/data/operator-pack/exemplars/entity-detail.tsx +245 -0
- package/data/harnesses/code/skills/design/data/operator-pack/exemplars/list-detail.tsx +223 -0
- package/data/harnesses/code/skills/design/data/operator-pack/exemplars/log-stream.tsx +249 -0
- package/data/harnesses/code/skills/design/data/operator-pack/fixtures.md +88 -0
- package/data/harnesses/code/skills/design/data/operator-pack/surface-catalog.md +132 -0
- package/data/harnesses/code/skills/design/data/operator-pack/trust-rubric.md +76 -0
- package/data/harnesses/code/skills/design/references/create.md +126 -0
- package/data/harnesses/code/skills/design/references/diagnosis.md +108 -0
- package/data/harnesses/code/skills/design/references/hard-rules.md +155 -0
- package/data/harnesses/code/skills/design/references/improve.md +85 -0
- package/data/harnesses/code/skills/design/references/interaction.md +77 -0
- package/data/harnesses/code/skills/design/references/introspect.md +100 -0
- package/data/harnesses/code/skills/design/references/lifecycle.md +73 -0
- package/data/harnesses/code/skills/design/references/personality.md +504 -0
- package/data/harnesses/code/skills/design/references/structure.md +138 -0
- package/data/harnesses/code/skills/design/scripts/derive-personality.mjs +706 -0
- package/data/harnesses/code/skills/design/scripts/detector.mjs +898 -0
- package/data/harnesses/code/skills/design/scripts/generate-fixtures.mjs +551 -0
- package/data/harnesses/code/skills/design/scripts/hook-config.json +11 -0
- package/data/harnesses/code/skills/design/scripts/phase-gate.mjs +386 -0
- package/data/harnesses/code/skills/design/scripts/preship-sweep.mjs +280 -0
- package/data/harnesses/code/skills/design/scripts/render-rung.mjs +690 -0
- package/data/harnesses/code/skills/design/scripts/scaffold.mjs +552 -0
- package/data/harnesses/code/skills/design/scripts/self-integrity.mjs +432 -0
- package/package.json +1 -1
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// skills/design/scripts/phase-gate.mjs — create-phase precondition gates (D21/D23): validate the named phase's exit preconditions, exit non-zero printing the exact fix, one green line on pass.
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { join, dirname, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const PHASES = ['intake', 'structure', 'scaffold', 'tokens', 'fixtures', 'surfaces'];
|
|
9
|
+
const ARCHETYPES = new Set(['list-detail', 'dashboard', 'entity-detail', 'create-edit', 'log-stream', 'command-center']);
|
|
10
|
+
const PROVENANCE = new Set(['project', 'extracted', 'library', 'default']);
|
|
11
|
+
const STATE_SYNONYMS = { loading: ['loading', 'skeleton'], empty: ['empty'], error: ['error'], partial: ['partial', 'degraded', 'stale'] };
|
|
12
|
+
|
|
13
|
+
const useColor = process.stdout.isTTY;
|
|
14
|
+
// Wrap a string in green/red when writing to a TTY, plain otherwise.
|
|
15
|
+
const green = (s) => (useColor ? `\x1b[32m${s}\x1b[0m` : s);
|
|
16
|
+
const red = (s) => (useColor ? `\x1b[31m${s}\x1b[0m` : s);
|
|
17
|
+
|
|
18
|
+
// Read a UTF-8 file, returning '' when it is absent.
|
|
19
|
+
function readText(path) {
|
|
20
|
+
try { return readFileSync(path, 'utf8'); } catch { return ''; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Report a passing gate as one green line and exit 0.
|
|
24
|
+
function pass(phase, msg) {
|
|
25
|
+
console.log(`${green(`PASS phase-gate[${phase}]`)} ${msg}`);
|
|
26
|
+
process.exit(0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Report a failing gate with the problem and the exact fix, then exit 1.
|
|
30
|
+
function fail(phase, problem, fixLines) {
|
|
31
|
+
console.error(`${red(`FAIL phase-gate[${phase}]`)} ${problem}`);
|
|
32
|
+
console.error('');
|
|
33
|
+
console.error(' Fix:');
|
|
34
|
+
for (const line of fixLines) {
|
|
35
|
+
if (line == null || line === '') continue;
|
|
36
|
+
for (const sub of String(line).split('\n')) console.error(` ${sub}`);
|
|
37
|
+
}
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Lowercase-kebab a display name for route-dir / fixture-module matching.
|
|
42
|
+
function kebab(s) {
|
|
43
|
+
return String(s).trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Return the first non-parameter path segment of a route (/tickets/:id -> tickets).
|
|
47
|
+
function firstStaticSegment(route) {
|
|
48
|
+
for (const p of String(route).split('/').filter(Boolean)) {
|
|
49
|
+
if (!p.startsWith(':')) return kebab(p);
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Return the last non-parameter path segment of a route (/tickets/new -> new).
|
|
55
|
+
function lastStaticSegment(route) {
|
|
56
|
+
const parts = String(route).split('/').filter(Boolean).filter((p) => !p.startsWith(':'));
|
|
57
|
+
return parts.length ? kebab(parts[parts.length - 1]) : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Parse leading YAML frontmatter into a flat key->value map, or null when absent.
|
|
61
|
+
function parseFrontmatter(text) {
|
|
62
|
+
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
63
|
+
if (!m) return null;
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
66
|
+
const idx = line.indexOf(':');
|
|
67
|
+
if (idx < 0) continue;
|
|
68
|
+
out[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Split a `[a, b, c]` inline list into trimmed members.
|
|
74
|
+
function parseBracketList(s) {
|
|
75
|
+
const m = String(s).match(/\[([^\]]*)\]/);
|
|
76
|
+
if (!m) return [];
|
|
77
|
+
return m[1].split(',').map((x) => x.trim()).filter(Boolean);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Store one `key: value` pair onto a list-item object, parsing `states` into an array.
|
|
81
|
+
function applyKV(obj, s) {
|
|
82
|
+
const idx = s.indexOf(':');
|
|
83
|
+
if (idx < 0) return;
|
|
84
|
+
const key = s.slice(0, idx).trim();
|
|
85
|
+
const value = s.slice(idx + 1).trim();
|
|
86
|
+
if (key === 'states') obj.states = parseBracketList(value);
|
|
87
|
+
else obj[key] = value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Parse the fenced ```yaml structure block into entities, surfaces, and temporal facts.
|
|
91
|
+
function parseStructureBlock(md) {
|
|
92
|
+
const m = md.match(/```yaml structure\r?\n([\s\S]*?)```/);
|
|
93
|
+
if (!m) return null;
|
|
94
|
+
const result = { unitOfWork: null, coreLoop: null, entities: [], temporal: null, surfaces: [] };
|
|
95
|
+
let section = null;
|
|
96
|
+
let itemIndent = -1;
|
|
97
|
+
let current = null;
|
|
98
|
+
for (const raw of m[1].split(/\r?\n/)) {
|
|
99
|
+
if (!raw.trim() || raw.trim().startsWith('#')) continue;
|
|
100
|
+
const indent = raw.length - raw.trimStart().length;
|
|
101
|
+
const line = raw.trim();
|
|
102
|
+
if (indent === 0) {
|
|
103
|
+
current = null;
|
|
104
|
+
itemIndent = -1;
|
|
105
|
+
if (line.startsWith('unit-of-work:')) { result.unitOfWork = line.slice(line.indexOf(':') + 1).trim(); section = null; }
|
|
106
|
+
else if (line.startsWith('core-loop:')) { result.coreLoop = line.slice(line.indexOf(':') + 1).trim(); section = null; }
|
|
107
|
+
else if (line === 'entities:') section = 'entities';
|
|
108
|
+
else if (line === 'surfaces:') section = 'surfaces';
|
|
109
|
+
else if (line === 'temporal:') { section = 'temporal'; result.temporal = {}; }
|
|
110
|
+
else section = null;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (section === 'entities' || section === 'surfaces') {
|
|
114
|
+
const list = section === 'entities' ? result.entities : result.surfaces;
|
|
115
|
+
if (line.startsWith('- ')) {
|
|
116
|
+
if (itemIndent === -1) itemIndent = indent;
|
|
117
|
+
if (indent === itemIndent) {
|
|
118
|
+
current = {};
|
|
119
|
+
list.push(current);
|
|
120
|
+
const rest = line.slice(2).trim();
|
|
121
|
+
if (rest && rest.includes(':')) applyKV(current, rest);
|
|
122
|
+
}
|
|
123
|
+
} else if (current && itemIndent !== -1 && indent > itemIndent) {
|
|
124
|
+
applyKV(current, line);
|
|
125
|
+
}
|
|
126
|
+
} else if (section === 'temporal') {
|
|
127
|
+
const idx = line.indexOf(':');
|
|
128
|
+
if (idx > 0) result.temporal[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Read and parse the structure map, or fail the gate with the structure-phase fix.
|
|
135
|
+
function requireMap(phase, project) {
|
|
136
|
+
const p = join(project, 'docs/design/structure.md');
|
|
137
|
+
if (!existsSync(p)) {
|
|
138
|
+
fail(phase, `No entity/surface map at ${p} — this phase reads it.`, [
|
|
139
|
+
'Complete the structure phase first: derive docs/design/structure.md with a fenced ```yaml structure block (references/structure.md).',
|
|
140
|
+
]);
|
|
141
|
+
}
|
|
142
|
+
const blk = parseStructureBlock(readText(p));
|
|
143
|
+
if (!blk) {
|
|
144
|
+
fail(phase, `${p} has no parseable "yaml structure" fenced block.`, [
|
|
145
|
+
'The block must open exactly with the fence info-string `yaml structure` and carry entities[], temporal, and surfaces[].',
|
|
146
|
+
]);
|
|
147
|
+
}
|
|
148
|
+
return blk;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Collect all .ts/.tsx source under a route directory into one lowercased blob.
|
|
152
|
+
function routeSource(dir) {
|
|
153
|
+
let out = '';
|
|
154
|
+
const stack = [dir];
|
|
155
|
+
while (stack.length) {
|
|
156
|
+
const cur = stack.pop();
|
|
157
|
+
let entries;
|
|
158
|
+
try { entries = readdirSync(cur, { withFileTypes: true }); } catch { continue; }
|
|
159
|
+
for (const ent of entries) {
|
|
160
|
+
const full = join(cur, ent.name);
|
|
161
|
+
if (ent.isDirectory()) stack.push(full);
|
|
162
|
+
else if (/\.(tsx|ts)$/.test(ent.name)) out += `\n${readText(full)}`;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return out.toLowerCase();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Return the candidate route-dir slugs for a surface (name-kebab, route segments, archetype).
|
|
169
|
+
function routeDirCandidates(s) {
|
|
170
|
+
const out = [];
|
|
171
|
+
if (s.name) out.push(kebab(s.name));
|
|
172
|
+
if (s.route) { const f = firstStaticSegment(s.route); if (f) out.push(f); const l = lastStaticSegment(s.route); if (l) out.push(l); }
|
|
173
|
+
if (s.archetype) out.push(s.archetype);
|
|
174
|
+
return [...new Set(out.filter(Boolean))];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Resolve a surface's route directory to the first existing candidate, or null.
|
|
178
|
+
function resolveRouteDir(s, routesDir) {
|
|
179
|
+
for (const c of routeDirCandidates(s)) {
|
|
180
|
+
const d = join(routesDir, c);
|
|
181
|
+
if (existsSync(d) && statSync(d).isDirectory()) return d;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// List the declared states whose branch is absent from a route's shell source.
|
|
187
|
+
function missingStates(dir, states) {
|
|
188
|
+
const text = routeSource(dir);
|
|
189
|
+
return states.filter((st) => {
|
|
190
|
+
const words = STATE_SYNONYMS[st] || [st.toLowerCase()];
|
|
191
|
+
return !words.some((w) => text.includes(w));
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Validate the load-bearing four-step globals.css import order (Cascade Contract 2).
|
|
196
|
+
function checkImportOrder(rawCss) {
|
|
197
|
+
const css = rawCss.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '');
|
|
198
|
+
const marks = [
|
|
199
|
+
['@import "./personality.css"', css.indexOf('@import "./personality.css"')],
|
|
200
|
+
['@import "tailwindcss"', css.indexOf('@import "tailwindcss"')],
|
|
201
|
+
['@import "@nurix/components/styles.css"', css.indexOf('@import "@nurix/components/styles.css"')],
|
|
202
|
+
['@theme inline', css.search(/^\s*@theme\s+inline\b/m)],
|
|
203
|
+
];
|
|
204
|
+
const absent = marks.filter(([, i]) => i < 0).map(([n]) => n);
|
|
205
|
+
if (absent.length) {
|
|
206
|
+
return { problem: `globals.css is missing: ${absent.join(', ')}.`, fix: [
|
|
207
|
+
'Emit the four-step cascade order (references/personality.md §4, Cascade Contract 2):',
|
|
208
|
+
'@import "./personality.css"; -> @import "tailwindcss"; -> @import "@nurix/components/styles.css"; -> @theme inline { ... }',
|
|
209
|
+
] };
|
|
210
|
+
}
|
|
211
|
+
const [p, t, l, th] = marks.map(([, i]) => i);
|
|
212
|
+
if (!(p < t && t < l && l < th)) {
|
|
213
|
+
return { problem: 'globals.css import order is wrong — personality -> tailwindcss -> library -> @theme is load-bearing.', fix: [
|
|
214
|
+
'Reorder so the consumer @theme inline comes AFTER the @nurix/components/styles.css import, or the library radius scale and token defaults win.',
|
|
215
|
+
] };
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// intake gate: a schema-valid design.md with a legal provenance sits at project root.
|
|
221
|
+
function gateIntake(project) {
|
|
222
|
+
const p = join(project, 'design.md');
|
|
223
|
+
if (!existsSync(p)) {
|
|
224
|
+
fail('intake', `No design.md at project root (${p}).`, [
|
|
225
|
+
`Create ${p} against skills/design/data/design-md/SCHEMA.md.`,
|
|
226
|
+
'Frontmatter must set schema: design-md/v1 and provenance: project|extracted|library|default.',
|
|
227
|
+
'Resolve the look design.md-first: the project doc wins, else a confirmed library entry, else the Nurix default.',
|
|
228
|
+
]);
|
|
229
|
+
}
|
|
230
|
+
const fm = parseFrontmatter(readText(p));
|
|
231
|
+
if (!fm) fail('intake', `${p} has no YAML frontmatter.`, ['Add a --- delimited frontmatter block with schema, product, provenance, sourced.']);
|
|
232
|
+
if (fm.schema !== 'design-md/v1') fail('intake', `design.md frontmatter schema is "${fm.schema || '(missing)'}", expected design-md/v1.`, ['Set schema: design-md/v1 in the frontmatter.']);
|
|
233
|
+
if (!PROVENANCE.has(fm.provenance)) fail('intake', `design.md provenance is "${fm.provenance || '(missing)'}"; must be one of project|extracted|library|default.`, ['Set provenance to one of: project | extracted | library | default (SCHEMA.md).']);
|
|
234
|
+
pass('intake', `design.md present with schema design-md/v1 and provenance "${fm.provenance}".`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// structure gate: the map parses, is populated, has a temporal block, and every surface archetype is one of the six.
|
|
238
|
+
function gateStructure(project) {
|
|
239
|
+
const blk = requireMap('structure', project);
|
|
240
|
+
if (!blk.entities.length) fail('structure', 'The structure map has no entities — the object pass was skipped.', ['Populate entities[] from the domain nouns (references/structure.md -> object pass); a small brief earns a small pass, never a skipped one.']);
|
|
241
|
+
if (!blk.surfaces.length) fail('structure', 'The structure map has no surfaces.', ['Assign each surface an archetype from the closed six under surfaces[].']);
|
|
242
|
+
if (!blk.temporal || !Object.keys(blk.temporal).length) fail('structure', 'The structure map has no temporal block.', ['Add the temporal probe (intermediate-states, read-model-lag, deletion, failure-paths) — model facts, not later polish.']);
|
|
243
|
+
const bad = blk.surfaces.filter((s) => !ARCHETYPES.has(s.archetype));
|
|
244
|
+
if (bad.length) {
|
|
245
|
+
fail('structure', `${bad.length} surface(s) carry an archetype outside the closed six: ${bad.map((s) => `${s.name || '(unnamed)'}="${s.archetype || '(none)'}"`).join(', ')}.`, [
|
|
246
|
+
`Every surface's archetype must be one of: ${[...ARCHETYPES].join(', ')}.`,
|
|
247
|
+
]);
|
|
248
|
+
}
|
|
249
|
+
pass('structure', `${blk.surfaces.length} surface(s), all archetypes valid; ${blk.entities.length} entit(ies) and a temporal block present.`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// scaffold gate: the shell, per-surface route dirs + state-shells, router wiring, and globals.css import order are in place.
|
|
253
|
+
function gateScaffold(project) {
|
|
254
|
+
const blk = requireMap('scaffold', project);
|
|
255
|
+
const shellFiles = ['index.html', 'src/main.tsx', 'src/App.tsx'];
|
|
256
|
+
const missing = shellFiles.filter((f) => !existsSync(join(project, f)));
|
|
257
|
+
if (missing.length) {
|
|
258
|
+
fail('scaffold', `Missing shell file(s): ${missing.join(', ')}.`, [
|
|
259
|
+
`Run scaffold.mjs docs/design/structure.md --out ${project} to emit index.html, src/main.tsx, src/App.tsx, route wiring, and state-shells.`,
|
|
260
|
+
]);
|
|
261
|
+
}
|
|
262
|
+
const indexHtml = readText(join(project, 'index.html'));
|
|
263
|
+
if (!/<html[^>]*\bdata-style=/.test(indexHtml)) {
|
|
264
|
+
fail('scaffold', 'index.html <html> is missing the data-style attribute.', [
|
|
265
|
+
'Emit <html data-style="..." data-density="..." data-verbosity="..." data-feedback="..." data-theme="light"> (references/personality.md §5).',
|
|
266
|
+
]);
|
|
267
|
+
}
|
|
268
|
+
const routesDir = join(project, 'src/routes');
|
|
269
|
+
const app = readText(join(project, 'src/App.tsx'));
|
|
270
|
+
const noRoute = [];
|
|
271
|
+
const noWire = [];
|
|
272
|
+
const noShell = [];
|
|
273
|
+
for (const s of blk.surfaces) {
|
|
274
|
+
const dir = resolveRouteDir(s, routesDir);
|
|
275
|
+
const name = s.name || s.route || '(unnamed)';
|
|
276
|
+
if (!dir) { noRoute.push(`${name} (expected src/routes/${routeDirCandidates(s).join(' or /')})`); continue; }
|
|
277
|
+
const seg = s.route ? firstStaticSegment(s.route) : null;
|
|
278
|
+
if (seg && !app.toLowerCase().includes(seg)) noWire.push(`${name} (route "${s.route}" not referenced in src/App.tsx)`);
|
|
279
|
+
const states = (s.states && s.states.length) ? s.states : ['loading', 'empty', 'error', 'partial'];
|
|
280
|
+
const miss = missingStates(dir, states);
|
|
281
|
+
if (miss.length) noShell.push(`${name} (no ${miss.join(', ')} shell)`);
|
|
282
|
+
}
|
|
283
|
+
if (noRoute.length) fail('scaffold', `No route directory for surface(s): ${noRoute.join('; ')}.`, ['scaffold.mjs owns route/nav wiring — emit src/routes/<surface>/ per surface from the map.']);
|
|
284
|
+
if (noWire.length) fail('scaffold', `Route(s) not wired into the router: ${noWire.join('; ')}.`, ['Every surface route must be registered in src/App.tsx (react-router-dom) — routes and nav derive from the map, never decorated on.']);
|
|
285
|
+
if (noShell.length) fail('scaffold', `Missing state-shell stubs: ${noShell.join('; ')}.`, ['Each surface needs its loading / empty / error / degraded shells wired (references/create.md -> scaffold).']);
|
|
286
|
+
const globals = join(project, 'src/styles/globals.css');
|
|
287
|
+
if (!existsSync(globals)) fail('scaffold', 'Missing src/styles/globals.css (Tailwind glue with the four-step import order).', ['Emit src/styles/globals.css with the Cascade Contract 2 import order (references/personality.md §4).']);
|
|
288
|
+
const orderErr = checkImportOrder(readText(globals));
|
|
289
|
+
if (orderErr) fail('scaffold', orderErr.problem, orderErr.fix);
|
|
290
|
+
pass('scaffold', `Shell, ${blk.surfaces.length} wired route dir(s) with state-shells, and globals.css import order all in place.`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// tokens gate: design.md clears the coverage gate (derive-personality.mjs --check) and both CSS files are emitted with the cascade order intact.
|
|
294
|
+
function gateTokens(project) {
|
|
295
|
+
const dm = join(project, 'design.md');
|
|
296
|
+
if (!existsSync(dm)) fail('tokens', `No design.md at ${dm}.`, ['Resolve and materialize design.md first (intake phase).']);
|
|
297
|
+
const derive = join(SCRIPT_DIR, 'derive-personality.mjs');
|
|
298
|
+
if (!existsSync(derive)) fail('tokens', `Coverage-gate sibling not found: ${derive}.`, ['derive-personality.mjs must sit beside phase-gate.mjs in the scripts/ directory.']);
|
|
299
|
+
const res = spawnSync(process.execPath, [derive, dm, '--check'], { encoding: 'utf8' });
|
|
300
|
+
if (res.status !== 0) {
|
|
301
|
+
const detail = `${res.stdout || ''}${res.stderr || ''}`.trim();
|
|
302
|
+
fail('tokens', `The design.md coverage gate failed (derive-personality.mjs --check exited ${res.status == null ? '(spawn error)' : res.status}).`, [
|
|
303
|
+
'Assign or hint every named primitive / dial / token below, then re-run this gate:',
|
|
304
|
+
detail,
|
|
305
|
+
]);
|
|
306
|
+
}
|
|
307
|
+
const files = ['src/styles/personality.css', 'src/styles/globals.css'];
|
|
308
|
+
const miss = files.filter((f) => !existsSync(join(project, f)));
|
|
309
|
+
if (miss.length) {
|
|
310
|
+
fail('tokens', `Coverage gate passed but emitted CSS is missing: ${miss.join(', ')}.`, [
|
|
311
|
+
`Run derive-personality.mjs ${dm} --emit ${project} to write src/styles/personality.css and src/styles/globals.css.`,
|
|
312
|
+
]);
|
|
313
|
+
}
|
|
314
|
+
const orderErr = checkImportOrder(readText(join(project, 'src/styles/globals.css')));
|
|
315
|
+
if (orderErr) fail('tokens', orderErr.problem, orderErr.fix);
|
|
316
|
+
pass('tokens', 'Coverage gate green; personality.css + globals.css emitted with the cascade order intact.');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// fixtures gate: src/fixtures/ carries one seeded module per map entity.
|
|
320
|
+
function gateFixtures(project) {
|
|
321
|
+
const blk = requireMap('fixtures', project);
|
|
322
|
+
const fdir = join(project, 'src/fixtures');
|
|
323
|
+
if (!existsSync(fdir)) {
|
|
324
|
+
fail('fixtures', 'No src/fixtures/ directory.', [
|
|
325
|
+
`Run generate-fixtures.mjs docs/design/structure.md --seed <n> --out ${join(project, 'src/fixtures/')} to write one seeded module per entity.`,
|
|
326
|
+
]);
|
|
327
|
+
}
|
|
328
|
+
const bases = readdirSync(fdir).filter((f) => /\.(ts|tsx)$/.test(f)).map((f) => f.replace(/\.(ts|tsx)$/, '').toLowerCase());
|
|
329
|
+
const missing = [];
|
|
330
|
+
for (const e of blk.entities) {
|
|
331
|
+
if (!e.name) continue;
|
|
332
|
+
const base = e.name.toLowerCase();
|
|
333
|
+
const k = kebab(e.name);
|
|
334
|
+
const cands = new Set([base, k, `${base}s`, `${k}s`]);
|
|
335
|
+
if (/y$/.test(base)) { cands.add(`${base.slice(0, -1)}ies`); cands.add(`${k.slice(0, -1)}ies`); }
|
|
336
|
+
if (!bases.some((n) => cands.has(n))) missing.push(e.name);
|
|
337
|
+
}
|
|
338
|
+
if (missing.length) {
|
|
339
|
+
fail('fixtures', `No seeded fixture module for entity/ies: ${missing.join(', ')}.`, [
|
|
340
|
+
'Each map entity needs a seeded module in src/fixtures/ (fixture-before-surface). Re-run generate-fixtures.mjs.',
|
|
341
|
+
]);
|
|
342
|
+
}
|
|
343
|
+
pass('fixtures', `Seeded fixture module present for all ${blk.entities.length} entit(ies).`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// surfaces gate: every surface's route dir exists and its shell contains every declared state branch.
|
|
347
|
+
function gateSurfaces(project) {
|
|
348
|
+
const blk = requireMap('surfaces', project);
|
|
349
|
+
const routesDir = join(project, 'src/routes');
|
|
350
|
+
const problems = [];
|
|
351
|
+
for (const s of blk.surfaces) {
|
|
352
|
+
const name = s.name || s.route || '(unnamed)';
|
|
353
|
+
const dir = resolveRouteDir(s, routesDir);
|
|
354
|
+
if (!dir) { problems.push(`${name}: no route dir under src/routes/ (expected ${routeDirCandidates(s).join(' or ')})`); continue; }
|
|
355
|
+
const states = (s.states && s.states.length) ? s.states : ['loading', 'empty', 'error'];
|
|
356
|
+
const miss = missingStates(dir, states);
|
|
357
|
+
if (miss.length) problems.push(`${name}: shell missing state branch(es) ${miss.join(', ')}`);
|
|
358
|
+
}
|
|
359
|
+
if (problems.length) {
|
|
360
|
+
fail('surfaces', `${problems.length} surface(s) not fully composed:`, [
|
|
361
|
+
...problems.map((p) => `- ${p}`),
|
|
362
|
+
'Fill every declared lifecycle state into its pre-created shell (references/create.md -> surfaces); states are composed with the surface, not bolted on.',
|
|
363
|
+
]);
|
|
364
|
+
}
|
|
365
|
+
pass('surfaces', `All ${blk.surfaces.length} surface(s) have a route dir with every declared state branch present.`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const GATES = { intake: gateIntake, structure: gateStructure, scaffold: gateScaffold, tokens: gateTokens, fixtures: gateFixtures, surfaces: gateSurfaces };
|
|
369
|
+
|
|
370
|
+
// Parse argv, resolve the project dir, and dispatch to the named phase gate.
|
|
371
|
+
function main() {
|
|
372
|
+
const args = process.argv.slice(2);
|
|
373
|
+
const phase = args[0];
|
|
374
|
+
let project = process.cwd();
|
|
375
|
+
for (let i = 1; i < args.length; i++) {
|
|
376
|
+
if (args[i] === '--project') project = resolve(args[++i] || '.');
|
|
377
|
+
else if (args[i].startsWith('--project=')) project = resolve(args[i].slice('--project='.length));
|
|
378
|
+
}
|
|
379
|
+
if (!phase || !PHASES.includes(phase)) {
|
|
380
|
+
console.error(`usage: phase-gate.mjs <${PHASES.join('|')}> [--project <dir>]`);
|
|
381
|
+
process.exit(2);
|
|
382
|
+
}
|
|
383
|
+
GATES[phase](project);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
main();
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// skills/design/scripts/preship-sweep.mjs — verification-ladder rung 1 (D21/D23): whole-app static sweep — detector relay + cross-file machine checks + mandatory-procedures checklist + scope cross-check.
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { join, dirname, resolve, relative } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const CATALOG = join(SCRIPT_DIR, '..', 'data', 'catalog', 'rules.csv');
|
|
9
|
+
const HARD_RULES = join(SCRIPT_DIR, '..', 'references', 'hard-rules.md');
|
|
10
|
+
|
|
11
|
+
const useColor = process.stdout.isTTY;
|
|
12
|
+
// Wrap a string in red/yellow/green when writing to a TTY, plain otherwise.
|
|
13
|
+
const red = (s) => (useColor ? `\x1b[31m${s}\x1b[0m` : s);
|
|
14
|
+
const yellow = (s) => (useColor ? `\x1b[33m${s}\x1b[0m` : s);
|
|
15
|
+
const green = (s) => (useColor ? `\x1b[32m${s}\x1b[0m` : s);
|
|
16
|
+
const bold = (s) => (useColor ? `\x1b[1m${s}\x1b[0m` : s);
|
|
17
|
+
|
|
18
|
+
// Read a UTF-8 file, returning '' when it is absent.
|
|
19
|
+
function readText(path) {
|
|
20
|
+
try { return readFileSync(path, 'utf8'); } catch { return ''; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Recursively collect files under a dir whose name ends with one of the given extensions.
|
|
24
|
+
function walk(dir, exts, acc = []) {
|
|
25
|
+
if (!existsSync(dir)) return acc;
|
|
26
|
+
let entries;
|
|
27
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return acc; }
|
|
28
|
+
for (const ent of entries) {
|
|
29
|
+
if (ent.name === 'node_modules' || ent.name.startsWith('.')) continue;
|
|
30
|
+
const full = join(dir, ent.name);
|
|
31
|
+
if (ent.isDirectory()) walk(full, exts, acc);
|
|
32
|
+
else if (exts.some((e) => ent.name.endsWith(e))) acc.push(full);
|
|
33
|
+
}
|
|
34
|
+
return acc;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Parse RFC-4180-ish CSV (quoted fields, "" escapes) into an array of row arrays.
|
|
38
|
+
function parseCsv(text) {
|
|
39
|
+
const rows = [];
|
|
40
|
+
let row = [];
|
|
41
|
+
let field = '';
|
|
42
|
+
let inQ = false;
|
|
43
|
+
for (let i = 0; i < text.length; i++) {
|
|
44
|
+
const c = text[i];
|
|
45
|
+
if (inQ) {
|
|
46
|
+
if (c === '"') { if (text[i + 1] === '"') { field += '"'; i++; } else inQ = false; }
|
|
47
|
+
else field += c;
|
|
48
|
+
} else if (c === '"') inQ = true;
|
|
49
|
+
else if (c === ',') { row.push(field); field = ''; }
|
|
50
|
+
else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
|
|
51
|
+
else if (c === '\r') { /* ignore */ }
|
|
52
|
+
else field += c;
|
|
53
|
+
}
|
|
54
|
+
if (field.length || row.length) { row.push(field); rows.push(row); }
|
|
55
|
+
return rows;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Load rules.csv into an array of header-keyed row objects.
|
|
59
|
+
function loadCatalog() {
|
|
60
|
+
const rows = parseCsv(readText(CATALOG));
|
|
61
|
+
if (!rows.length) return [];
|
|
62
|
+
const header = rows[0];
|
|
63
|
+
return rows.slice(1).filter((r) => r.length > 1).map((r) => Object.fromEntries(header.map((h, i) => [h, r[i] ?? ''])));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Extract the sequence of heading levels (1-6) appearing in a source blob.
|
|
67
|
+
function headingLevels(text) {
|
|
68
|
+
const levels = [];
|
|
69
|
+
const re = /<h([1-6])[\s/>]/g;
|
|
70
|
+
let m;
|
|
71
|
+
while ((m = re.exec(text))) levels.push(Number(m[1]));
|
|
72
|
+
return levels;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Return the forward heading-level skips (prev -> level jump greater than one) in order.
|
|
76
|
+
function headingSkips(levels) {
|
|
77
|
+
const skips = [];
|
|
78
|
+
let prev = 0;
|
|
79
|
+
for (const l of levels) {
|
|
80
|
+
if (prev && l > prev + 1) skips.push(`h${prev}->h${l}`);
|
|
81
|
+
prev = l;
|
|
82
|
+
}
|
|
83
|
+
return skips;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Count the route directories under src/routes that hold at least one view file.
|
|
87
|
+
function countDeliverables(project) {
|
|
88
|
+
const rd = join(project, 'src/routes');
|
|
89
|
+
if (!existsSync(rd)) return 0;
|
|
90
|
+
let n = 0;
|
|
91
|
+
for (const ent of readdirSync(rd, { withFileTypes: true })) {
|
|
92
|
+
if (!ent.isDirectory()) continue;
|
|
93
|
+
if (walk(join(rd, ent.name), ['.tsx']).length) n++;
|
|
94
|
+
}
|
|
95
|
+
return n;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Count surfaces declared in the structure map by its per-surface archetype lines.
|
|
99
|
+
function countSurfaces(project) {
|
|
100
|
+
const p = join(project, 'docs/design/structure.md');
|
|
101
|
+
if (!existsSync(p)) return null;
|
|
102
|
+
const m = readText(p).match(/```yaml structure\r?\n([\s\S]*?)```/);
|
|
103
|
+
if (!m) return null;
|
|
104
|
+
return (m[1].match(/^\s*archetype\s*:/gm) || []).length;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Read the spc-14 nav bound (nav 5-9) from the catalog.
|
|
108
|
+
function navBounds(catalog) {
|
|
109
|
+
const row = catalog.find((r) => r.id === 'spc-14');
|
|
110
|
+
const m = row && row.numbers && row.numbers.match(/nav\s*(\d+)\s*[-–]\s*(\d+)/);
|
|
111
|
+
return m ? { min: Number(m[1]), max: Number(m[2]) } : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Count nav items in src/App.tsx, preferring an explicit <nav> region, else route entries.
|
|
115
|
+
function countNav(project) {
|
|
116
|
+
const text = readText(join(project, 'src/App.tsx'));
|
|
117
|
+
const nav = text.match(/<nav[\s\S]*?<\/nav>/i);
|
|
118
|
+
if (nav) {
|
|
119
|
+
const items = (nav[0].match(/<(a|NavLink|Link|NavItem|button)\b/gi) || []).length;
|
|
120
|
+
if (items) return items;
|
|
121
|
+
}
|
|
122
|
+
return (text.match(/\bpath\s*=/g) || []).length;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Try to parse detector --json stdout into a flat findings array, or null.
|
|
126
|
+
function parseDetectorJson(stdout) {
|
|
127
|
+
const s = (stdout || '').trim();
|
|
128
|
+
if (!s) return [];
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(s);
|
|
131
|
+
if (Array.isArray(parsed)) return parsed;
|
|
132
|
+
if (parsed && Array.isArray(parsed.findings)) return parsed.findings;
|
|
133
|
+
if (parsed && Array.isArray(parsed.violations)) return parsed.violations;
|
|
134
|
+
return null;
|
|
135
|
+
} catch { return null; }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Render one relayed detector finding as a single fix-or-classify line.
|
|
139
|
+
function formatFinding(f, project) {
|
|
140
|
+
const anchor = f.id || f.anchor || f.rule || f.key || '?';
|
|
141
|
+
const file = f.file ? relative(project, f.file) || f.file : '';
|
|
142
|
+
const loc = f.line != null ? `${file}:${f.line}` : file;
|
|
143
|
+
const evidence = f.excerpt || f.evidence || f.value || '';
|
|
144
|
+
const msg = f.message || f.statement || f.detail || f.fix || '';
|
|
145
|
+
const ev = evidence ? ` [${evidence}]` : '';
|
|
146
|
+
return ` ${red(anchor)} ${loc}${ev} ${msg}`.trimEnd();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Spawn detector.mjs over every source file and relay its findings; returns a violation count or -1 when unavailable.
|
|
150
|
+
function runDetector(project, files, section) {
|
|
151
|
+
const detector = join(SCRIPT_DIR, 'detector.mjs');
|
|
152
|
+
if (!existsSync(detector)) {
|
|
153
|
+
section.push(yellow(` SWEEP INCOMPLETE: detector.mjs not found at ${detector} — the per-file catalog was not run. Build it before shipping.`));
|
|
154
|
+
return -1;
|
|
155
|
+
}
|
|
156
|
+
if (!files.length) { section.push(' (no src/**/*.{tsx,ts,css,html} files to scan)'); return 0; }
|
|
157
|
+
const args = [detector, ...files, '--json'];
|
|
158
|
+
const manifest = join(project, 'design.md');
|
|
159
|
+
if (existsSync(manifest)) args.push('--manifest', manifest);
|
|
160
|
+
const res = spawnSync(process.execPath, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
161
|
+
const findings = parseDetectorJson(res.stdout);
|
|
162
|
+
if (findings) {
|
|
163
|
+
if (!findings.length) { section.push(green(' detector: no per-file violations.')); return 0; }
|
|
164
|
+
section.push(` detector reported ${findings.length} violation(s) — fix each now, or classify why it does not apply in docs/memory/design-decay.md; never ignore silently:`);
|
|
165
|
+
for (const f of findings) section.push(formatFinding(f, project));
|
|
166
|
+
return findings.length;
|
|
167
|
+
}
|
|
168
|
+
if (res.stdout) section.push(res.stdout.replace(/\n$/, '').split('\n').map((l) => ` ${l}`).join('\n'));
|
|
169
|
+
if (res.stderr) section.push(yellow(res.stderr.replace(/\n$/, '').split('\n').map((l) => ` ${l}`).join('\n')));
|
|
170
|
+
return res.status ? 1 : 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Run the cross-file machine checks (dep-02 one-h1 + heading skips) and the advisory nav-count.
|
|
174
|
+
function crossFileChecks(project, files, catalog, section) {
|
|
175
|
+
const violations = [];
|
|
176
|
+
for (const file of files) {
|
|
177
|
+
if (!/\.(tsx|html)$/.test(file)) continue;
|
|
178
|
+
const text = readText(file);
|
|
179
|
+
const rel = relative(project, file) || file;
|
|
180
|
+
const h1 = (text.match(/<h1[\s/>]/g) || []).length;
|
|
181
|
+
if (h1 >= 2) violations.push(` ${red('dep-02')} ${rel} ${h1} <h1> elements in one view; exactly one h1 per view.`);
|
|
182
|
+
const skips = headingSkips(headingLevels(text));
|
|
183
|
+
if (skips.length) violations.push(` ${red('dep-02')} ${rel} heading level skips (${skips.join(', ')}); heading levels never skip.`);
|
|
184
|
+
}
|
|
185
|
+
const routesDir = join(project, 'src/routes');
|
|
186
|
+
if (existsSync(routesDir)) {
|
|
187
|
+
for (const ent of readdirSync(routesDir, { withFileTypes: true })) {
|
|
188
|
+
if (!ent.isDirectory()) continue;
|
|
189
|
+
const dir = join(routesDir, ent.name);
|
|
190
|
+
const total = walk(dir, ['.tsx']).reduce((n, f) => n + (readText(f).match(/<h1[\s/>]/g) || []).length, 0);
|
|
191
|
+
if (total > 1) violations.push(` ${red('dep-02')} src/routes/${ent.name}/ ${total} <h1> across the route shell; one h1 per route view.`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (violations.length) { section.push(...violations); }
|
|
195
|
+
else section.push(green(' cross-file machine checks (dep-02 one-h1, heading-skip): clean.'));
|
|
196
|
+
|
|
197
|
+
const bounds = navBounds(catalog);
|
|
198
|
+
const nav = countNav(project);
|
|
199
|
+
if (bounds) {
|
|
200
|
+
const within = nav >= bounds.min && nav <= bounds.max;
|
|
201
|
+
const note = ` ${within ? '' : yellow('[advisory] ')}nav items: ${nav} (spc-14 advisory band ${bounds.min}-${bounds.max}${within ? ', within' : ', outside — judged, not gated'}).`;
|
|
202
|
+
section.push(note);
|
|
203
|
+
}
|
|
204
|
+
return violations.length;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Print the proc-* mandatory-procedures checklist, each row's imperative sourced from hard-rules.md.
|
|
208
|
+
function procChecklist(catalog) {
|
|
209
|
+
const hard = readText(HARD_RULES);
|
|
210
|
+
const imperatives = new Map();
|
|
211
|
+
for (const line of hard.split(/\r?\n/)) {
|
|
212
|
+
const m = line.match(/^- `(proc-\d+)`\s*(.*)$/);
|
|
213
|
+
if (m) imperatives.set(m[1], m[2].trim());
|
|
214
|
+
}
|
|
215
|
+
const procs = catalog.filter((r) => /^proc-\d+$/.test(r.id)).sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }));
|
|
216
|
+
const lines = [];
|
|
217
|
+
for (const r of procs) {
|
|
218
|
+
const text = imperatives.get(r.id) || r.statement || '';
|
|
219
|
+
lines.push(` [ ] ${r.id} — ${text}`);
|
|
220
|
+
}
|
|
221
|
+
return lines;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Assemble and print the whole sweep, then exit non-zero when detector or cross-file checks found violations.
|
|
225
|
+
function main() {
|
|
226
|
+
const args = process.argv.slice(2);
|
|
227
|
+
let project = process.cwd();
|
|
228
|
+
for (let i = 0; i < args.length; i++) {
|
|
229
|
+
if (args[i] === '--project') project = resolve(args[++i] || '.');
|
|
230
|
+
else if (args[i].startsWith('--project=')) project = resolve(args[i].slice('--project='.length));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const catalog = loadCatalog();
|
|
234
|
+
const files = walk(join(project, 'src'), ['.tsx', '.ts', '.css', '.html']);
|
|
235
|
+
|
|
236
|
+
console.log(bold(`preship-sweep — verification ladder rung 1 (project: ${project})`));
|
|
237
|
+
console.log('');
|
|
238
|
+
|
|
239
|
+
console.log(bold('(a) Per-file catalog — detector.mjs relay'));
|
|
240
|
+
const secA = [];
|
|
241
|
+
const detectorCount = runDetector(project, files, secA);
|
|
242
|
+
console.log(secA.join('\n'));
|
|
243
|
+
console.log('');
|
|
244
|
+
|
|
245
|
+
console.log(bold('(b) Cross-file machine checks'));
|
|
246
|
+
const secB = [];
|
|
247
|
+
const crossCount = crossFileChecks(project, files, catalog, secB);
|
|
248
|
+
console.log(secB.join('\n'));
|
|
249
|
+
console.log('');
|
|
250
|
+
|
|
251
|
+
console.log(bold('(c) Mandatory procedures — self-audit checklist (proc-*)'));
|
|
252
|
+
console.log(procChecklist(catalog).join('\n'));
|
|
253
|
+
console.log('');
|
|
254
|
+
|
|
255
|
+
console.log(bold('(c) Scope cross-check (proc-14)'));
|
|
256
|
+
const surfaces = countSurfaces(project);
|
|
257
|
+
const deliverables = countDeliverables(project);
|
|
258
|
+
if (surfaces == null) {
|
|
259
|
+
console.log(` ${yellow('no docs/design/structure.md map found — cannot cross-check scope.')}`);
|
|
260
|
+
} else {
|
|
261
|
+
console.log(` structure map declares ${surfaces} surface(s); found ${deliverables} route deliverable(s) under src/routes/.`);
|
|
262
|
+
if (surfaces !== deliverables) console.log(` ${yellow(`[advisory] scope mismatch (${surfaces} vs ${deliverables}) — add or reconcile before responding (proc-14).`)}`);
|
|
263
|
+
}
|
|
264
|
+
console.log('');
|
|
265
|
+
|
|
266
|
+
const detectorFailed = detectorCount === -1 || detectorCount > 0;
|
|
267
|
+
const failed = detectorFailed || crossCount > 0;
|
|
268
|
+
if (failed) {
|
|
269
|
+
const reasons = [];
|
|
270
|
+
if (detectorCount === -1) reasons.push('detector.mjs unavailable (sweep incomplete)');
|
|
271
|
+
if (detectorCount > 0) reasons.push(`${detectorCount} per-file violation(s)`);
|
|
272
|
+
if (crossCount > 0) reasons.push(`${crossCount} cross-file violation(s)`);
|
|
273
|
+
console.log(red(`FAIL preship-sweep — ${reasons.join('; ')}. Resolve, then re-run.`));
|
|
274
|
+
process.exit(1);
|
|
275
|
+
}
|
|
276
|
+
console.log(green('PASS preship-sweep — no static violations; work the checklist above before ship.'));
|
|
277
|
+
process.exit(0);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
main();
|