@starci/hfs 4.7.34 → 4.7.35

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +8 -11
  3. package/lint/run.mjs +70 -29
  4. package/package.json +2 -2
  5. package/runtime/knowledge/grammars/index.yaml +102 -0
  6. package/runtime/knowledge/grammars/offset-pop/playbook.yaml +302 -0
  7. package/runtime/knowledge/grammars/starci/playbook.yaml +1159 -0
  8. package/runtime/knowledge/hfs/canon-pins.yaml +22 -8
  9. package/runtime/knowledge/hfs/fe-stack.yaml +7 -0
  10. package/runtime/knowledge/hfs/rules.yaml +133 -83
  11. package/runtime/knowledge/hfs/slots.yaml +3 -3
  12. package/runtime/knowledge/ui/presentation/font.yaml +101 -0
  13. package/runtime/knowledge/ui/presentation/padding.yaml +216 -0
  14. package/runtime/modules/kernel/failure-codes.yaml +40 -20
  15. package/runtime/modules/kernel/removed-vocabulary.yaml +29 -0
  16. package/runtime/packages/cli/src/catalog.generated.mjs +7 -7
  17. package/runtime/scripts/gates/canon-pins.mjs +103 -0
  18. package/runtime/scripts/gates/gate.mjs +3 -36
  19. package/runtime/scripts/gates/output.mjs +40 -0
  20. package/runtime/scripts/gates/test-world-run.mjs +2 -4
  21. package/runtime/scripts/hfs/architecture/config.mjs +8 -8
  22. package/runtime/scripts/hfs/check.mjs +1 -1
  23. package/runtime/scripts/hfs/db-restamp-verb.mjs +2 -2
  24. package/runtime/scripts/hfs/declaration-shape.mjs +8 -2
  25. package/runtime/scripts/hfs/grammar-presentation.mjs +152 -0
  26. package/runtime/scripts/hfs/rule-catalog.mjs +2 -2
  27. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -0
  28. package/runtime/scripts/hfs/rules/fe-stack.mjs +44 -1
  29. package/runtime/scripts/hfs/slots.mjs +1 -0
  30. package/runtime/scripts/hfs/view.mjs +1 -0
  31. package/runtime/scripts/lib/canon-binding-text.mjs +129 -0
  32. package/runtime/scripts/lib/example-refs.mjs +1 -1
  33. package/runtime/scripts/lib/grammar-lint.mjs +48 -0
  34. package/runtime/scripts/lib/grammar-vendor-parity.mjs +31 -111
  35. package/runtime/scripts/lib/grammar-vendor-source.mjs +345 -0
  36. package/runtime/scripts/lib/numeric-match.mjs +5 -0
  37. package/runtime/scripts/lib/source-package-imports.mjs +111 -0
  38. package/runtime/scripts/work/draw/draw-layer.mjs +5 -4
  39. package/scaffold/app.mjs +7 -5
  40. package/src/main.mjs +26 -13
  41. package/stack/config.mjs +2 -1
  42. package/stack/render.mjs +24 -3
  43. package/sync/index.mjs +8 -2
  44. package/templates/app/skeleton/.starcistacks/dev/infra/compose/stack.yaml +0 -1
  45. package/templates/app/skeleton/.starcistacks/production/infra/swarm/stack.yaml +0 -1
  46. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +0 -1
  47. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +0 -1
  48. package/templates/fe/tool-config/eslint.config.mjs +4 -1
@@ -0,0 +1,345 @@
1
+ // grammar-vendor-source.mjs - pure JSX and expression readers for Grammar vendor-parity judgments.
2
+ // The caller supplies source text, import aliases and measured role declarations; this module performs no I/O.
3
+ // Kept separate from the parity rules so parsing and vendor render-prop custody have one owner.
4
+
5
+ const lineOf = (text, index) => text.slice(0, index).split('\n').length;
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Reading JSX without a parser: the opening tags of the aliases a file imports from @heroui/react.
9
+ // ---------------------------------------------------------------------------
10
+
11
+ /** The index after the quoted string that starts at `start`. */
12
+ function skipQuoted(text, start) {
13
+ const quote = text[start];
14
+ let index = start + 1;
15
+ while (index < text.length && text[index] !== quote) index += text[index] === '\\' ? 2 : 1;
16
+ return index + 1;
17
+ }
18
+
19
+ /** The index after the template literal that starts at `start`, `${...}` substitutions included. */
20
+ function skipTemplate(text, start) {
21
+ let index = start + 1;
22
+ while (index < text.length && text[index] !== '`') {
23
+ if (text[index] === '\\') index += 2;
24
+ else if (text[index] === '$' && text[index + 1] === '{') index = skipBraces(text, index + 1);
25
+ else index += 1;
26
+ }
27
+ return index + 1;
28
+ }
29
+
30
+ /** The index after the balanced `{...}` that starts at `start`. */
31
+ function skipBraces(text, start) {
32
+ let depth = 0;
33
+ let index = start;
34
+ while (index < text.length) {
35
+ const char = text[index];
36
+ if (char === '"' || char === "'") index = skipQuoted(text, index);
37
+ else if (char === '`') index = skipTemplate(text, index);
38
+ else {
39
+ if (char === '{') depth += 1;
40
+ if (char === '}') depth -= 1;
41
+ index += 1;
42
+ if (depth === 0 && char === '}') return index;
43
+ }
44
+ }
45
+ return index;
46
+ }
47
+
48
+ /** The index after the value (a string, a template or a braced expression) that starts at `start`. */
49
+ function skipValue(text, start) {
50
+ const char = text[start];
51
+ if (char === '"' || char === "'") return skipQuoted(text, start);
52
+ if (char === '`') return skipTemplate(text, start);
53
+ return char === '{' ? skipBraces(text, start) : start + 1;
54
+ }
55
+
56
+ /** Attributes of the opening tag whose name ended at `from`: `{name, raw}` (a spread is named `...`). */
57
+ function readAttributes(text, from) {
58
+ const attrs = [];
59
+ let index = from;
60
+ while (index < text.length) {
61
+ while (/\s/.test(text[index] ?? '')) index += 1;
62
+ if (text[index] === '>' || text.startsWith('/>', index)) break;
63
+ if (text[index] === '{') {
64
+ const end = skipBraces(text, index);
65
+ attrs.push({ name: '...', raw: text.slice(index + 1, end - 1) });
66
+ index = end;
67
+ continue;
68
+ }
69
+ const name = /^[\w:-]+/.exec(text.slice(index, index + 80))?.[0];
70
+ if (!name) break;
71
+ index += name.length;
72
+ if (text[index] !== '=') {
73
+ attrs.push({ name, raw: 'true' });
74
+ continue;
75
+ }
76
+ index += 1;
77
+ const end = skipValue(text, index);
78
+ const raw = text.slice(index, end);
79
+ attrs.push({ name, raw: raw.startsWith('{') ? raw.slice(1, -1) : raw, offset: raw.startsWith('{') ? index + 1 : index });
80
+ index = end;
81
+ }
82
+ return attrs;
83
+ }
84
+
85
+ /** The opening tags of the given import aliases: `{alias, part, attrs, line}` (`part` is `Root` for the bare alias). */
86
+ export function heroTags(text, aliases) {
87
+ if (!aliases.length) return [];
88
+ const pattern = new RegExp(String.raw`<(${aliases.join('|')})(?:\.(\w+))?(?=[\s/>])`, 'g');
89
+ return [...text.matchAll(pattern)].map((match) => ({
90
+ alias: match[1],
91
+ part: match[2] ?? 'Root',
92
+ attrs: readAttributes(text, match.index + match[0].length),
93
+ line: lineOf(text, match.index),
94
+ }));
95
+ }
96
+
97
+ /** The vendor imports of a source: alias -> vendor component name. */
98
+ export function importAliases(text) {
99
+ const aliases = new Map();
100
+ for (const match of text.matchAll(/import\s*\{([^}]*)\}\s*from\s*["']@heroui\/react["']/g)) {
101
+ for (const entry of match[1].split(',').map((part) => part.trim()).filter(Boolean)) {
102
+ const found = /^(?!type\s)(\w+)(?:\s+as\s+(\w+))?$/.exec(entry);
103
+ if (found) aliases.set(found[2] ?? found[1], found[1]);
104
+ }
105
+ }
106
+ return aliases;
107
+ }
108
+
109
+ /** Whether the cursor starts a JavaScript string or template. */
110
+ const isQuoted = (char) => char === '"' || char === "'" || char === '`';
111
+
112
+ /** Skip strings and comments while looking for JSX rather than text that only resembles it. */
113
+ function skipJsTrivia(text, index) {
114
+ if (isQuoted(text[index])) return skipValue(text, index);
115
+ if (text.startsWith('//', index)) {
116
+ const end = text.indexOf('\n', index);
117
+ return end < 0 ? text.length : end;
118
+ }
119
+ if (text.startsWith('/*', index)) {
120
+ const end = text.indexOf('*/', index + 2);
121
+ return end < 0 ? text.length : end + 2;
122
+ }
123
+ return undefined;
124
+ }
125
+
126
+ /** The end of an opening tag, keeping greater-than signs inside attribute expressions intact. */
127
+ function jsxOpeningEnd(text, start) {
128
+ let index = start;
129
+ while (index < text.length && text[index] !== '>') {
130
+ index = /["'{`]/.test(text[index]) ? skipValue(text, index) : index + 1;
131
+ }
132
+ return index;
133
+ }
134
+
135
+ /** Read one opening tag and the cursor after its attributes. */
136
+ function jsxOpening(text, index) {
137
+ if (text[index] !== '<') return undefined;
138
+ const opening = /^<([A-Za-z][\w.:-]*)(?=[\s/>])/.exec(text.slice(index));
139
+ if (!opening) return undefined;
140
+ const from = index + opening[0].length;
141
+ const [alias, part = 'Root'] = opening[1].split('.');
142
+ return { tag: { alias, part, attrs: readAttributes(text, from), index }, end: jsxOpeningEnd(text, from) };
143
+ }
144
+
145
+ /** Opening JSX tags, excluding JavaScript strings and comments; attribute expressions remain intact. */
146
+ export function jsxTags(text) {
147
+ const tags = [];
148
+ let index = 0;
149
+ while (index < text.length) {
150
+ const skipped = skipJsTrivia(text, index);
151
+ if (skipped !== undefined) {
152
+ index = skipped;
153
+ continue;
154
+ }
155
+ const opening = jsxOpening(text, index);
156
+ if (!opening) {
157
+ index += 1;
158
+ continue;
159
+ }
160
+ tags.push(opening.tag);
161
+ for (const attr of opening.tag.attrs.filter((candidate) => candidate.name !== '...')) {
162
+ tags.push(...jsxTags(attr.raw).map((tag) => ({ ...tag, index: tag.index + attr.offset })));
163
+ }
164
+ index = opening.end + 1;
165
+ }
166
+ return tags;
167
+ }
168
+
169
+ /** Apply the delimiter at a cursor to an expression's nesting depth. */
170
+ const expressionDepth = (depth, char) => {
171
+ if ('({['.includes(char)) return depth + 1;
172
+ return ')}]'.includes(char) ? depth - 1 : depth;
173
+ };
174
+
175
+ /** The top-level operator at this cursor, with nullish and logical operators kept whole. */
176
+ function expressionOperator(raw, index) {
177
+ const logical = /^(?:\?\?|\|\||&&)/.exec(raw.slice(index));
178
+ if (logical) return logical[0];
179
+ if (/\s/.test(raw[index - 1] ?? '') && /^(?:as|satisfies)\s/.test(raw.slice(index))) return 'type';
180
+ return ['?', ':', ','].includes(raw[index]) ? raw[index] : undefined;
181
+ }
182
+
183
+ /** Top-level expression operators, excluding strings, nested expressions and nullish `??`. */
184
+ function expressionOperators(raw) {
185
+ const operators = [];
186
+ let depth = 0;
187
+ let index = 0;
188
+ while (index < raw.length) {
189
+ const char = raw[index];
190
+ if (isQuoted(char)) {
191
+ index = skipValue(raw, index);
192
+ continue;
193
+ }
194
+ depth = expressionDepth(depth, char);
195
+ const operator = depth === 0 ? expressionOperator(raw, index) : undefined;
196
+ if (operator !== undefined) operators.push({ operator, index });
197
+ index += ['??', '||', '&&'].includes(operator) ? 2 : 1;
198
+ }
199
+ return operators;
200
+ }
201
+
202
+ /** The first top-level expression terminator; constant initializers can continue a conditional on the next line. */
203
+ function expressionEnd(text, start, continuedConditional = false) {
204
+ let depth = 0;
205
+ let index = start;
206
+ while (index < text.length) {
207
+ const char = text[index];
208
+ if (isQuoted(char)) {
209
+ index = skipValue(text, index);
210
+ continue;
211
+ }
212
+ depth = expressionDepth(depth, char);
213
+ if (depth === 0 && char === ';') break;
214
+ if (depth === 0 && char === '\n') {
215
+ const continues = continuedConditional && /^\s*(?:\?(?!\.)|:|\|\||&&)/.test(text.slice(index + 1));
216
+ if (!continues) break;
217
+ }
218
+ index += 1;
219
+ }
220
+ return index;
221
+ }
222
+
223
+ /** A local constant initializer, including balanced objects and multiline conditional branches. */
224
+ function constantExpression(text, identifier) {
225
+ const declaration = new RegExp(String.raw`\bconst\s+${identifier}(?:\s*:\s*[^=;\n]+)?\s*=`).exec(text);
226
+ if (!declaration) return undefined;
227
+ const start = declaration.index + declaration[0].length;
228
+ return text.slice(start, expressionEnd(text, start, true)).trim();
229
+ }
230
+
231
+ /** The static entries of an object constant used as a role lookup table. */
232
+ function expressionEntries(raw) {
233
+ raw = raw.trim();
234
+ const assertion = expressionOperators(raw).find((entry) => entry.operator === 'type');
235
+ if (assertion) raw = raw.slice(0, assertion.index).trim();
236
+ if (!raw.startsWith('{') || !raw.endsWith('}')) return [];
237
+ const body = raw.slice(1, -1);
238
+ const separators = expressionOperators(body).filter((entry) => entry.operator === ',').map((entry) => entry.index);
239
+ const entries = [];
240
+ let start = 0;
241
+ for (const end of [...separators, body.length]) {
242
+ const entry = body.slice(start, end).trim();
243
+ const colon = expressionOperators(entry).find((operator) => operator.operator === ':');
244
+ if (colon) entries.push({ key: entry.slice(0, colon.index).trim().replace(/^['"]|['"]$/g, ''), value: entry.slice(colon.index + 1) });
245
+ start = end + 1;
246
+ }
247
+ return entries;
248
+ }
249
+
250
+ /** Whether one balanced pair encloses the complete expression rather than only its first operand. */
251
+ function enclosesExpression(raw) {
252
+ if (!raw.startsWith('(') || !raw.endsWith(')')) return false;
253
+ let depth = 0;
254
+ let index = 0;
255
+ while (index < raw.length) {
256
+ const char = raw[index];
257
+ if (isQuoted(char)) {
258
+ index = skipValue(raw, index);
259
+ continue;
260
+ }
261
+ if (char === '(') depth += 1;
262
+ if (char === ')') depth -= 1;
263
+ if (depth === 0) return index === raw.length - 1;
264
+ index += 1;
265
+ }
266
+ return false;
267
+ }
268
+
269
+ /** Match a conditional's colon without confusing nested conditionals with its own branches. */
270
+ function conditionalColon(operators, question) {
271
+ let nested = 0;
272
+ return operators.slice(question + 1).find((entry) => {
273
+ if (entry.operator === '?') nested += 1;
274
+ if (entry.operator !== ':') return false;
275
+ if (nested > 0) {
276
+ nested -= 1;
277
+ return false;
278
+ }
279
+ return true;
280
+ });
281
+ }
282
+
283
+ /** Resolve a role lookup through a local object constant, considering every possible computed key. */
284
+ function memberExpressionStrings(text, member, seen) {
285
+ if (seen.has(member[1])) return [];
286
+ const initializer = constantExpression(text, member[1]);
287
+ if (!initializer) return [];
288
+ seen.add(member[1]);
289
+ const literalKey = member[3] ?? (/^(['"])([^'"]+)\1$/.exec(member[2]?.trim() ?? '')?.[2]);
290
+ return expressionEntries(initializer).filter((entry) => literalKey === undefined || entry.key === literalKey).flatMap((entry) => expressionStrings(text, entry.value, new Set(seen)));
291
+ }
292
+
293
+ /** Result literals of a JSX expression, including local constants; condition literals are not result values. */
294
+ export function expressionStrings(text, raw, seen = new Set()) {
295
+ raw = raw.trim();
296
+ if (enclosesExpression(raw)) return expressionStrings(text, raw.slice(1, -1), seen);
297
+ if (/^(?:"[^"\n]*"|'[^'\n]*'|`[^`$]*`)$/.test(raw)) return [raw.slice(1, -1)];
298
+ const operators = expressionOperators(raw);
299
+ const assertion = operators.find((entry) => entry.operator === 'type');
300
+ if (assertion) return expressionStrings(text, raw.slice(0, assertion.index), seen);
301
+ const question = operators.findIndex((entry) => entry.operator === '?');
302
+ const colon = question >= 0 ? conditionalColon(operators, question) : undefined;
303
+ if (colon) return [...expressionStrings(text, raw.slice(operators[question].index + 1, colon.index), new Set(seen)), ...expressionStrings(text, raw.slice(colon.index + 1), new Set(seen))];
304
+ const logical = operators.find((entry) => ['??', '||', '&&'].includes(entry.operator));
305
+ if (logical) return [...expressionStrings(text, raw.slice(0, logical.index), new Set(seen)), ...expressionStrings(text, raw.slice(logical.index + 2), new Set(seen))];
306
+ const member = /^([A-Za-z_$][\w$]*)(?:\[([\s\S]+)\]|\.([A-Za-z_$][\w$]*))$/.exec(raw);
307
+ if (member) return memberExpressionStrings(text, member, seen);
308
+ const identifier = /^([A-Za-z_$][\w$]*)$/.exec(raw)?.[1];
309
+ if (!identifier || seen.has(identifier)) return [];
310
+ seen.add(identifier);
311
+ const initializer = constantExpression(text, identifier);
312
+ return initializer ? expressionStrings(text, initializer, seen) : [];
313
+ }
314
+
315
+ /** The first callback parameter, after optional parentheses and surrounding whitespace. */
316
+ function callbackParameter(source) {
317
+ const trimmed = source.trimStart();
318
+ const parameter = trimmed.startsWith('(') ? trimmed.slice(1).trimStart() : trimmed;
319
+ return /^([A-Za-z_$][\w$]*)/.exec(parameter)?.[1];
320
+ }
321
+
322
+ /** A render callback's source offset, resolving a named local callback when present. */
323
+ function callbackStart(text, callback) {
324
+ if (!/^[\w$]+$/.test(callback.raw)) return callback.offset;
325
+ const declaration = new RegExp(String.raw`\bconst\s+${callback.raw}\s*=`).exec(text);
326
+ return declaration ? declaration.index + declaration[0].length : undefined;
327
+ }
328
+
329
+ /** The native root receiving the vendor's render props and the role contracts it therefore inherits. */
330
+ function vendorRenderRoot(text, callback, declarations) {
331
+ const start = callbackStart(text, callback);
332
+ if (start === undefined) return [];
333
+ const parameter = callbackParameter(text.slice(start));
334
+ if (!parameter) return [];
335
+ const root = jsxTags(text.slice(start, expressionEnd(text, start)))[0];
336
+ if (!root || !root.attrs.some((attr) => attr.name === '...' && attr.raw.trim() === `...${parameter}`)) return [];
337
+ const roles = Object.entries(declarations).filter(([, entry]) => entry.contracts?.includes(`${callback.owner}.Root`)).map(([role]) => role);
338
+ return [{ index: root.index + start, roles }];
339
+ }
340
+
341
+ /** The local render callbacks supplied to vendor roots keep the vendor's state, semantics and prop custody. */
342
+ export function vendorRenderRoots(text, aliases, declarations) {
343
+ const callbacks = heroTags(text, [...aliases.keys()]).flatMap((tag) => tag.attrs.filter((attr) => attr.name === 'render').map((attr) => ({ raw: attr.raw.trim(), offset: attr.offset, owner: aliases.get(tag.alias) })));
344
+ return callbacks.flatMap((callback) => vendorRenderRoot(text, callback, declarations));
345
+ }
@@ -0,0 +1,5 @@
1
+ /** Read the first numeric capture of a matching recognizer; unmatched values have no number. */
2
+ export function numericMatch(value, pattern) {
3
+ const match = pattern.exec(String(value ?? ''));
4
+ return match ? Number(match[1]) : null;
5
+ }
@@ -0,0 +1,111 @@
1
+ // Source-package imports preserve published export and exact dependency contracts without a workspace install.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { createRequire, registerHooks } from 'node:module';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
+ import { insidePath, pathKey, realPath } from './path-key.mjs';
7
+
8
+ const packageNameOf = (specifier) => specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split('/')[0];
9
+
10
+ function conditionalTarget(value, conditions) {
11
+ if (typeof value === 'string' || value === null) return value;
12
+ if (Array.isArray(value)) return value.map(entry => conditionalTarget(entry, conditions)).find(entry => entry !== undefined);
13
+ if (!value || typeof value !== 'object') return undefined;
14
+ for (const [condition, target] of Object.entries(value)) {
15
+ if (condition === 'default' || conditions.includes(condition)) {
16
+ const selected = conditionalTarget(target, conditions);
17
+ if (selected !== undefined) return selected;
18
+ }
19
+ }
20
+ }
21
+
22
+ /** Resolve only exports that the package publishes; private paths remain unavailable in a source checkout. */
23
+ function exportedTarget(exports, subpath, conditions) {
24
+ if (!exports || typeof exports !== 'object' || Array.isArray(exports)) return subpath === '.' ? conditionalTarget(exports, conditions) : undefined;
25
+ const keys = Object.keys(exports);
26
+ if (keys.every(key => !key.startsWith('.'))) return subpath === '.' ? conditionalTarget(exports, conditions) : undefined;
27
+ if (Object.hasOwn(exports, subpath)) return conditionalTarget(exports[subpath], conditions);
28
+ const patterns = keys.filter(key => key.includes('*')).sort((a, b) => b.indexOf('*') - a.indexOf('*') || b.length - a.length);
29
+ for (const pattern of patterns) {
30
+ const [prefix, suffix] = pattern.split('*');
31
+ if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue;
32
+ const match = subpath.slice(prefix.length, suffix ? -suffix.length : undefined);
33
+ const target = conditionalTarget(exports[pattern], conditions);
34
+ return typeof target === 'string' ? target.replaceAll('*', match) : target;
35
+ }
36
+ }
37
+
38
+ function sourcePackages(root, packages) {
39
+ return Object.entries(packages).filter(([, pin]) => typeof pin?.source === 'string').map(([name, pin]) => {
40
+ const manifestFile = realPath(path.resolve(root, pin.source));
41
+ if (!insidePath(root, manifestFile, { key: pathKey })) throw new Error(`Source package ${name} is outside the source tree`);
42
+ return { name, pin, manifestFile, root: path.dirname(manifestFile), manifest: null };
43
+ }).sort((a, b) => b.root.length - a.root.length);
44
+ }
45
+
46
+ function manifestOf(source) {
47
+ source.manifest ??= JSON.parse(fs.readFileSync(source.manifestFile, 'utf8'));
48
+ if (source.manifest.name !== source.name || source.manifest.version !== source.pin.version) {
49
+ throw new Error(`Source package ${source.name} does not match its pinned name and version`);
50
+ }
51
+ return source.manifest;
52
+ }
53
+
54
+ /** Settle each declared bare dependency before installing a hook, using the consumer first and source install second. */
55
+ function consumerDependencyTargets(sources, consumerRoot) {
56
+ const consumer = createRequire(path.join(consumerRoot, 'package.json'));
57
+ for (const source of sources) {
58
+ const manifest = manifestOf(source), local = createRequire(source.manifestFile);
59
+ source.consumerDependencies = new Map();
60
+ for (const name of Object.keys({ ...manifest.peerDependencies, ...manifest.optionalDependencies, ...manifest.dependencies })) {
61
+ for (const require of [consumer, local]) {
62
+ try { source.consumerDependencies.set(name, pathToFileURL(require.resolve(name)).href); break; }
63
+ catch { /* The next install may carry the dependency; a missing import still fails through the native resolver. */ }
64
+ }
65
+ }
66
+ }
67
+ }
68
+
69
+ function resolveSource(specifier, context, nextResolve, sources, consumerRoot) {
70
+ if (!context.parentURL?.startsWith('file:')) return nextResolve(specifier, context);
71
+ const parent = fileURLToPath(context.parentURL);
72
+ const owner = sources.find(source => insidePath(source.root, parent, { key: pathKey }));
73
+ const target = sources.find(source => source.name === packageNameOf(specifier));
74
+ if (!target) {
75
+ const dependency = owner?.consumerDependencies?.get(specifier);
76
+ return dependency ? { url: dependency, shortCircuit: true } : nextResolve(specifier, context);
77
+ }
78
+ if (!owner && (!consumerRoot || !insidePath(consumerRoot, parent, { key: pathKey }))) return nextResolve(specifier, context);
79
+ const manifest = manifestOf(target);
80
+ if (owner) {
81
+ const caller = manifestOf(owner);
82
+ const dependency = caller.dependencies?.[target.name] ?? caller.devDependencies?.[target.name] ?? caller.optionalDependencies?.[target.name];
83
+ if (owner !== target && dependency !== manifest.version) throw new Error(`${owner.name} must declare its source dependency ${target.name}@${manifest.version} exactly`);
84
+ }
85
+ const subpath = `.${specifier.slice(target.name.length)}`;
86
+ const published = exportedTarget(manifest.exports, subpath, context.conditions);
87
+ if (typeof published !== 'string' || !published.startsWith('./')) throw new Error(`${specifier} is not a published source-package export`);
88
+ const resolved = path.resolve(target.root, published);
89
+ if (!insidePath(target.root, resolved, { key: pathKey }) || published.split('/').includes('node_modules')) throw new Error(`${specifier} has an invalid source-package export target`);
90
+ return { url: pathToFileURL(resolved).href, shortCircuit: true };
91
+ }
92
+
93
+ /**
94
+ * Await source imports with pinned local packages resolved through their own public exports.
95
+ * Imports originating inside a declared source package obey exact dependency versions and public exports.
96
+ * An explicit consumerRoot also redirects that consumer's selected source-package imports and resolves source
97
+ * packages' declared bare imports and requires from the consumer install first. This keeps a parser and its
98
+ * typed rules on the same dependency instances. The hook is removed on success or failure; callers await
99
+ * every import and dependent action. Normal installed-package resolution remains in force afterward.
100
+ * @param {{ root: string, packages: Record<string, { version: string, source?: string }>, consumerRoot?: string }} input - Canonical source pins and an optional bounded consumer source root.
101
+ * @param {() => Promise<unknown>} action - Imports whose package contracts are being checked.
102
+ * @returns {Promise<unknown>} The action's result, without a persistent resolver change.
103
+ */
104
+ export async function withSourcePackageImports({ root, packages, consumerRoot }, action) {
105
+ const sources = sourcePackages(realPath(root), packages);
106
+ const consumer = consumerRoot === undefined ? null : realPath(consumerRoot);
107
+ if (consumer) consumerDependencyTargets(sources, consumer);
108
+ const hook = registerHooks({ resolve: (specifier, context, nextResolve) => resolveSource(specifier, context, nextResolve, sources, consumer) });
109
+ try { return await action(); }
110
+ finally { hook.deregister(); }
111
+ }
@@ -2,7 +2,7 @@
2
2
  // "this is KNOWLEDGE, not golden rules" - the runtime catches these from knowledge and gates, never from owner feedback).
3
3
  //
4
4
  // DRAW_NESTED_VARIANT knowledge/ui/proof/anatomy-source.yaml ANATOMY-2. The layer chain is page Background ->
5
- // Surface -> nested control: every form control ON a surface (a SurfaceCard of any frame, a
5
+ // Surface -> nested control: every form control ON a painted surface (a SurfaceCard, a
6
6
  // card, a dialog / drawer panel) carries HeroUI's nested `secondary` variant - the choice
7
7
  // controls (Checkbox, RadioGroup, ...) as much as the text fields (case-1); a form region
8
8
  // (fields plus a primary submit) stands in a Surface, never directly on the Background
@@ -39,9 +39,9 @@ const VARIANT_CONTROLS = Object.freeze(['Input', 'Textarea', 'Select', 'ComboBox
39
39
  /** Every grammar field control (the variant ones plus those without a vendor variant): what makes a region a form. */
40
40
  const FIELD_CONTROLS = Object.freeze([...VARIANT_CONTROLS, 'Switch', 'Slider', 'FileDropzone']);
41
41
  /** Components that paint a surface of their own. */
42
- const SURFACE_COMPONENTS = Object.freeze(['SurfaceCard', 'SurfaceListCard', 'SurfaceAccordionCard', 'Rail', 'Sidebar', 'Dialog', 'Drawer', 'AlertDialog', 'Popover']);
42
+ const SURFACE_COMPONENTS = Object.freeze(['SurfaceCard', 'SurfaceListCard', 'Sidebar', 'Dialog', 'Drawer', 'AlertDialog', 'Popover']);
43
43
  /** Boolean surface-owner hooks emitted by Grammar compositions. */
44
- const SURFACE_STAMPS = Object.freeze(['data-grammar-surface', 'data-grammar-surface-card', 'data-grammar-surface-list', 'data-grammar-surface-accordion-card', 'data-grammar-rail']);
44
+ const SURFACE_STAMPS = Object.freeze(['data-grammar-surface', 'data-grammar-surface-card', 'data-grammar-surface-list', 'data-grammar-surface-accordion-card']);
45
45
  /** The vendor class prefixes that carry the layer variant (`<prefix>--primary|secondary`). */
46
46
  const VARIANT_CLASS_PREFIXES = Object.freeze(['input', 'input-group', 'textfield', 'textarea', 'select', 'combo-box', 'search-field', 'number-field',
47
47
  'date-input-group', 'date-field', 'time-field', 'date-picker', 'checkbox', 'checkbox-group', 'radio-group', 'input-otp']);
@@ -49,8 +49,9 @@ const VARIANT_CLASS_PREFIXES = Object.freeze(['input', 'input-group', 'textfield
49
49
  const VARIANT_CLASS = new RegExp(`^(?:${VARIANT_CLASS_PREFIXES.join('|')})--(primary|secondary)$`);
50
50
  const componentOf = (attrs) => attrs?.['data-component'] ?? attrs?.['data-grammar-component'] ?? null;
51
51
 
52
- /** Whether one parsed/DOM element establishes a surface nesting boundary, including frameless compositions. */
52
+ /** Whether one parsed/DOM element paints a surface; unpainted layout containers inherit their parent. */
53
53
  function isSurface({ component, attrs, classes }) {
54
+ if (attrs['data-grammar-frame'] === 'frameless') return false;
54
55
  if (SURFACE_COMPONENTS.includes(component)) return true;
55
56
  if (attrs['data-grammar-nesting'] === 'surface') return true;
56
57
  if (SURFACE_STAMPS.some((stamp) => attrs[stamp] === 'true')) return true;
package/scaffold/app.mjs CHANGED
@@ -33,6 +33,7 @@ import { init as gitInit } from '../runtime/scripts/api/git/init.mjs';
33
33
  import { insidePath, samePath } from '../runtime/scripts/lib/path-key.mjs';
34
34
  import { byCodeUnit } from '../report/order.mjs';
35
35
  import { sonarGateName } from '../sync/sonar-key.mjs';
36
+ import { grammarLintFor } from '../runtime/scripts/lib/grammar-lint.mjs';
36
37
 
37
38
  const NAME = /^[a-z][a-z0-9-]*$/;
38
39
  const APP_DIR = '__app__';
@@ -126,7 +127,7 @@ export const packageJsonText = value => `${JSON.stringify(value, null, 2)}\n`;
126
127
  const STARTER_SIDES = Object.freeze({
127
128
  be: Object.freeze({ apps: [{ name: 'core', kind: 'api' }, { name: 'cli', kind: 'cli' }], kinds: ['api', 'cli'], connections: [{ name: 'primary', envPrefix: 'PRIMARY_DB', owner: 'core', isolation: 'database' }] }),
128
129
  // fe: the landing and the product app, over the shared ui and i18n packages (both opt-in slots, enabled here).
129
- fe: Object.freeze({ apps: [{ name: 'landing', kind: 'next' }, { name: 'app', kind: 'next' }], reads: ['be/contracts/'], optionalSlots: ['fe.package.ui', 'fe.package.i18n'] }),
130
+ fe: Object.freeze({ grammar: 'starci', apps: [{ name: 'landing', kind: 'next' }, { name: 'app', kind: 'next' }], reads: ['be/contracts/'], optionalSlots: ['fe.package.ui', 'fe.package.i18n'] }),
130
131
  });
131
132
 
132
133
  /** The lite starter is the upgrade-safe subset: one API, one web app and one Supabase schema authority. */
@@ -137,7 +138,7 @@ const LITE_STARTER_SIDES = Object.freeze({
137
138
  connections: [{ name: 'primary', envPrefix: 'PRIMARY_DB', owner: 'api', isolation: 'schema', provider: 'supabase' }],
138
139
  reads: ['supabase/types/'],
139
140
  }),
140
- fe: Object.freeze({ apps: [{ name: 'web', kind: 'next' }], reads: ['be/contracts/', 'supabase/types/'] }),
141
+ fe: Object.freeze({ grammar: 'starci', apps: [{ name: 'web', kind: 'next' }], reads: ['be/contracts/', 'supabase/types/'] }),
141
142
  });
142
143
 
143
144
  /** The checked local Supabase auth posture of every newly scaffolded lite app. */
@@ -313,9 +314,10 @@ const ROOT_OVERRIDES = Object.freeze(['@graphql-tools/utils', '@xyflow/react', '
313
314
  * The root package.json of a new app, the monorepo root: name, workspaces, npm, its edition's dependencies with their runtime
314
315
  * peers, the fe stack's root tools and the one-version overrides, at canon pins.
315
316
  */
316
- function packageManifest(name, pins, edition, stack = coreFeStackPackages()) {
317
+ function packageManifest(name, pins, edition, grammar, stack = coreFeStackPackages()) {
317
318
  const dependencies = edition === 'lite' ? LITE_STARTER_DEPENDENCIES : STARTER_DEPENDENCIES;
318
- const devDependencies = { ...dependencies.devDependencies, ...Object.fromEntries(stack.root.map(dependency => [dependency, null])) };
319
+ const grammarLint = grammarLintFor(grammar);
320
+ const devDependencies = { ...dependencies.devDependencies, ...Object.fromEntries(stack.root.map(dependency => [dependency, null])), ...(grammarLint.package ? { [grammarLint.package]: null } : {}) };
319
321
  const pkg = { name, version: '0.0.0', private: true, packageManager: PACKAGE_MANAGER, workspaces: [...WORKSPACES], dependencies: pinnedSection(dependencies.dependencies, pins), devDependencies: pinnedSection(devDependencies, pins) };
320
322
  pkg.dependencies = pinnedSection({ ...pkg.dependencies, ...peerDependencies(pkg, pins) }, pins);
321
323
  pkg.overrides = pinnedSection(Object.fromEntries(ROOT_OVERRIDES.map(dependency => [dependency, null])), pins);
@@ -667,7 +669,7 @@ export function scaffoldApp({ name, into, presets, edition = 'full', authCallbac
667
669
  const declaration = starterDeclaration(name, manifest, edition);
668
670
  if (authCallback) enableAuthCallback(declaration);
669
671
  const app = resolveRepoDeclaration(manifest, declaration);
670
- const pkg = packageManifest(name, pins, app.edition);
672
+ const pkg = packageManifest(name, pins, app.edition, app.sides.fe.grammar);
671
673
  const timestamp = now().toISOString().replace(/\D/g, '').slice(0, 14);
672
674
  const files = [
673
675
  { path: 'hfs.json', content: jsonText(declaration) },
package/src/main.mjs CHANGED
@@ -10,6 +10,8 @@
10
10
  // --fast, and a repository without prettier is a refusal); soft-size backlog (report only); then the whole
11
11
  // architecture machine (tiers, owners, clones, dead exports, module registration, the
12
12
  // front-end and back-end source rules), each violation a finding with its why.
13
+ // ESLint executes once per side through the installed managed config; the FE config composes
14
+ // the universal canon and the explicitly selected Grammar-family canon.
13
15
  // --fast: only what changed since the merge-base with origin/main (else main; --base
14
16
  // overrides it); the machine
15
17
  // runs on those owners without clones and dead exports. No merge-base is a refusal
@@ -60,7 +62,7 @@ import { createRequire } from 'node:module';
60
62
  import path from 'node:path';
61
63
  import { fileURLToPath, pathToFileURL } from 'node:url';
62
64
  import { isMain } from '../runtime/scripts/lib/is-main.mjs';
63
- import { checkDatabase, checkRepository, explainPath, trackedFiles, withCheckProofs } from '../runtime/scripts/hfs/check.mjs';
65
+ import { changedSince, checkDatabase, checkRepository, explainPath, trackedFiles, withCheckProofs } from '../runtime/scripts/hfs/check.mjs';
64
66
  import { secretMain } from '../runtime/scripts/hfs/secret.mjs';
65
67
  import { dbBaselineMain } from '../runtime/scripts/hfs/db-baseline-verb.mjs';
66
68
  import { dbRestampMain } from '../runtime/scripts/hfs/db-restamp-verb.mjs';
@@ -79,7 +81,7 @@ import { addKind } from '../scaffold/add.mjs';
79
81
  import { scaffoldApp } from '../scaffold/app.mjs';
80
82
  import { newImages } from '../scaffold/image.mjs';
81
83
  import { contractEmitFindings } from '../runtime/scripts/hfs/rules/contract.mjs';
82
- import { lintRepository, parseLintArgs, printLintText } from '../lint/run.mjs';
84
+ import { eslintRepository, lintRepository, parseLintArgs, printLintText } from '../lint/run.mjs';
83
85
  import { writeReport } from '../report/sonar.mjs';
84
86
  import { checkEdition } from '../upgrade/check.mjs';
85
87
  import { UpgradeError, upgradeMain } from '../upgrade/index.mjs';
@@ -187,11 +189,17 @@ function printCheckOverview(result, out) {
187
189
  if (result.fast) out(` ${result.fast.changed} path${result.fast.changed === 1 ? '' : 's'} changed since ${result.fast.base.slice(0, 12)}\n`);
188
190
  out(` architecture machine: ${machineDescription(result.machine)}\n`);
189
191
  if (result.contracts) out(` contract snapshots: ${contractDescription(result.contracts)}\n`);
192
+ if (result.eslint) {
193
+ const sides = Object.entries(result.eslint.sides).map(([side, run]) => `${side}: ${run.files} files${run.skipped ? ` (${run.skipped})` : ''}`).join(', ');
194
+ out(` ESLint: ${sides}\n`);
195
+ for (const error of result.eslint.errors) out(` ESLint tool error: ${error}\n`);
196
+ }
190
197
  }
191
198
 
192
199
  function printFindingGroup(code, list, out) {
193
200
  const first = list[0];
194
- out(`\n[${first.level.toUpperCase()}] ${code} x${list.length}: ${first.titleVi}\n ${first.whyVi}\n -> ${first.nextStepVi}\n`);
201
+ if (first.engine === 'eslint') out(`\n[${first.level.toUpperCase()}] ${code} x${list.length}\n`);
202
+ else out(`\n[${first.level.toUpperCase()}] ${code} x${list.length}: ${first.titleVi}\n ${first.whyVi}\n -> ${first.nextStepVi}\n`);
195
203
  for (const finding of list.slice(0, PER_CODE_LIMIT)) out(` - ${finding.message}\n`);
196
204
  if (list.length > PER_CODE_LIMIT) out(` ... and ${list.length - PER_CODE_LIMIT} more (use --json for all)\n`);
197
205
  }
@@ -264,7 +272,7 @@ export function appRootAbove(dir) {
264
272
  }
265
273
 
266
274
  /** `starci app lint`: ESLint per side, the app checks and stylelint over the fe side as one report; see lint/run.mjs. */
267
- async function lintMain(argv, { cwd, stdout, presets, prettier }) {
275
+ async function lintMain(argv, { cwd, stdout, presets, prettier, runLinter, checkApp = runCheck }) {
268
276
  const opts = parseLintArgs(argv);
269
277
  // --workspace names a folder from the working directory (`starci app lint --workspace .` is an fe workspace script); the app
270
278
  // root is then the nearest folder above it that holds hfs.json, and the workspace is passed on app-relative.
@@ -272,8 +280,8 @@ async function lintMain(argv, { cwd, stdout, presets, prettier }) {
272
280
  const repoRoot = path.resolve(workspaceDir ? appRootAbove(workspaceDir) : cwd);
273
281
  if (workspaceDir) opts.workspace = path.relative(repoRoot, workspaceDir).split(path.sep).join('/');
274
282
  const { report, sonar, exit } = await lintRepository({
275
- repoRoot, opts, trackedFiles,
276
- hfsCheck: (root) => runCheck({ repoRoot: root, only: opts.changed ?? undefined, presets, prettier }),
283
+ repoRoot, opts, trackedFiles, runLinter,
284
+ hfsCheck: (root) => checkApp({ repoRoot: root, only: opts.changed ?? undefined, presets, prettier }),
277
285
  });
278
286
  if (opts.sonar !== undefined) writeReport(path.resolve(cwd, opts.sonar), sonar);
279
287
  if (opts.format === 'json') stdout(`${JSON.stringify(report, null, 2)}
@@ -336,14 +344,19 @@ function printCheckProofs(result, out) {
336
344
  if (result.coverage) out(formatCoverageReport(result.coverage));
337
345
  }
338
346
 
339
- async function checkMain(opts, { cwd: repoRoot, edition, stdout: out, presets, prettier }) {
347
+ async function checkMain(opts, { cwd: repoRoot, edition, stdout: out, presets, prettier, checkApp = runCheck, runLinter }) {
340
348
  if (opts.positional.length) throw new Error('starci app check takes no path');
341
349
  if (opts.base !== undefined && opts.fast !== true) throw new Error('--base names the ref --fast compares with; it needs --fast');
342
- const checked = await checkEdition({ repoRoot, edition, normal: () => runCheck({ repoRoot, fast: opts.fast === true, base: opts.base, presets, prettier, dbTypes: opts['db-types'] === true }), presets, prettier });
343
- const result = withCheckProofs(checked, { repoRoot, edition, wiring: opts.wiring === true, coverageFrom: opts['coverage-from'] });
350
+ const checked = await checkEdition({ repoRoot, edition, normal: () => checkApp({ repoRoot, fast: opts.fast === true, base: opts.base, presets, prettier, dbTypes: opts['db-types'] === true }), presets, prettier });
351
+ const proof = withCheckProofs(checked, { repoRoot, edition, wiring: opts.wiring === true, coverageFrom: opts['coverage-from'] });
352
+ const changed = opts.fast === true ? changedSince(repoRoot, proof.fast?.base ?? opts.base).files : null;
353
+ const existing = changed ? changed.filter(file => fs.existsSync(path.join(repoRoot, file))) : [];
354
+ const eslint = await eslintRepository({ repoRoot, changed, existing, run: runLinter });
355
+ const findings = [...proof.findings, ...eslint.findings.map(finding => ({ ...finding, code: finding.code ?? finding.rule, level: 'error' }))];
356
+ const result = { ...proof, findings, counts: { ...proof.counts, error: proof.counts.error + eslint.findings.length }, eslint: { ...eslint.engine, errors: eslint.errors }, ok: proof.ok && eslint.findings.length === 0 && eslint.errors.length === 0 };
344
357
  if (opts.json) out(`${JSON.stringify(result, null, 2)}\n`);
345
358
  else { printCheck(result, out); printCheckProofs(result, out); }
346
- return result.ok ? 0 : 1;
359
+ return eslint.errors.length ? 2 : result.ok ? 0 : 1;
347
360
  }
348
361
 
349
362
  function emitMain(opts, { cwd: repoRoot, stdout: out, run }) {
@@ -434,10 +447,10 @@ function errorMessage(error) {
434
447
  return `starci app: ${error.message}\n${USAGE}`;
435
448
  }
436
449
 
437
- /** `run` injects the Supabase command runner and `stackIo` the spawn, fetch, sleep and clock seams of the Swarm tooling; IO and tool seams keep every verb independently testable. */
450
+ /** `run` injects the Supabase runner, `runLinter` the linter transport, `checkApp` the HFS check and `stackIo` Swarm tools; production defaults execute every check. */
438
451
  export async function main(argv, {
439
452
  cwd: baseCwd = process.cwd(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), presets, prettier, lock, run, stackMain,
440
- env = process.env, stdin, sops, tools, platform, stackIo,
453
+ env = process.env, stdin, sops, tools, platform, stackIo, runLinter, checkApp,
441
454
  } = {}) {
442
455
  try {
443
456
  const global = globalOptions(argv, path.resolve(baseCwd));
@@ -445,7 +458,7 @@ export async function main(argv, {
445
458
  const out = global.quiet ? () => {} : stdout;
446
459
  if (global.help) { out(USAGE); return 0; }
447
460
  if (!VERBS.includes(verb)) { stderr(USAGE); return 2; }
448
- return await dispatchMain(verb, rest, { cwd: global.cwd, edition: global.edition, stdout: out, stderr, presets, prettier, lock, run, stackMain, env, stdin, sops, tools, platform, stackIo });
461
+ return await dispatchMain(verb, rest, { cwd: global.cwd, edition: global.edition, stdout: out, stderr, presets, prettier, lock, run, stackMain, env, stdin, sops, tools, platform, stackIo, runLinter, checkApp });
449
462
  } catch (error) {
450
463
  stderr(errorMessage(error));
451
464
  return 2;