@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +8 -11
  3. package/codemod/button-intent.mjs +356 -0
  4. package/lint/run.mjs +70 -29
  5. package/package.json +3 -2
  6. package/runtime/knowledge/grammars/index.yaml +111 -0
  7. package/runtime/knowledge/grammars/offset-pop/playbook.yaml +302 -0
  8. package/runtime/knowledge/grammars/starci/playbook.yaml +1190 -0
  9. package/runtime/knowledge/hfs/canon-pins.yaml +22 -8
  10. package/runtime/knowledge/hfs/fe-stack.yaml +12 -4
  11. package/runtime/knowledge/hfs/rules.yaml +163 -83
  12. package/runtime/knowledge/hfs/slots.yaml +3 -3
  13. package/runtime/knowledge/ui/presentation/font.yaml +101 -0
  14. package/runtime/knowledge/ui/presentation/padding.yaml +217 -0
  15. package/runtime/modules/kernel/failure-codes.yaml +102 -20
  16. package/runtime/modules/kernel/removed-vocabulary.yaml +29 -0
  17. package/runtime/modules/kernel/roles.yaml +1 -1
  18. package/runtime/packages/cli/src/catalog.generated.mjs +119 -9
  19. package/runtime/scripts/gates/canon-pins.mjs +103 -0
  20. package/runtime/scripts/gates/gate.mjs +3 -36
  21. package/runtime/scripts/gates/output.mjs +40 -0
  22. package/runtime/scripts/gates/test-world-run.mjs +2 -4
  23. package/runtime/scripts/guards/release-definition.mjs +22 -3
  24. package/runtime/scripts/hfs/architecture/config.mjs +8 -8
  25. package/runtime/scripts/hfs/check.mjs +1 -1
  26. package/runtime/scripts/hfs/db-restamp-verb.mjs +2 -2
  27. package/runtime/scripts/hfs/declaration-shape.mjs +8 -2
  28. package/runtime/scripts/hfs/grammar-presentation.mjs +152 -0
  29. package/runtime/scripts/hfs/rule-catalog.mjs +2 -2
  30. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -0
  31. package/runtime/scripts/hfs/rules/fe-stack.mjs +44 -1
  32. package/runtime/scripts/hfs/slots.mjs +1 -0
  33. package/runtime/scripts/hfs/view.mjs +1 -0
  34. package/runtime/scripts/lib/canon-binding-text.mjs +129 -0
  35. package/runtime/scripts/lib/css-rules.mjs +18 -0
  36. package/runtime/scripts/lib/example-refs.mjs +1 -1
  37. package/runtime/scripts/lib/grammar-lint.mjs +48 -0
  38. package/runtime/scripts/lib/grammar-vendor-parity-jsx.mjs +360 -0
  39. package/runtime/scripts/lib/grammar-vendor-parity.mjs +110 -157
  40. package/runtime/scripts/lib/numeric-match.mjs +5 -0
  41. package/runtime/scripts/lib/release-version.mjs +119 -0
  42. package/runtime/scripts/lib/runtime-publication-tags.mjs +3 -46
  43. package/runtime/scripts/lib/source-package-imports.mjs +111 -0
  44. package/runtime/scripts/work/draw/draw-layer.mjs +5 -4
  45. package/scaffold/app.mjs +7 -5
  46. package/src/main.mjs +30 -15
  47. package/stack/config.mjs +2 -1
  48. package/stack/render.mjs +24 -3
  49. package/sync/index.mjs +8 -2
  50. package/templates/app/skeleton/.starcistacks/dev/infra/compose/stack.yaml +0 -1
  51. package/templates/app/skeleton/.starcistacks/production/infra/swarm/stack.yaml +0 -1
  52. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +0 -1
  53. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +0 -1
  54. 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 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';
@@ -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}: ${first.titleVi}\n ${first.whyVi}\n -> ${first.nextStepVi}\n`);
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) => runCheck({ repoRoot: root, only: opts.changed ?? undefined, presets, prettier }),
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: () => 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'] });
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 command runner and `stackIo` the spawn, fetch, sleep and clock seams of the Swarm tooling; IO and tool seams keep every verb independently testable. */
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
- const readYaml = (file, what) => {
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. Empty variable-only lines disappear; an unknown name is an error.
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]*\{\{([A-Za-z]\w*)\}\}[ \t]*(?:\n|$)/gm, (line, key) => vars[key] === '' ? '' : line);
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)),
@@ -1,5 +1,4 @@
1
1
  # Rendered development model. App images and owner infrastructure must be provisioned before use.
2
- version: "3.8"
3
2
  services:
4
3
  core:
5
4
  image: "{{project}}/core:v0.0.0"
@@ -1,5 +1,4 @@
1
1
  # Rendered production model. All-zero app digests are illustrative; publish verified images before use.
2
- version: "3.8"
3
2
  services:
4
3
  core:
5
4
  image: "{{project}}/core:v0.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000"
@@ -1,5 +1,4 @@
1
1
  # Illustrative development model. Replace all-zero application digests with verified published release digests before use.
2
- version: "3.8"
3
2
  services:
4
3
  api:
5
4
  image: "{{project}}/api:v0.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000"
@@ -1,5 +1,4 @@
1
1
  # Illustrative production model. Replace all-zero application digests with verified published release digests before use.
2
- version: "3.8"
3
2
  services:
4
3
  api:
5
4
  image: "{{project}}/api:v0.0.0@sha256:0000000000000000000000000000000000000000000000000000000000000000"
@@ -1,3 +1,6 @@
1
1
  import { loadHfs, starciFeConfig } from "@starci/eslint-canon-fe"
2
+ // {{grammarLintImport}}
2
3
 
3
- export default starciFeConfig({ hfs: loadHfs(import.meta.url) })
4
+ const hfs = loadHfs(import.meta.url)
5
+
6
+ // {{grammarLintExport}}