@starci/hfs 4.7.34 → 4.7.36
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 +13 -0
- package/README.md +8 -11
- package/codemod/button-intent.mjs +356 -0
- package/lint/run.mjs +70 -29
- package/package.json +3 -2
- package/runtime/knowledge/grammars/index.yaml +111 -0
- package/runtime/knowledge/grammars/offset-pop/playbook.yaml +302 -0
- package/runtime/knowledge/grammars/starci/playbook.yaml +1190 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +22 -8
- package/runtime/knowledge/hfs/fe-stack.yaml +12 -4
- package/runtime/knowledge/hfs/rules.yaml +163 -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 +217 -0
- package/runtime/modules/kernel/failure-codes.yaml +102 -20
- package/runtime/modules/kernel/removed-vocabulary.yaml +29 -0
- package/runtime/modules/kernel/roles.yaml +1 -1
- package/runtime/packages/cli/src/catalog.generated.mjs +119 -9
- 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/guards/release-definition.mjs +22 -3
- 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/css-rules.mjs +18 -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-jsx.mjs +360 -0
- package/runtime/scripts/lib/grammar-vendor-parity.mjs +110 -157
- package/runtime/scripts/lib/numeric-match.mjs +5 -0
- package/runtime/scripts/lib/release-version.mjs +119 -0
- package/runtime/scripts/lib/runtime-publication-tags.mjs +3 -46
- 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 +30 -15
- 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,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';
|
|
@@ -89,13 +91,15 @@ import { dbMigrateMain } from '../db/migrate.mjs';
|
|
|
89
91
|
import { dbPostCutoverMain } from '../db/post-cutover.mjs';
|
|
90
92
|
import { dbDumpMain } from '../db/dump.mjs';
|
|
91
93
|
import { dbResetMain } from '../db/reset.mjs';
|
|
94
|
+
import { codemodMain } from '../codemod/button-intent.mjs';
|
|
92
95
|
|
|
93
|
-
export const VERBS = Object.freeze(['scaffold', 'add', 'lint', 'sync', 'check', 'upgrade', 'stack', 'release', 'explain', 'emit', 'new', 'secret', 'ci', 'db', 'hygiene', 'coverage']);
|
|
96
|
+
export const VERBS = Object.freeze(['scaffold', 'add', 'lint', 'sync', 'check', 'upgrade', 'stack', 'release', 'explain', 'emit', 'new', 'secret', 'ci', 'db', 'hygiene', 'coverage', 'codemod']);
|
|
94
97
|
const DATABASE_VERBS = new Map([['baseline', dbBaselineMain], ['restamp', dbRestampMain], ['plan', dbPlanMain], ['migrate', dbMigrateMain], ['post-cutover', dbPostCutoverMain], ['dump', dbDumpMain], ['reset', dbResetMain]]);
|
|
95
98
|
|
|
96
99
|
const USAGE = `starci app scaffold <name> [--into <dir>] [--edition full|lite] [--registry-visibility public|private] [--cwd <dir>]
|
|
97
100
|
starci app add <api|webhook|realtime|saga|job|reactor|queue|projection|cli|table|app> <name> [options] [--cwd <dir>]
|
|
98
101
|
starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--format text|json] [--sonar <file>]
|
|
102
|
+
starci app codemod button-intent [--write] [--format json|markdown] [--report <file.json|file.md>] [--cwd <dir>]
|
|
99
103
|
starci app sync (--check | --write) [--cwd <dir>]
|
|
100
104
|
starci app check [--cwd <dir>] [--json] [--fast] [--base <ref>] [--db-types] [--wiring] [--coverage-from <lcov>]
|
|
101
105
|
starci app db baseline [--stamp <version>] [--local] [--apply] [--cwd <dir>]
|
|
@@ -187,11 +191,17 @@ function printCheckOverview(result, out) {
|
|
|
187
191
|
if (result.fast) out(` ${result.fast.changed} path${result.fast.changed === 1 ? '' : 's'} changed since ${result.fast.base.slice(0, 12)}\n`);
|
|
188
192
|
out(` architecture machine: ${machineDescription(result.machine)}\n`);
|
|
189
193
|
if (result.contracts) out(` contract snapshots: ${contractDescription(result.contracts)}\n`);
|
|
194
|
+
if (result.eslint) {
|
|
195
|
+
const sides = Object.entries(result.eslint.sides).map(([side, run]) => `${side}: ${run.files} files${run.skipped ? ` (${run.skipped})` : ''}`).join(', ');
|
|
196
|
+
out(` ESLint: ${sides}\n`);
|
|
197
|
+
for (const error of result.eslint.errors) out(` ESLint tool error: ${error}\n`);
|
|
198
|
+
}
|
|
190
199
|
}
|
|
191
200
|
|
|
192
201
|
function printFindingGroup(code, list, out) {
|
|
193
202
|
const first = list[0];
|
|
194
|
-
out(`\n[${first.level.toUpperCase()}] ${code} x${list.length}
|
|
203
|
+
if (first.engine === 'eslint') out(`\n[${first.level.toUpperCase()}] ${code} x${list.length}\n`);
|
|
204
|
+
else out(`\n[${first.level.toUpperCase()}] ${code} x${list.length}: ${first.titleVi}\n ${first.whyVi}\n -> ${first.nextStepVi}\n`);
|
|
195
205
|
for (const finding of list.slice(0, PER_CODE_LIMIT)) out(` - ${finding.message}\n`);
|
|
196
206
|
if (list.length > PER_CODE_LIMIT) out(` ... and ${list.length - PER_CODE_LIMIT} more (use --json for all)\n`);
|
|
197
207
|
}
|
|
@@ -264,7 +274,7 @@ export function appRootAbove(dir) {
|
|
|
264
274
|
}
|
|
265
275
|
|
|
266
276
|
/** `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 }) {
|
|
277
|
+
async function lintMain(argv, { cwd, stdout, presets, prettier, runLinter, checkApp = runCheck }) {
|
|
268
278
|
const opts = parseLintArgs(argv);
|
|
269
279
|
// --workspace names a folder from the working directory (`starci app lint --workspace .` is an fe workspace script); the app
|
|
270
280
|
// root is then the nearest folder above it that holds hfs.json, and the workspace is passed on app-relative.
|
|
@@ -272,8 +282,8 @@ async function lintMain(argv, { cwd, stdout, presets, prettier }) {
|
|
|
272
282
|
const repoRoot = path.resolve(workspaceDir ? appRootAbove(workspaceDir) : cwd);
|
|
273
283
|
if (workspaceDir) opts.workspace = path.relative(repoRoot, workspaceDir).split(path.sep).join('/');
|
|
274
284
|
const { report, sonar, exit } = await lintRepository({
|
|
275
|
-
repoRoot, opts, trackedFiles,
|
|
276
|
-
hfsCheck: (root) =>
|
|
285
|
+
repoRoot, opts, trackedFiles, runLinter,
|
|
286
|
+
hfsCheck: (root) => checkApp({ repoRoot: root, only: opts.changed ?? undefined, presets, prettier }),
|
|
277
287
|
});
|
|
278
288
|
if (opts.sonar !== undefined) writeReport(path.resolve(cwd, opts.sonar), sonar);
|
|
279
289
|
if (opts.format === 'json') stdout(`${JSON.stringify(report, null, 2)}
|
|
@@ -336,14 +346,19 @@ function printCheckProofs(result, out) {
|
|
|
336
346
|
if (result.coverage) out(formatCoverageReport(result.coverage));
|
|
337
347
|
}
|
|
338
348
|
|
|
339
|
-
async function checkMain(opts, { cwd: repoRoot, edition, stdout: out, presets, prettier }) {
|
|
349
|
+
async function checkMain(opts, { cwd: repoRoot, edition, stdout: out, presets, prettier, checkApp = runCheck, runLinter }) {
|
|
340
350
|
if (opts.positional.length) throw new Error('starci app check takes no path');
|
|
341
351
|
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
|
|
352
|
+
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 });
|
|
353
|
+
const proof = withCheckProofs(checked, { repoRoot, edition, wiring: opts.wiring === true, coverageFrom: opts['coverage-from'] });
|
|
354
|
+
const changed = opts.fast === true ? changedSince(repoRoot, proof.fast?.base ?? opts.base).files : null;
|
|
355
|
+
const existing = changed ? changed.filter(file => fs.existsSync(path.join(repoRoot, file))) : [];
|
|
356
|
+
const eslint = await eslintRepository({ repoRoot, changed, existing, run: runLinter });
|
|
357
|
+
const findings = [...proof.findings, ...eslint.findings.map(finding => ({ ...finding, code: finding.code ?? finding.rule, level: 'error' }))];
|
|
358
|
+
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
359
|
if (opts.json) out(`${JSON.stringify(result, null, 2)}\n`);
|
|
345
360
|
else { printCheck(result, out); printCheckProofs(result, out); }
|
|
346
|
-
return result.ok ? 0 : 1;
|
|
361
|
+
return eslint.errors.length ? 2 : result.ok ? 0 : 1;
|
|
347
362
|
}
|
|
348
363
|
|
|
349
364
|
function emitMain(opts, { cwd: repoRoot, stdout: out, run }) {
|
|
@@ -418,7 +433,7 @@ async function stackAppMain(argv, { cwd, edition, stackMain, stdout: out, stderr
|
|
|
418
433
|
return run(argv, { cwd, out, err });
|
|
419
434
|
}
|
|
420
435
|
|
|
421
|
-
const DIRECT_VERBS = new Map([['ci', async (argv, io) => (await import('../ci/cli.mjs')).ciMain(argv, io)], ['lint', lintMain], ['secret', secretMain], ['db', databaseMain], ['upgrade', upgradeAppMain], ['stack', stackAppMain], ['release', releaseMain], ['coverage', async (argv, io) => (await import('../coverage/cli.mjs')).coverageMain(argv, io)]]);
|
|
436
|
+
const DIRECT_VERBS = new Map([['ci', async (argv, io) => (await import('../ci/cli.mjs')).ciMain(argv, io)], ['lint', lintMain], ['secret', secretMain], ['db', databaseMain], ['upgrade', upgradeAppMain], ['stack', stackAppMain], ['release', releaseMain], ['coverage', async (argv, io) => (await import('../coverage/cli.mjs')).coverageMain(argv, io)], ['codemod', codemodMain]]);
|
|
422
437
|
const PARSED_VERBS = new Map([['check', checkMain], ['emit', emitMain], ['new', newMain], ['add', addMain], ['scaffold', scaffoldMain], ['explain', explainMain]]);
|
|
423
438
|
|
|
424
439
|
function dispatchMain(verb, argv, io) {
|
|
@@ -434,10 +449,10 @@ function errorMessage(error) {
|
|
|
434
449
|
return `starci app: ${error.message}\n${USAGE}`;
|
|
435
450
|
}
|
|
436
451
|
|
|
437
|
-
/** `run` injects the Supabase
|
|
452
|
+
/** `run` injects the Supabase runner, `runLinter` the linter transport, `checkApp` the HFS check and `stackIo` Swarm tools; production defaults execute every check. */
|
|
438
453
|
export async function main(argv, {
|
|
439
454
|
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,
|
|
455
|
+
env = process.env, stdin, sops, tools, platform, stackIo, runLinter, checkApp,
|
|
441
456
|
} = {}) {
|
|
442
457
|
try {
|
|
443
458
|
const global = globalOptions(argv, path.resolve(baseCwd));
|
|
@@ -445,7 +460,7 @@ export async function main(argv, {
|
|
|
445
460
|
const out = global.quiet ? () => {} : stdout;
|
|
446
461
|
if (global.help) { out(USAGE); return 0; }
|
|
447
462
|
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 });
|
|
463
|
+
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
464
|
} catch (error) {
|
|
450
465
|
stderr(errorMessage(error));
|
|
451
466
|
return 2;
|
package/stack/config.mjs
CHANGED
|
@@ -9,7 +9,8 @@ import { refusal } from './errors.mjs';
|
|
|
9
9
|
export const STACKS_DIR = '.starcistacks';
|
|
10
10
|
export const STACKS_FILE = 'application-stacks.yaml';
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
/** Read a YAML input, refusing with a stack error naming its purpose when it is missing or invalid. */
|
|
13
|
+
export const readYaml = (file, what) => {
|
|
13
14
|
let source;
|
|
14
15
|
try { source = fs.readFileSync(file, 'utf8'); } catch { throw refusal('STACKS_DEPLOY_FILE_MISSING', `${what} (${file}) is not readable`); }
|
|
15
16
|
try { return parseYaml(source); } catch { throw refusal('STACKS_DEPLOY_FILE_INVALID', `${what} is not valid YAML`); }
|
package/stack/render.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// render.mjs - renders a release: the committed stack source plus a generated override (image references, plain environment, the
|
|
2
2
|
// `<KEY>_FILE` pointers and the versioned secret grants) resolved by `docker stack config`. Writes `stack.generated.yaml`, `stack.yaml`
|
|
3
3
|
// and `release.json` under the output directory, and nothing at all when a guard refuses. Output carries names and counts only; the
|
|
4
|
-
// docker child gets a minimal environment, never the age identity or DOCKER_HOST.
|
|
4
|
+
// docker child gets a minimal environment, never the age identity or DOCKER_HOST. The override mirrors the source's compose version:
|
|
5
|
+
// Docker's legacy stack loader refuses mixed versions across its input files.
|
|
5
6
|
import fs from 'node:fs';
|
|
6
7
|
import os from 'node:os';
|
|
7
8
|
import path from 'node:path';
|
|
9
|
+
import { readYaml } from './config.mjs';
|
|
8
10
|
import { tool } from './context.mjs';
|
|
9
11
|
import { hasPlaceholderDigest, isImageReference, isReleaseId } from './digest.mjs';
|
|
10
12
|
import { failure, refusal } from './errors.mjs';
|
|
@@ -35,6 +37,22 @@ function checkInputs(config, options) {
|
|
|
35
37
|
if (options.previous !== null && options.previous !== undefined && !isReleaseId(options.previous)) throw refusal('STACK_PREVIOUS_INVALID', 'previous must be 16 lowercase hex characters');
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
/** Docker reads an absent, empty or 3 version as its current 3.x default; every other version must match exactly. */
|
|
41
|
+
const versionKey = (version) => {
|
|
42
|
+
const text = version === undefined ? '' : String(version);
|
|
43
|
+
return text === '' || text === '3' ? null : text;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function checkComposeVersions(sourceVersion, extraCompose) {
|
|
47
|
+
for (const file of extraCompose) {
|
|
48
|
+
const version = readYaml(file, `the extra compose file ${path.basename(file)}`)?.version;
|
|
49
|
+
if (versionKey(version) !== versionKey(sourceVersion)) {
|
|
50
|
+
throw refusal('STACK_COMPOSE_VERSION_MISMATCH',
|
|
51
|
+
`${path.basename(file)} declares compose version ${version ?? '(none)'} but the stack source declares ${sourceVersion ?? '(none)'}; docker stack config refuses files whose versions differ`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
/** Compose interpolation treats `$` specially; the override doubles it so a value reaches the service unchanged. */
|
|
39
57
|
const escapeDollar = (value) => value.replaceAll('$', '$$$$');
|
|
40
58
|
|
|
@@ -70,9 +88,10 @@ function serviceOverride(config, sealed, images, service) {
|
|
|
70
88
|
return out;
|
|
71
89
|
}
|
|
72
90
|
|
|
73
|
-
function buildOverride(config, sealed, images) {
|
|
91
|
+
function buildOverride(config, sealed, images, version) {
|
|
74
92
|
const services = new Set([...Object.keys(config.images), ...Object.keys(config.environment), ...Object.keys(sealed.grants)]);
|
|
75
93
|
return {
|
|
94
|
+
...(version === undefined ? {} : { version }),
|
|
76
95
|
services: Object.fromEntries([...services].sort((a, b) => a.localeCompare(b)).map((service) => [service, serviceOverride(config, sealed, images, service)])),
|
|
77
96
|
secrets: Object.fromEntries(Object.entries(sealed.secrets).map(([key, entry]) => [key, { external: true, name: entry.runtimeName }])),
|
|
78
97
|
};
|
|
@@ -124,9 +143,11 @@ function writeOutput(out, override, stackBytes, release) {
|
|
|
124
143
|
/** Renders a release into `options.out` (a fresh temp directory when null). Effects stay local; nothing leaves the machine. */
|
|
125
144
|
export async function renderRelease(ctx, config, options) {
|
|
126
145
|
checkInputs(config, options);
|
|
146
|
+
const sourceVersion = readYaml(config.stackFile, 'the stack source')?.version;
|
|
147
|
+
checkComposeVersions(sourceVersion, options.extraCompose ?? []);
|
|
127
148
|
const sealed = loadSealed(ctx, config);
|
|
128
149
|
const configs = configObjects(config);
|
|
129
|
-
const override = buildOverride(config, sealed, options.images);
|
|
150
|
+
const override = buildOverride(config, sealed, options.images, sourceVersion);
|
|
130
151
|
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'stack-override-'));
|
|
131
152
|
try {
|
|
132
153
|
const overrideFile = path.join(scratch, 'stack.generated.yaml');
|
package/sync/index.mjs
CHANGED
|
@@ -35,6 +35,7 @@ import { isRuntimeExampleApp } from '../runtime/scripts/lib/runtime-example.mjs'
|
|
|
35
35
|
import { ciWorkflowCommands } from './ci-commands.mjs';
|
|
36
36
|
import { dependabotUpdates, hasDeclaredServices, readDeclaredQuality, readDeclaredBadges, sonarHostLine } from './quality-services.mjs';
|
|
37
37
|
import { qualityServices } from '../runtime/scripts/hfs/ci-declaration.mjs';
|
|
38
|
+
import { grammarLintFor } from '../runtime/scripts/lib/grammar-lint.mjs';
|
|
38
39
|
|
|
39
40
|
export const TEMPLATES_DIR = path.join(import.meta.dirname, '..', 'templates');
|
|
40
41
|
const NODE_MAJOR = 22;
|
|
@@ -133,11 +134,13 @@ function readBundled(name) {
|
|
|
133
134
|
|
|
134
135
|
/**
|
|
135
136
|
* Fill `{{name}}` from vars and expand `{{> partial}}` lines. A partial line may be written as a line comment (`// {{> partial}}`)
|
|
136
|
-
* so a JavaScript template stays valid source before rendering.
|
|
137
|
+
* so a JavaScript template stays valid source before rendering. Variable-only lines may also use a line comment;
|
|
138
|
+
* empty variable-only lines disappear and an unknown name is an error.
|
|
137
139
|
*/
|
|
138
140
|
export function render(text, vars, readTemplate = readBundled) {
|
|
139
141
|
const withPartials = text.replace(/^(?:\/\/ )?\{\{> ([\w./-]+)\}\}\n/gm, (_, name) => trailingNewline(render(readTemplate(name), vars, readTemplate)));
|
|
140
|
-
const presentLines = withPartials.replace(/^[ \t]
|
|
142
|
+
const presentLines = withPartials.replace(/^[ \t]*(?:\/\/ )?\{\{([A-Za-z]\w*)\}\}[ \t]*(?:\n|$)/gm,
|
|
143
|
+
(line, key) => vars[key] === '' ? '' : line.replace(/^(\s*)\/\/ /, '$1'));
|
|
141
144
|
return presentLines.replace(/\{\{([A-Za-z]\w*)\}\}/g, (_, key) => {
|
|
142
145
|
if (!Object.hasOwn(vars, key)) throw new SyncError('HFS_SYNC_TEMPLATE_VARIABLE', `template names {{${key}}}, which sync does not provide`);
|
|
143
146
|
return vars[key];
|
|
@@ -341,6 +344,7 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
|
|
|
341
344
|
const lite = app.edition === 'lite';
|
|
342
345
|
const providers = (app.sides.be.connections ?? []).map(connection => connection.provider);
|
|
343
346
|
const quality = source.quality ?? qualityServices({});
|
|
347
|
+
const grammarLint = scope === 'fe' ? grammarLintFor(fe.grammar) : null;
|
|
344
348
|
return {
|
|
345
349
|
header: HEADER(scope),
|
|
346
350
|
readmeBadges: source.badges ?? '',
|
|
@@ -375,6 +379,8 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
|
|
|
375
379
|
dependabotUpdates: dependabotUpdates(quality.dependabot),
|
|
376
380
|
tsconfigPaths: ['be/tsconfig.json', ...fe.apps.map(entry => `fe/apps/${entry.name}/tsconfig.json`), ...(packages ? ['fe/packages/*/tsconfig.json'] : [])].join(','),
|
|
377
381
|
styleGlob: STYLE_GLOB,
|
|
382
|
+
grammarLintImport: grammarLint?.package ? `import { ${grammarLint.factory} } from "${grammarLint.package}"` : '',
|
|
383
|
+
grammarLintExport: grammarLint?.package ? `export default [...starciFeConfig({ hfs }), ...${grammarLint.factory}({ hfs })]` : 'export default starciFeConfig({ hfs })',
|
|
378
384
|
imageMatrix: imageMatrix(app, source),
|
|
379
385
|
imageProject: app.project,
|
|
380
386
|
...stackWorkflowVariables(stack, app.project, name => stackDockerfile(app, name)),
|