@starci/hfs 4.7.34 → 4.7.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +8 -11
  3. package/lint/run.mjs +70 -29
  4. package/package.json +2 -2
  5. package/runtime/knowledge/grammars/index.yaml +102 -0
  6. package/runtime/knowledge/grammars/offset-pop/playbook.yaml +302 -0
  7. package/runtime/knowledge/grammars/starci/playbook.yaml +1159 -0
  8. package/runtime/knowledge/hfs/canon-pins.yaml +22 -8
  9. package/runtime/knowledge/hfs/fe-stack.yaml +7 -0
  10. package/runtime/knowledge/hfs/rules.yaml +133 -83
  11. package/runtime/knowledge/hfs/slots.yaml +3 -3
  12. package/runtime/knowledge/ui/presentation/font.yaml +101 -0
  13. package/runtime/knowledge/ui/presentation/padding.yaml +216 -0
  14. package/runtime/modules/kernel/failure-codes.yaml +40 -20
  15. package/runtime/modules/kernel/removed-vocabulary.yaml +29 -0
  16. package/runtime/packages/cli/src/catalog.generated.mjs +7 -7
  17. package/runtime/scripts/gates/canon-pins.mjs +103 -0
  18. package/runtime/scripts/gates/gate.mjs +3 -36
  19. package/runtime/scripts/gates/output.mjs +40 -0
  20. package/runtime/scripts/gates/test-world-run.mjs +2 -4
  21. package/runtime/scripts/hfs/architecture/config.mjs +8 -8
  22. package/runtime/scripts/hfs/check.mjs +1 -1
  23. package/runtime/scripts/hfs/db-restamp-verb.mjs +2 -2
  24. package/runtime/scripts/hfs/declaration-shape.mjs +8 -2
  25. package/runtime/scripts/hfs/grammar-presentation.mjs +152 -0
  26. package/runtime/scripts/hfs/rule-catalog.mjs +2 -2
  27. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -0
  28. package/runtime/scripts/hfs/rules/fe-stack.mjs +44 -1
  29. package/runtime/scripts/hfs/slots.mjs +1 -0
  30. package/runtime/scripts/hfs/view.mjs +1 -0
  31. package/runtime/scripts/lib/canon-binding-text.mjs +129 -0
  32. package/runtime/scripts/lib/example-refs.mjs +1 -1
  33. package/runtime/scripts/lib/grammar-lint.mjs +48 -0
  34. package/runtime/scripts/lib/grammar-vendor-parity.mjs +31 -111
  35. package/runtime/scripts/lib/grammar-vendor-source.mjs +345 -0
  36. package/runtime/scripts/lib/numeric-match.mjs +5 -0
  37. package/runtime/scripts/lib/source-package-imports.mjs +111 -0
  38. package/runtime/scripts/work/draw/draw-layer.mjs +5 -4
  39. package/scaffold/app.mjs +7 -5
  40. package/src/main.mjs +26 -13
  41. package/stack/config.mjs +2 -1
  42. package/stack/render.mjs +24 -3
  43. package/sync/index.mjs +8 -2
  44. package/templates/app/skeleton/.starcistacks/dev/infra/compose/stack.yaml +0 -1
  45. package/templates/app/skeleton/.starcistacks/production/infra/swarm/stack.yaml +0 -1
  46. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +0 -1
  47. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +0 -1
  48. package/templates/fe/tool-config/eslint.config.mjs +4 -1
@@ -3,14 +3,117 @@
3
3
  // The gates read the pins here; scripts/checks/check-canon-pins.mjs is the self-check that judges them.
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
+ import { createRequire } from 'node:module';
6
7
  import { skillRoot } from '../../engine/runtime-root.mjs';
7
8
  import { parseYaml } from '../../engine/yaml.mjs';
9
+ import { grammarLintFor } from '../lib/grammar-lint.mjs';
10
+ import { locateDeclaration } from '../hfs/slots.mjs';
11
+ import { pathKey, posixPath } from '../lib/path-key.mjs';
12
+ import { canonContentDigest, installedFiles } from './canon-digest.mjs';
8
13
 
9
14
  export const PINS_FILE = 'knowledge/hfs/canon-pins.yaml';
10
15
  export const SCHEMA_FILE = 'modules/schemas/canon-pins.schema.yaml';
11
16
  export const PROFILES_FILE = 'modules/models/code-patterns.yaml';
17
+ /** A missing, mismatched or unjudged installed canon remains a failing gate. */
18
+ export const CANON_INSTALL_CODES = Object.freeze({ missing: 'CANON_INSTALL_MISSING', mismatch: 'CANON_INSTALL_MISMATCH', unjudged: 'CANON_INSTALL_UNJUDGED' });
12
19
 
13
20
  /** The parsed canon pins of the runtime at `root`. */
14
21
  export function loadPins(root = skillRoot) {
15
22
  return parseYaml(fs.readFileSync(path.join(root, PINS_FILE), 'utf8'));
16
23
  }
24
+
25
+ /** Every independently published canon binding of a profile, including each declared Grammar family. */
26
+ export function profileCanonBindings(profile) {
27
+ return [
28
+ { binding: 'canon', canon: profile?.canon },
29
+ ...Object.entries(profile?.familyCanons ?? {}).map(([family, canon]) => ({ binding: `familyCanons.${family}`, family, canon })),
30
+ ];
31
+ }
32
+
33
+ /** The zero-based source lines that author actual profile canon versions, including named family bindings. */
34
+ export function canonBindingVersionLines(text) {
35
+ const profiles = parseYaml(text)?.profiles ?? {};
36
+ const bindings = new Map(Object.entries(profiles).flatMap(([profile, value]) => profileCanonBindings(value)
37
+ .filter(row => typeof row.canon?.package === 'string' && typeof row.canon?.version === 'string')
38
+ .map(row => [JSON.stringify(['profiles', profile, ...row.binding.split('.')]), row.canon])));
39
+ const leaves = new Map(), stack = [];
40
+ text.split('\n').forEach((line, index) => {
41
+ const separator = line.indexOf(':'), field = line.slice(0, separator), key = field.trim();
42
+ if (separator < 0 || !/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key)) return;
43
+ const indent = field.length - field.trimStart().length, value = line.slice(separator + 1).trimStart();
44
+ while (stack.length && stack.at(-1).indent >= indent) stack.pop();
45
+ const keys = [...stack.map(entry => entry.key), key];
46
+ let binding = bindings.get(JSON.stringify(keys));
47
+ if (key === 'version') binding = bindings.get(JSON.stringify(keys.slice(0, -1)));
48
+ if (binding && (key === 'version' || /^\{.*\bversion\s*:/.test(value))) leaves.set(index, { package: binding.package, version: binding.version });
49
+ stack.push({ key, indent });
50
+ });
51
+ return leaves;
52
+ }
53
+
54
+ /** The universal binding and the explicitly selected family's binding; no absent selection implies a family. */
55
+ export function selectedProfileCanonBindings(profile, family) {
56
+ const bindings = [{ binding: 'canon', canon: profile?.canon }];
57
+ if (!profile?.familyCanons) return bindings;
58
+ const contract = grammarLintFor(family);
59
+ if (contract.package === null) return bindings;
60
+ const canon = profile.familyCanons[contract.family];
61
+ if (!canon || canon.package !== contract.package) {
62
+ throw new Error(`Grammar family ${contract.family} has no matching code-pattern canon binding for ${contract.package}`);
63
+ }
64
+ return [...bindings, { binding: `familyCanons.${contract.family}`, family: contract.family, canon }];
65
+ }
66
+
67
+ /** The package root resolved from a side's npm install, without admitting an install above the app boundary. */
68
+ function installedPackageRoot(directory, name, bound) {
69
+ const top = pathKey(bound);
70
+ const searched = (createRequire(path.join(directory, 'package.json')).resolve.paths(name) ?? []).filter(modules => pathKey(modules).startsWith(`${top}/`));
71
+ for (const modules of searched) {
72
+ const candidate = path.join(modules, ...name.split('/'));
73
+ if (fs.existsSync(path.join(candidate, 'package.json'))) return fs.realpathSync(candidate);
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /** Inspect one selected binding's actual installed bytes; errors remain unavailable and mismatches remain findings. */
79
+ function inspectBinding(root, bound, pins, profile, row, out) {
80
+ const { canon, family } = row, side = pins[canon?.package]?.side;
81
+ const label = row.binding === 'canon' ? profile : `${profile}.${row.binding}`;
82
+ if (!canon?.package || typeof side !== 'string') { out.errors.push(`${CANON_INSTALL_CODES.unjudged} profile ${label}: canon.package ${canon?.package} has no pinned side`); return; }
83
+ const installed = installedPackageRoot(path.join(root, side), canon.package, bound);
84
+ if (!installed) { out.errors.push(`${CANON_INSTALL_CODES.missing} profile ${label}: ${canon.package} does not resolve from ${side}/ of the app; install the pinned ${canon.version}`); return; }
85
+ const where = posixPath(path.relative(root, installed));
86
+ let manifest, version, digest;
87
+ try {
88
+ manifest = JSON.parse(fs.readFileSync(path.join(installed, 'package.json'), 'utf8'));
89
+ version = manifest.version ?? null;
90
+ digest = canonContentDigest(installed, canon.contentDigest, installedFiles(installed));
91
+ } catch (error) { out.errors.push(`${CANON_INSTALL_CODES.unjudged} profile ${label}: ${error.code ?? 'ERROR'} ${error.message}`); return; }
92
+ out.checked.push({ profile: label, ...(family ? { family } : {}), package: canon.package, side, path: where, version, digest: digest.value, files: digest.files });
93
+ if (version !== canon.version || digest.value !== canon.contentDigest.value || digest.files !== canon.contentDigest.files) {
94
+ out.findings.push({ engine: 'canon', rule: 'installed-canon-mismatch', path: where, line: null,
95
+ message: `${canon.package} installed for ${side}/ is ${version} with ${digest.files} files digesting ${digest.value}; profile ${label} binds ${canon.version} with ${canon.contentDigest.files} files digesting ${canon.contentDigest.value} (${CANON_INSTALL_CODES.mismatch}: reinstall the pinned version from the registry)` });
96
+ }
97
+ return Object.keys(manifest.dependencies ?? {});
98
+ }
99
+
100
+ /** Judge the universal and explicitly selected family installs against their independent version/content bindings. */
101
+ export function inspectInstalledCanons(root, { pins, profiles, bound = root }) {
102
+ const out = { checked: [], findings: [], errors: [] };
103
+ for (const [profile, value] of Object.entries(profiles)) {
104
+ let bindings;
105
+ try {
106
+ const declaration = value?.familyCanons ? JSON.parse(fs.readFileSync(locateDeclaration(root).file, 'utf8')) : null;
107
+ bindings = selectedProfileCanonBindings(value, declaration?.sides?.fe?.grammar);
108
+ } catch (error) { out.errors.push(`${CANON_INSTALL_CODES.unjudged} profile ${profile}: ${error.message}`); continue; }
109
+ const available = profileCanonBindings(value), checked = new Set();
110
+ for (const row of bindings) {
111
+ if (checked.has(row.canon?.package)) continue;
112
+ checked.add(row.canon?.package);
113
+ const dependencies = inspectBinding(root, bound, pins, profile, row, out) ?? [];
114
+ // A family adapter's digest does not cover its independently published implementation dependency.
115
+ bindings.push(...available.filter(entry => dependencies.includes(entry.canon?.package) && !checked.has(entry.canon.package)));
116
+ }
117
+ }
118
+ return out;
119
+ }
@@ -53,8 +53,7 @@ import { APP_SCOPE, HFS_DECLARATION_FILE, locateDeclaration } from '../hfs/slots
53
53
  import { runNode } from '../api/node/run-node.mjs';
54
54
  import { sha256 } from '../../engine/digest.mjs';
55
55
  import { parseYaml } from '../../engine/yaml.mjs';
56
- import { canonContentDigest, installedFiles } from './canon-digest.mjs';
57
- import { PROFILES_FILE, loadPins } from './canon-pins.mjs';
56
+ import { CANON_INSTALL_CODES, PROFILES_FILE, inspectInstalledCanons, loadPins } from './canon-pins.mjs';
58
57
  import { gateGit as git, gateGitText as gitText, resolveGateBase, gateDelta, gateInputSnapshot } from './gate-input.mjs';
59
58
  export { resolveGateBase, gateInputSnapshot } from './gate-input.mjs';
60
59
  import { jestRunError, reduceJest } from './test-world-run.mjs';
@@ -438,21 +437,6 @@ export function mergeGuard(root, { base, head = 'HEAD', mainTip = mainTipOf(root
438
437
  }
439
438
 
440
439
  /* ----------------------------------------------------------------------------------------- installed canon */
441
- const INSTALL = Object.freeze({ missing: 'CANON_INSTALL_MISSING', mismatch: 'CANON_INSTALL_MISMATCH', unjudged: 'CANON_INSTALL_UNJUDGED' });
442
-
443
- /**
444
- * The installed root of package `name` as node resolves it from `directory` (its node_modules walk), never above `bound` (the
445
- * app root, appRootOf): a canon in an enclosing repository's node_modules is not this app's install. Null when none.
446
- */
447
- function installedPackageRoot(directory, name, bound) {
448
- const top = pathKey(bound);
449
- const searched = (createRequire(path.join(directory, 'package.json')).resolve.paths(name) ?? []).filter((modules) => pathKey(modules).startsWith(`${top}/`));
450
- for (const modules of searched) {
451
- const candidate = path.join(modules, ...name.split('/'));
452
- if (fs.existsSync(path.join(candidate, 'package.json'))) return fs.realpathSync(candidate);
453
- }
454
- return null;
455
- }
456
440
  /**
457
441
  * Every profile's canon as installed under the app at `root`, judged against its binding in `runtime`'s code-patterns.yaml:
458
442
  * the package resolved from the pin's side directory must have the bound version and content digest.
@@ -464,25 +448,8 @@ export function installedCanonFindings(root, { runtime = runtimeRoot } = {}) {
464
448
  try {
465
449
  pins = loadPins(runtime).pins ?? {};
466
450
  profiles = parseYaml(fs.readFileSync(path.join(runtime, PROFILES_FILE), 'utf8'))?.profiles ?? {};
467
- } catch (error) { out.errors.push(`${INSTALL.unjudged} the canon bindings are unreadable: ${error.message}`); return out; }
468
- for (const [profile, value] of Object.entries(profiles)) {
469
- const canon = value?.canon, side = pins[canon?.package]?.side;
470
- if (!canon?.package || typeof side !== 'string') { out.errors.push(`${INSTALL.unjudged} profile ${profile}: canon.package ${canon?.package} has no pinned side`); continue; }
471
- const installed = installedPackageRoot(path.join(root, side), canon.package, appRootOf(root, path.join(side, 'package.json')));
472
- if (!installed) { out.errors.push(`${INSTALL.missing} profile ${profile}: ${canon.package} does not resolve from ${side}/ of the app; install the pinned ${canon.version}`); continue; }
473
- const where = posixPath(path.relative(root, installed));
474
- let version, digest;
475
- try {
476
- version = JSON.parse(fs.readFileSync(path.join(installed, 'package.json'), 'utf8')).version ?? null;
477
- digest = canonContentDigest(installed, canon.contentDigest, installedFiles(installed));
478
- } catch (error) { out.errors.push(`${INSTALL.unjudged} profile ${profile}: ${error.code ?? 'ERROR'} ${error.message}`); continue; }
479
- out.checked.push({ profile, package: canon.package, side, path: where, version, digest: digest.value, files: digest.files });
480
- if (version !== canon.version || digest.value !== canon.contentDigest.value || digest.files !== canon.contentDigest.files) {
481
- out.findings.push({ engine: 'canon', rule: 'installed-canon-mismatch', path: where, line: null,
482
- message: `${canon.package} installed for ${side}/ is ${version} with ${digest.files} files digesting ${digest.value}; profile ${profile} binds ${canon.version} with ${canon.contentDigest.files} files digesting ${canon.contentDigest.value} (${INSTALL.mismatch}: reinstall the pinned version from the registry)` });
483
- }
484
- }
485
- return out;
451
+ } catch (error) { out.errors.push(`${CANON_INSTALL_CODES.unjudged} the canon bindings are unreadable: ${error.message}`); return out; }
452
+ return inspectInstalledCanons(root, { pins, profiles, bound: appRootOf(root, 'package.json') });
486
453
  }
487
454
 
488
455
  /* ----------------------------------------------------------------------------------------------- gate */
@@ -0,0 +1,40 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {redactBytes} from '../lib/redact.mjs';
4
+
5
+ /** Print a gate summary, retain an optional scratch copy, and return its measured exit code. */
6
+ export function emitGateSummary(summary, out = null, write = text => process.stdout.write(text)) {
7
+ const text = `${JSON.stringify(summary, null, 2)}\n`;
8
+ if (out) {
9
+ const file = path.resolve(out);
10
+ fs.mkdirSync(path.dirname(file), {recursive: true});
11
+ fs.writeFileSync(file, text);
12
+ }
13
+ write(text);
14
+ return summary.exit;
15
+ }
16
+
17
+ /**
18
+ * Write a check result to scratch, stdout, or the external content-addressed store. A blob is redacted first when its
19
+ * media type is text (scripts/lib/redact.mjs); the printed {sha, redaction} is what a check_runs row cites.
20
+ */
21
+ export async function emitCheckOutput(value, {out = null, blob = false, mediaType = 'application/json',
22
+ write = text => process.stdout.write(text), put = null} = {}) {
23
+ if (out && blob) throw new Error('--out and --blob are mutually exclusive');
24
+ const bytes = Buffer.isBuffer(value) ? value : Buffer.from(String(value));
25
+ if (out) {
26
+ const file = path.resolve(out);
27
+ fs.mkdirSync(path.dirname(file), {recursive: true});
28
+ fs.writeFileSync(file, bytes);
29
+ return {out: file};
30
+ }
31
+ if (blob) {
32
+ const putBlob = put ?? (await import('../../engine/db/blob.mjs')).putBlob;
33
+ const clean = redactBytes(bytes, mediaType);
34
+ const {sha} = await putBlob(clean.bytes, {mediaType});
35
+ write(`${JSON.stringify({sha, redaction: clean.redaction})}\n`);
36
+ return {sha, redaction: clean.redaction};
37
+ }
38
+ write(bytes.toString());
39
+ return {};
40
+ }
@@ -26,6 +26,7 @@ import { valueFlags } from '../lib/cli-arg.mjs';
26
26
  import { isMain } from '../lib/is-main.mjs'; import { walkFiles } from '../lib/walk.mjs';
27
27
  import { opGateRules } from '../lib/op-gate.mjs';
28
28
  import { makeTempDir } from '../api/fs/make-temp-dir.mjs';
29
+ import { emitGateSummary } from './output.mjs';
29
30
 
30
31
  export const TEST_WORLD_RUN_SCHEMA = 'starci/test-world-run@1';
31
32
  const WORLD_PROJECTS = Object.freeze({
@@ -243,10 +244,7 @@ if (isMain(import.meta.url)) {
243
244
  try {
244
245
  const opts = parseTestWorldArgs(process.argv.slice(2));
245
246
  const summary = buildTestWorldRun({ root: opts.root ?? process.cwd(), project: opts.project, tests: opts.tests });
246
- const text = `${JSON.stringify(summary, null, 2)}\n`;
247
- if (opts.out) { fs.mkdirSync(path.dirname(path.resolve(opts.out)), { recursive: true }); fs.writeFileSync(path.resolve(opts.out), text); }
248
- process.stdout.write(text);
249
- process.exitCode = summary.exit;
247
+ process.exitCode = emitGateSummary(summary, opts.out);
250
248
  } catch (error) {
251
249
  process.stderr.write(`test-world-run: ${error.message}\n`);
252
250
  process.exitCode = 2;
@@ -5,6 +5,7 @@ import { readJsonFile as readJson } from '../../lib/json.mjs';
5
5
  import { isInside } from '../../lib/walk.mjs';
6
6
  import { openHfs } from '../slots.mjs';
7
7
  import { byCodeUnit } from '../../lib/list.mjs';
8
+ import { grammarContractFor } from '../../lib/grammar-lint.mjs';
8
9
 
9
10
 
10
11
  /** A file's identity: its resolved real path, or the resolved path when it does not exist. */
@@ -270,19 +271,18 @@ function derivedOwners(root, resolver) {
270
271
 
271
272
  const DECLARED_HANDLER_DECORATORS = ['CommandHandler', 'QueryHandler'];
272
273
 
273
- const GRAMMAR_PACKAGE = '@starci/grammar';
274
-
275
274
  /**
276
- * The Grammar contract of a frontend repository, derived rather than declared: every app's globals.css is a style
277
- * source, every app and every workspace package that depends on the Grammar package is a consumer. null when no app has
275
+ * The selected family's component contract with derived consumers: every app's globals.css is a style
276
+ * source, every app and every workspace package that depends on its package is a consumer. null when no app has
278
277
  * a globals.css to judge (the contract is then reported unavailable, never passed).
279
278
  */
280
- function derivedGrammar(root, packageRoot, workspaces, apps = []) {
279
+ function derivedGrammar(root, packageRoot, workspaces, apps, family) {
281
280
  const styleSources = apps.map(app => `apps/${app.name}/src/app/globals.css`).filter(relative => existingRegularFile(root, relative));
282
281
  if (!styleSources.length) return null;
282
+ const contract = grammarContractFor(family);
283
283
  // Every fe app and package is an npm workspace that declares what it imports (HFS_MONO_WORKSPACE_DEP): each workspace that
284
284
  // declares the Grammar package is a consumer, and so is the app root's manifest when it does.
285
- const declaresGrammar = (pkg) => Boolean(pkg && [pkg.dependencies, pkg.peerDependencies, pkg.devDependencies].some(section => section && Object.hasOwn(section, GRAMMAR_PACKAGE)));
285
+ const declaresGrammar = (pkg) => Boolean(pkg && [pkg.dependencies, pkg.peerDependencies, pkg.devDependencies].some(section => section && Object.hasOwn(section, contract.package)));
286
286
  const appManifest = slash(path.relative(root, path.join(packageRoot, 'package.json')));
287
287
  const consumerManifests = declaresGrammar(readJson(path.join(packageRoot, 'package.json'))) ? [appManifest] : [];
288
288
  for (const workspace of workspaces) {
@@ -292,7 +292,7 @@ function derivedGrammar(root, packageRoot, workspaces, apps = []) {
292
292
  if (declaresGrammar(pkg)) consumerManifests.push(manifest);
293
293
  }
294
294
  if (!consumerManifests.length) return null;
295
- return { package: GRAMMAR_PACKAGE, entry: `${GRAMMAR_PACKAGE}/common`, styleEntry: `${GRAMMAR_PACKAGE}/common.css`, styleSources, consumerManifests, peers: ['react', '@heroui/react', '@xyflow/react'] };
295
+ return { ...contract, styleSources, consumerManifests };
296
296
  }
297
297
 
298
298
  /**
@@ -328,7 +328,7 @@ export function loadArchitectureConfig(repositoryRoot, { hfs } = {}) {
328
328
  hooks: inferred.hooks,
329
329
  modules: inferred.modules,
330
330
  transport: inferred.transport,
331
- grammar: profile === 'fe' ? derivedGrammar(root, packageRoot, workspaces, apps) : null,
331
+ grammar: profile === 'fe' ? derivedGrammar(root, packageRoot, workspaces, apps, opened.repo.grammar) : null,
332
332
  };
333
333
  if (profile === 'fe') assertFrontendRolesDisjoint(root, frontend);
334
334
  return {
@@ -341,7 +341,7 @@ function mergeBaseOf(repoRoot, base) {
341
341
  }
342
342
 
343
343
  /** Tracked paths that differ from the merge-base (commits, staged and unstaged edits; deletions are not paths to judge). */
344
- function changedSince(repoRoot, base) {
344
+ export function changedSince(repoRoot, base) {
345
345
  const sha = mergeBaseOf(repoRoot, base);
346
346
  if (!sha) {
347
347
  throw new Error(base
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { parseYaml } from '../../engine/yaml.mjs';
5
5
  import { invocationDir } from '../lib/roots.mjs';
6
6
  import { migrationStamp } from './rules/database-migrations.mjs';
7
- import { isActiveMigration, MIGRATIONS_DIR } from './rules/database-constants.mjs';
7
+ import { MIGRATIONS_DIR } from './rules/database-constants.mjs';
8
8
  import { DbVerbError, dbOptions, dbAppRoot, dbSqlFiles, runDbSupabase, repairArgs, dbFailure } from './db-baseline-verb.mjs';
9
9
 
10
10
  /** The map value of one old version: a new version, or { file, to, applied } for the colliding-versions case. */
@@ -55,7 +55,7 @@ function restampPlan(root, mapFile, { local = false, ports = {} } = {}) {
55
55
  catch { throw new DbVerbError('restamp map must be readable YAML with unique version keys'); }
56
56
  const mapping = document?.versions ?? document;
57
57
  if (!mapping || typeof mapping !== 'object' || Array.isArray(mapping) || !Object.keys(mapping).length) throw new DbVerbError('restamp map must contain old-to-new version pairs');
58
- const active = dbSqlFiles(root).filter((entry) => isActiveMigration(entry.file));
58
+ const active = dbSqlFiles(root);
59
59
  const occupied = new Set(active.map((entry) => entry.stamp));
60
60
  const targets = new Set();
61
61
  const steps = Object.entries(mapping).map(([old, raw]) => {
@@ -73,11 +73,17 @@ function extraPackageProblems(side, entries) {
73
73
  return [];
74
74
  }
75
75
 
76
+ function grammarProblems(side, grammar) {
77
+ if (grammar === undefined) return [];
78
+ if (side !== 'fe') return ['grammar belongs to the fe side'];
79
+ return typeof grammar === 'string' && NAME.test(grammar) ? [] : ['sides.fe.grammar must name a Grammar family'];
80
+ }
81
+
76
82
  function declarationSideProblems(side, value, names) {
77
83
  const bad = [];
78
84
  const at = `sides.${side}`;
79
85
  if (!isPlainObject(value)) return [`${at} must be an object`];
80
- for (const key of Object.keys(value)) if (!['apps', 'optionalSlots', 'patterns', 'kinds', 'connections', 'reads', 'extraPackages', 'backing'].includes(key)) bad.push(`${at} has unknown key ${key}`);
86
+ for (const key of Object.keys(value)) if (!['apps', 'optionalSlots', 'patterns', 'kinds', 'connections', 'reads', 'extraPackages', 'grammar', 'backing'].includes(key)) bad.push(`${at} has unknown key ${key}`);
81
87
  bad.push(...appShapeProblems(value.apps, at, side, names));
82
88
  if (isBadUniqueList(value.optionalSlots, (slot) => SLOT_ID.test(String(slot)))) bad.push(`${at}.optionalSlots must be a unique list of slot ids`);
83
89
  if (value.backing !== undefined && side !== 'be') bad.push('backing belongs to the be side');
@@ -85,7 +91,7 @@ function declarationSideProblems(side, value, names) {
85
91
  bad.push(...patternShapeProblems(value, at, NAME), ...kindShapeProblems(value, at, NAME));
86
92
  if (isBadUniqueList(value.reads, (read) => typeof read === 'string' && read.length > 0)) bad.push(`${at}.reads must be a unique list of paths`);
87
93
  if (value.connections !== undefined) bad.push(...connectionProblems(side, value));
88
- bad.push(...extraPackageProblems(side, value.extraPackages));
94
+ bad.push(...extraPackageProblems(side, value.extraPackages), ...grammarProblems(side, value.grammar));
89
95
  return bad;
90
96
  }
91
97
 
@@ -0,0 +1,152 @@
1
+ // Family presentation data decorates universal proof cases without moving their semantic identities.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { parseYaml } from '../../engine/yaml.mjs';
6
+ import { skillRoot } from '../../engine/runtime-root.mjs';
7
+ import { openHfs } from './slots.mjs';
8
+ import { grammarFamilyFor } from '../lib/grammar-lint.mjs';
9
+
10
+ const GRAMMARS = fileURLToPath(new URL('../../knowledge/grammars/', import.meta.url));
11
+ const PADDING_TOPIC = 'presentation/padding.yaml';
12
+ const TOPIC_NAMES = ['padding', 'font'];
13
+ const REM_PX = 16;
14
+ const TEXT_WEIGHT_NAMES = ['normal', 'medium', 'semibold'];
15
+
16
+ /** The Common text-weight matrix is complete only when each public weight has one declared class. */
17
+ function fontWeightClasses(guidance) {
18
+ const matrix = guidance?.familyRequirement ?? guidance?.requirement ?? '';
19
+ const pattern = new RegExp('`font-(' + TEXT_WEIGHT_NAMES.join('|') + ')`', 'g');
20
+ const names = [...String(matrix).matchAll(pattern)].map(match => match[1]);
21
+ if (names.length !== TEXT_WEIGHT_NAMES.length || new Set(names).size !== TEXT_WEIGHT_NAMES.length) {
22
+ throw new Error('Grammar font weight matrix is missing or incomplete');
23
+ }
24
+ return names;
25
+ }
26
+
27
+ /** Resolve a recorded rem, px or unitless scale value at the proof's baseline root; unsupported values stay unavailable. */
28
+ export const remPx = (value) => {
29
+ const match = /^(-?(?:\d+(?:\.\d+)?|\.\d+))(rem|px)?$/.exec(String(value ?? '').trim());
30
+ if (!match) return null;
31
+ const px = Number(match[1]) * (match[2] === 'px' ? 1 : REM_PX);
32
+ return Number.isFinite(px) ? px : null;
33
+ };
34
+
35
+ /** Resolve the selected family's published text weights through the active CSS token scope. */
36
+ export function fontWeightsOf(guidance, scope) {
37
+ const weights = fontWeightClasses(guidance).map(name => Number(scope?.variable(`--font-weight-${name}`)?.value));
38
+ if (weights.some(weight => !Number.isFinite(weight))) throw new Error('Grammar font weight tokens are unresolved');
39
+ return weights;
40
+ }
41
+
42
+ /** Missing or ambiguous family weight data cannot remove the render's weight proof. */
43
+ function validateFontGuidance(data) {
44
+ const weights = (data.guidance ?? []).filter(item => item.id === 'weight');
45
+ if (weights.length !== 1) throw new Error('Grammar font guidance needs one concrete weight matrix');
46
+ fontWeightClasses(weights[0]);
47
+ }
48
+
49
+ /** Read the explicit family or the app declaration; CSS discovery never chooses presentation law. */
50
+ function selectedFamily(family, repo) {
51
+ if (family !== null && family !== undefined) return grammarFamilyFor(family).id;
52
+ if (!repo) throw new Error('Presentation proof needs an explicit Grammar family or an app repository');
53
+ const declaration = openHfs({ root: skillRoot, repoRoot: repo }).repo;
54
+ return grammarFamilyFor(declaration.grammar ?? declaration.sides?.fe?.grammar).id;
55
+ }
56
+
57
+ /** The one declared presentation owner, following only explicit inheritance and rejecting cycles. */
58
+ function presentationOwner(family, name, seen = new Set()) {
59
+ if (seen.has(family)) throw new Error(`Grammar ${name} inheritance cycles at ${family}`);
60
+ seen.add(family);
61
+ const entry = grammarFamilyFor(family);
62
+ if (typeof entry.productComposition !== 'string' || !entry.productComposition) {
63
+ throw new Error(`Grammar family ${family} has no presentation playbook`);
64
+ }
65
+ const file = path.resolve(GRAMMARS, entry.productComposition);
66
+ const relative = path.relative(GRAMMARS, file);
67
+ if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error(`Grammar family ${family} has an invalid presentation path`);
68
+ const doc = parseYaml(fs.readFileSync(file, 'utf8'));
69
+ const data = doc?.presentation?.[name];
70
+ if (!data || typeof data !== 'object') throw new Error(`Grammar family ${family} has no ${name} presentation data`);
71
+ if (Object.hasOwn(data, 'inherits')) {
72
+ if (Object.keys(data).some(key => !['inherits', 'ownership', 'contract'].includes(key))) {
73
+ throw new Error(`Grammar family ${family} mixes inherited and replacement ${name} data`);
74
+ }
75
+ return presentationOwner(data.inherits, name, seen);
76
+ }
77
+ if (!Array.isArray(data.scale?.steps) || !data.scale.steps.length
78
+ || !Array.isArray(data.componentOwnership?.rows) || !data.componentOwnership.rows.length
79
+ || !Array.isArray(data.recipes) || !data.recipes.length) {
80
+ throw new Error(`Grammar family ${family} has incomplete ${name} presentation data`);
81
+ }
82
+ const steps = new Map(data.scale.steps.map(step => [step.id, step]));
83
+ if (steps.size !== data.scale.steps.length || data.scale.steps.some(step => typeof step.id !== 'string'
84
+ || !step.id || (name === 'padding' && (remPx(step.value) === null || remPx(step.value) < 0)))) {
85
+ throw new Error(`Grammar family ${family} has duplicate or unusable ${name} scale steps`);
86
+ }
87
+ if (data.componentOwnership.rows.some(row => !steps.has(row.rule))) {
88
+ throw new Error(`Grammar family ${family} has a component ${name === 'padding' ? 'inset' : 'font rank'} without a scale value`);
89
+ }
90
+ if (data.recipes.some(recipe => typeof recipe.title !== 'string' || !recipe.title.trim())) {
91
+ throw new Error(`Grammar family ${family} has a ${name} recipe without its concrete title`);
92
+ }
93
+ if (name === 'font') validateFontGuidance(data);
94
+ return { data, source: `knowledge/grammars/${relative.split(path.sep).join('/')}#presentation.${name}` };
95
+ }
96
+
97
+ /** Preserve every universal case while attaching the exact renderer recipe and its numerical owner. */
98
+ function presentationTopic(topic, owner, name) {
99
+ const { data, source } = owner;
100
+ const recipes = new Map(data.recipes.map(recipe => [recipe.rule, recipe]));
101
+ const rules = topic.doc.rules ?? [];
102
+ if (recipes.size !== data.recipes.length || recipes.size !== rules.length) {
103
+ throw new Error(`Grammar ${name} recipes do not cover the universal rule identities exactly once`);
104
+ }
105
+ const attached = rules.map(rule => {
106
+ const recipe = recipes.get(rule.id);
107
+ const byCase = new Map((recipe?.cases ?? []).map(item => [item.id, item]));
108
+ if (!recipe || byCase.size !== recipe.cases.length || byCase.size !== (rule.cases ?? []).length) {
109
+ throw new Error(`Grammar ${name} recipe ${rule.id} does not cover its universal cases exactly once`);
110
+ }
111
+ const cases = rule.cases.map(item => {
112
+ const concrete = byCase.get(item.id);
113
+ if (!concrete || typeof concrete.render !== 'string' || typeof concrete.owner !== 'string') {
114
+ throw new Error(`Grammar ${name} recipe ${rule.id}/${item.id} has no renderer contract`);
115
+ }
116
+ return { ...item, owner: concrete.owner, render: concrete.render, recipeSource: source };
117
+ });
118
+ return { ...rule, familyTitle: recipe.title, cases };
119
+ });
120
+ const guidance = new Map((topic.doc.guidance ?? []).map(item => [item.id, item]));
121
+ for (const concrete of data.guidance ?? []) {
122
+ const semantic = guidance.get(concrete.id);
123
+ guidance.set(concrete.id, semantic ? { ...semantic, familyRequirement: concrete.requirement, familySource: source }
124
+ : { ...concrete, familySource: source });
125
+ }
126
+ return {
127
+ ...topic,
128
+ familySource: source,
129
+ doc: { ...topic.doc, scale: data.scale, componentOwnership: data.componentOwnership,
130
+ guidance: [...guidance.values()], rules: attached },
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Bind supported universal presentation cases to the selected family's numerical recipes without mutation.
136
+ * Missing family data and incomplete coverage fail visibly rather than selecting another family.
137
+ * @param {object[]} knowledge - Universal topics with stable relative paths and parsed documents.
138
+ * @param {{ family?: string, repo?: string }} input - Explicit family or the app whose declaration selects it.
139
+ * @returns {{ family: string, knowledge: object[] }} Selected family and proof-ready topics with owner provenance.
140
+ */
141
+ export function familyPresentationKnowledge(knowledge, { family = null, repo = null } = {}) {
142
+ const selected = selectedFamily(family, repo);
143
+ const topics = knowledge.filter(topic => topic.rel.endsWith(PADDING_TOPIC));
144
+ if (topics.length !== 1) throw new Error('Presentation proof needs exactly one universal padding topic');
145
+ const decorated = new Map();
146
+ for (const name of TOPIC_NAMES) {
147
+ const matches = knowledge.filter(topic => topic.rel.endsWith(`presentation/${name}.yaml`));
148
+ if (matches.length > 1) throw new Error(`Presentation proof has duplicate universal ${name} topics`);
149
+ if (matches.length) decorated.set(matches[0], presentationTopic(matches[0], presentationOwner(selected, name), name));
150
+ }
151
+ return { family: selected, knowledge: knowledge.map(topic => decorated.get(topic) ?? topic) };
152
+ }
@@ -12,7 +12,7 @@ import { fail } from './slot-errors.mjs';
12
12
 
13
13
  const HFS_RULES_FILE = 'knowledge/hfs/rules.yaml';
14
14
  const RULE_GATES = Object.freeze(['pre-commit', 'pre-push', 'settle', 'land', 'ci', 'sonar', 'runtime']);
15
- const ENFORCER_FAMILIES = Object.freeze(['eslint-be', 'eslint-fe', 'stylelint', 'machine', 'hfs', 'work-validate', 'sonar', 'runtime']);
15
+ const ENFORCER_FAMILIES = Object.freeze(['eslint-be', 'eslint-fe', 'eslint-grammar-starci', 'stylelint', 'machine', 'hfs', 'work-validate', 'sonar', 'runtime']);
16
16
  const RULE_KINDS = Object.freeze(['codemod', 'lint', 'check', 'design']);
17
17
  const FINDING_CODE = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/;
18
18
  const ENFORCER_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
@@ -346,7 +346,7 @@ export function loadRuleCatalog({ root = skillRoot, file = path.join(root, HFS_R
346
346
  /** The catalogued why code of a lint finding's rule id (`starci-be/<id>`, `starci-fe/<id>`), or undefined. */
347
347
  lintCode: (ruleId) => {
348
348
  const [plugin, id] = String(ruleId ?? '').split('/');
349
- const kind = (plugin === 'starci-be' && 'eslint-be') || (plugin === 'starci-fe' && 'eslint-fe') || null;
349
+ const kind = (plugin === 'starci-be' && 'eslint-be') || (plugin === 'starci-fe' && 'eslint-fe') || (plugin === 'starci-grammar' && 'eslint-grammar-starci') || null;
350
350
  return kind ? list.find((rule) => rule.enforcers.some((enforcer) => enforcer.kind === kind && enforcer.id === id))?.code : undefined;
351
351
  },
352
352
  /** The rules one enforcer judges, e.g. forEnforcer('eslint-be', 'error-home'). */
@@ -42,6 +42,7 @@ import { feBuildArgsFindings } from './fe-build-args.mjs';
42
42
  /** The failure codes the finders above emit, so `starci app check` ships their labels (check.mjs adds them to CHECK_CODES). Append-only; the coordinator regenerates it at merge time. */
43
43
  export const A15_CODES = [
44
44
  // codes-begin
45
+ 'FE_STACK_FAMILY_LINT_MISSING',
45
46
  'HFS_TEXT_CORRUPT',
46
47
  'FE_PUBLIC_BUILD_ARG_MISSING',
47
48
  'HFS_WORKFLOW_CONTEXT',
@@ -12,10 +12,15 @@ import { found, readJson } from './read.mjs';
12
12
  import { DEPENDENCY_SECTIONS } from './fe-no-tests.mjs';
13
13
  import { editionRuleParams } from '../edition-slots.mjs';
14
14
  import { globExpression } from '../../lib/glob.mjs';
15
+ import { grammarLintFor } from '../../lib/grammar-lint.mjs';
16
+ import { numericMatch } from '../../lib/numeric-match.mjs';
15
17
 
16
18
  export const FE_STACK_FORBIDDEN = 'FE_STACK_FORBIDDEN';
17
19
  export const FE_STACK_UNLISTED = 'FE_STACK_UNLISTED';
18
20
  export const FE_STACK_CORE_MISSING = 'FE_STACK_CORE_MISSING';
21
+ export const FE_STACK_FAMILY_LINT_MISSING = 'FE_STACK_FAMILY_LINT_MISSING';
22
+ const UNIVERSAL_CANON = '@starci/eslint-canon-fe';
23
+ const FAMILY_SPLIT_MAJOR = 9;
19
24
  const CATALOG_FILE = new URL('../../../knowledge/hfs/fe-stack.yaml', import.meta.url);
20
25
  const PACKAGE_JSON = /(?:^|\/)package\.json$/;
21
26
  const APP_ROOT_MANIFEST = 'package.json';
@@ -109,6 +114,42 @@ function unlistedFindings(rel, pkg, allowed, extras, workspaces) {
109
114
  return findings;
110
115
  }
111
116
 
117
+ /** The major version a dependency spec admits, or null when the spec names no plain semver (a tag, a path or a protocol). */
118
+ const declaredMajor = (spec) => numericMatch(spec, /^[\^~>=v\s]*(\d+)(?:\.|$)/);
119
+
120
+ /** The universal canon's declared spec in any front-end or app-root manifest, or null when none declares it. */
121
+ function universalCanonSpec(manifests) {
122
+ for (const [, pkg] of manifests) for (const section of DEPENDENCY_SECTIONS) {
123
+ const spec = pkg?.[section]?.[UNIVERSAL_CANON];
124
+ if (spec !== undefined) return spec;
125
+ }
126
+ return null;
127
+ }
128
+
129
+ /**
130
+ * From canon-fe 9 the Grammar component rules ship in the selected family's lint package; an app that installs the
131
+ * universal canon without that package would silently lose them, so the stack refuses it. An unparseable spec is judged
132
+ * like 9 rather than waved through.
133
+ */
134
+ function familyLintFindings(declaration, repo, packages, rootManifest) {
135
+ const family = declaration?.sides?.fe?.grammar ?? repo.grammar;
136
+ if (typeof family !== 'string' || !family) return [];
137
+ const manifests = [...packages, ['../package.json', rootManifest]];
138
+ const product = (repo.apps ?? []).find(app => app.kind === 'next' && app.name !== 'landing');
139
+ const anchor = product ? `apps/${product.name}/package.json` : 'package.json';
140
+ const spec = universalCanonSpec(manifests);
141
+ if (spec === null) return [];
142
+ const major = declaredMajor(spec);
143
+ if (major !== null && major < FAMILY_SPLIT_MAJOR) return [];
144
+ const lint = grammarLintFor(family);
145
+ if (lint.package === null) return [];
146
+ if (manifests.some(([, pkg]) => dependenciesOf(pkg).has(lint.package))) return [];
147
+ const nextStep = `Add ${lint.package} beside ${UNIVERSAL_CANON} (the app root devDependencies or the fe workspace), compose ${lint.factory}({ hfs }) after starciFeConfig({ hfs }) in fe/eslint.config.mjs, or run starci app sync.`;
148
+ return [found(FE_STACK_FAMILY_LINT_MISSING, anchor,
149
+ `hfs.json selects sides.fe.grammar ${family} and the front end declares ${UNIVERSAL_CANON} ${spec}, but no manifest declares ${lint.package}: from ${UNIVERSAL_CANON} ${FAMILY_SPLIT_MAJOR} that package carries the ${family} Grammar component rules, so without it they are not enforced. ${nextStep}`,
150
+ { dependency: lint.package, family, nextStep })];
151
+ }
152
+
112
153
  /** Closed FE catalog and core presence over the front-end side (the mixed root also holds BE tools). */
113
154
  export function feStackAllowlistFindings({ repoRoot, files, all = files, repo, inScope = () => true }) {
114
155
  if (repo.profile !== 'fe' || repo.kind === 'runtime') return [];
@@ -119,7 +160,9 @@ export function feStackAllowlistFindings({ repoRoot, files, all = files, repo, i
119
160
  const { allowed, core } = stackCatalog();
120
161
  const extras = extraPackages(declaration);
121
162
  const findings = [...packages].filter(([file]) => inScope(file)).flatMap(([file, pkg]) => unlistedFindings(file, pkg, allowed, extras, workspaces));
122
- const rootTools = dependenciesOf(readJson(repoRoot, '../package.json'));
163
+ const rootManifest = readJson(repoRoot, '../package.json');
164
+ const rootTools = dependenciesOf(rootManifest);
165
+ findings.push(...familyLintFindings(declaration, repo, packages, rootManifest));
123
166
  for (const app of repo.apps ?? []) {
124
167
  if (app.kind !== 'next' || app.name === 'landing') continue;
125
168
  const rel = `apps/${app.name}/package.json`;
@@ -120,6 +120,7 @@ function sideDeclaration(manifest, declaration, name, edition, providers) {
120
120
  connections: Object.freeze((s.connections ?? []).map(connectionView)),
121
121
  providers,
122
122
  reads: Object.freeze([...(s.reads ?? [])]),
123
+ ...(s.grammar === undefined ? {} : { grammar: s.grammar }),
123
124
  ...(s.extraPackages === undefined ? {} : { extraPackages: Object.freeze(s.extraPackages.map(entry => Object.freeze({ ...entry }))) }),
124
125
  manifestVersion: manifest.version,
125
126
  });
@@ -33,6 +33,7 @@ function hfsView(opened, repoRoot) {
33
33
  profile: opened.repo.profile,
34
34
  side: opened.repo.side ?? null,
35
35
  edition: opened.repo.edition ?? 'full',
36
+ ...(opened.repo.grammar === undefined ? {} : { grammar: opened.repo.grammar }),
36
37
  apps: opened.repo.apps,
37
38
  connections: opened.repo.connections,
38
39
  patterns: Object.freeze([...(opened.repo.patterns ?? [])]),