@starci/hfs 4.7.33 → 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.
- package/CHANGELOG.md +8 -1
- package/README.md +8 -11
- package/lint/run.mjs +70 -29
- package/package.json +2 -2
- package/runtime/knowledge/grammars/index.yaml +102 -0
- package/runtime/knowledge/grammars/offset-pop/playbook.yaml +302 -0
- package/runtime/knowledge/grammars/starci/playbook.yaml +1159 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +22 -8
- package/runtime/knowledge/hfs/fe-stack.yaml +7 -0
- package/runtime/knowledge/hfs/rules.yaml +133 -83
- package/runtime/knowledge/hfs/slots.yaml +3 -3
- package/runtime/knowledge/ui/presentation/font.yaml +101 -0
- package/runtime/knowledge/ui/presentation/padding.yaml +216 -0
- package/runtime/modules/kernel/failure-codes.yaml +40 -20
- package/runtime/modules/kernel/removed-vocabulary.yaml +29 -0
- package/runtime/packages/cli/src/catalog.generated.mjs +7 -7
- package/runtime/scripts/gates/canon-pins.mjs +103 -0
- package/runtime/scripts/gates/gate.mjs +3 -36
- package/runtime/scripts/gates/output.mjs +40 -0
- package/runtime/scripts/gates/test-world-run.mjs +2 -4
- package/runtime/scripts/hfs/architecture/config.mjs +8 -8
- package/runtime/scripts/hfs/check.mjs +1 -1
- package/runtime/scripts/hfs/db-restamp-verb.mjs +2 -2
- package/runtime/scripts/hfs/declaration-shape.mjs +8 -2
- package/runtime/scripts/hfs/grammar-presentation.mjs +152 -0
- package/runtime/scripts/hfs/rule-catalog.mjs +2 -2
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -0
- package/runtime/scripts/hfs/rules/fe-stack.mjs +44 -1
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/hfs/view.mjs +1 -0
- package/runtime/scripts/lib/canon-binding-text.mjs +129 -0
- package/runtime/scripts/lib/example-refs.mjs +1 -1
- package/runtime/scripts/lib/grammar-lint.mjs +48 -0
- package/runtime/scripts/lib/grammar-vendor-parity.mjs +31 -111
- package/runtime/scripts/lib/grammar-vendor-source.mjs +345 -0
- package/runtime/scripts/lib/numeric-match.mjs +5 -0
- package/runtime/scripts/lib/source-package-imports.mjs +111 -0
- package/runtime/scripts/work/draw/draw-layer.mjs +5 -4
- package/scaffold/app.mjs +7 -5
- package/src/main.mjs +26 -13
- package/stack/config.mjs +2 -1
- package/stack/render.mjs +24 -3
- package/sync/index.mjs +8 -2
- package/templates/app/skeleton/.starcistacks/dev/infra/compose/stack.yaml +0 -1
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/stack.yaml +0 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +0 -1
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +0 -1
- 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,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
|
|
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', '
|
|
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'
|
|
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
|
|
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
|
|
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}
|
|
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) =>
|
|
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: () =>
|
|
343
|
-
const
|
|
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
|
|
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;
|