@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,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 ?? [])]),
@@ -0,0 +1,129 @@
1
+ // Canon release binding edits preserve authored profile text and update only the selected binding's identity.
2
+
3
+ const KEY = /^[A-Za-z_][A-Za-z0-9_-]*$/;
4
+
5
+ function supportedBinding(value) {
6
+ const keys = typeof value === 'string' ? value.split('.') : [];
7
+ return (keys.length === 1 && keys[0] === 'canon') || (keys.length === 2 && keys[0] === 'familyCanons' && KEY.test(keys[1]));
8
+ }
9
+
10
+ function lineEntry(line, index) {
11
+ const text = line.slice(0, line.length - lineEnding(line).length);
12
+ if (!text.trim() || text.trimStart().startsWith('#')) return null;
13
+ let indent = 0;
14
+ while (text[indent] === ' ') indent++;
15
+ const colon = text.indexOf(':', indent);
16
+ const key = colon < 0 ? '' : text.slice(indent, colon);
17
+ if (!KEY.test(key)) return { index, indent };
18
+ let valueAt = colon + 1;
19
+ while (text[valueAt] === ' ' || text[valueAt] === '\t') valueAt++;
20
+ return { index, indent, key, prefix: text.slice(0, valueAt), value: text.slice(valueAt) };
21
+ }
22
+
23
+ function lineEnding(line) {
24
+ if (line.endsWith('\r\n')) return '\r\n';
25
+ return line.endsWith('\n') ? '\n' : '';
26
+ }
27
+
28
+ function linesOf(text) {
29
+ const lines = [];
30
+ let start = 0;
31
+ for (let index = 0; index < text.length; index++) {
32
+ if (text[index] !== '\n') continue;
33
+ lines.push(text.slice(start, index + 1));
34
+ start = index + 1;
35
+ }
36
+ if (start < text.length) lines.push(text.slice(start));
37
+ return lines;
38
+ }
39
+
40
+ function child(entries, parent, key, at) {
41
+ const nested = entries.filter(entry => entry.index > parent.start && entry.index < parent.end && entry.indent > parent.indent);
42
+ const directIndent = Math.min(...nested.map(entry => entry.indent));
43
+ const found = nested.filter(entry => entry.indent === directIndent && entry.key === key);
44
+ if (found.length !== 1) throw new Error(`${at}: expected one ${key} field, found ${found.length}`);
45
+ const entry = found[0];
46
+ const boundary = entries.find(candidate => candidate.index > entry.index && candidate.index < parent.end && candidate.indent <= entry.indent);
47
+ return { ...entry, start: entry.index, end: boundary?.index ?? parent.end };
48
+ }
49
+
50
+ function mapping(entries, parent, key, at) {
51
+ const node = child(entries, parent, key, at);
52
+ if (node.value.trim() && !node.value.trim().startsWith('#')) throw new Error(`${at}.${key}: expected a block mapping`);
53
+ return node;
54
+ }
55
+
56
+ function scalar(entries, parent, key, at) {
57
+ const node = child(entries, parent, key, at);
58
+ if (!node.value.trim() || node.value.trim().startsWith('#')) throw new Error(`${at}.${key}: expected a scalar value`);
59
+ return node;
60
+ }
61
+
62
+ function scalarParts(value) {
63
+ const quote = value[0] === "'" || value[0] === '"' ? value[0] : null;
64
+ let end = quote ? value.indexOf(quote, 1) : 0;
65
+ if (quote && end < 1) return null;
66
+ if (quote) end++;
67
+ else {
68
+ while (end < value.length && value[end].trim() && !['#', "'", '"'].includes(value[end])) end++;
69
+ }
70
+ if (!end) return null;
71
+ let tail = end;
72
+ while (value[tail] === ' ' || value[tail] === '\t') tail++;
73
+ if (tail < value.length && (tail === end || value[tail] !== '#')) return null;
74
+ return { value: quote ? value.slice(1, end - 1) : value.slice(0, end), suffix: value.slice(end) };
75
+ }
76
+
77
+ function packageValue(value) {
78
+ return scalarParts(value)?.value;
79
+ }
80
+
81
+ function replaceScalar(lines, node, value) {
82
+ const parts = scalarParts(node.value);
83
+ if (!parts) throw new Error(`Cannot replace malformed scalar ${node.key}`);
84
+ lines[node.index] = `${node.prefix}${value}${parts.suffix}${lineEnding(lines[node.index])}`;
85
+ }
86
+
87
+ function bindingNode(entries, root, row) {
88
+ const at = `profiles.${row.profile}.${row.binding}`;
89
+ let node = mapping(entries, root, 'profiles', 'document');
90
+ node = mapping(entries, node, row.profile, 'profiles');
91
+ for (const key of row.binding.split('.')) node = mapping(entries, node, key, at);
92
+ const declared = scalar(entries, node, 'package', at);
93
+ if (packageValue(declared.value) !== row.package) throw new Error(`${at}: package does not match ${row.package}`);
94
+ return { node, at };
95
+ }
96
+
97
+ /**
98
+ * Render release version and digest updates at their exact profile binding paths without reserializing authored YAML.
99
+ * Unrelated bytes, comments and newline style are preserved. Missing or duplicate mapping paths, unexpected packages,
100
+ * incomplete digest blocks and invalid replacement identities throw before any caller writes the returned text.
101
+ * @param {string} original - The authored code-pattern profile YAML.
102
+ * @param {Array<{profile: string, binding: string, package: string, to: {version: string, value: string, files: number}}>} rows - Exact canon or family binding identities to replace.
103
+ * @returns {string} Updated profile YAML; the input is returned unchanged when no rows are supplied.
104
+ */
105
+ export function renderCanonBindingChanges(original, rows) {
106
+ if (typeof original !== 'string' || !Array.isArray(rows)) throw new Error('canon binding changes need YAML text and identity rows');
107
+ const lines = linesOf(original);
108
+ const entries = lines.map((line, index) => lineEntry(line, index)).filter(Boolean);
109
+ const root = { start: -1, end: lines.length, indent: -1 };
110
+ const selected = new Set();
111
+ for (const row of rows) {
112
+ if (!KEY.test(row?.profile ?? '') || !supportedBinding(row?.binding) || typeof row?.package !== 'string' || !row.package) throw new Error('canon binding changes need a supported profile, package and binding path');
113
+ const identity = `${row.profile}.${row.binding}`;
114
+ if (selected.has(identity)) throw new Error(`${identity}: duplicate binding change`);
115
+ selected.add(identity);
116
+ if (!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/.test(row.to?.version ?? '') || !/^[a-f0-9]{64}$/i.test(row.to?.value ?? '')
117
+ || !Number.isSafeInteger(row.to?.files) || row.to.files < 1) throw new Error(`${identity}: invalid replacement version or digest`);
118
+ const { node, at } = bindingNode(entries, root, row);
119
+ const version = scalar(entries, node, 'version', at);
120
+ const digest = mapping(entries, node, 'contentDigest', at);
121
+ const value = scalar(entries, digest, 'value', `${at}.contentDigest`);
122
+ const files = scalar(entries, digest, 'files', `${at}.contentDigest`);
123
+ replaceScalar(lines, version, row.to.version);
124
+ // A hexadecimal digest can contain digits only; quoting preserves its string identity in YAML.
125
+ replaceScalar(lines, value, `'${row.to.value}'`);
126
+ replaceScalar(lines, files, row.to.files);
127
+ }
128
+ return lines.join('');
129
+ }
@@ -218,3 +218,21 @@ export function utilityFamilies(utility) {
218
218
  export function ruleFamilies(rule) {
219
219
  return [...new Set([...rule.apply.flatMap(utilityFamilies), ...rule.declarations.map((entry) => propertyFamily(entry.property)).filter(Boolean)])].sort(byCodeUnit);
220
220
  }
221
+
222
+ // Container composition is layout even when the vendor sets a default for it. Keep this separate from
223
+ // ruleFamilies: the vendor census records every declaration, while the strict judge measures paint.
224
+ const LAYOUT_FAMILIES = new Set([
225
+ 'gap', 'inline-size', 'display', 'order',
226
+ 'flex', 'flex-basis', 'flex-direction', 'flex-flow', 'flex-grow', 'flex-shrink', 'flex-wrap',
227
+ 'align-content', 'align-items', 'align-self',
228
+ 'justify-content', 'justify-items', 'justify-self',
229
+ 'place-content', 'place-items', 'place-self',
230
+ 'grid', 'grid-template', 'grid-template-areas', 'grid-template-columns', 'grid-template-rows',
231
+ 'grid-auto-columns', 'grid-auto-flow', 'grid-auto-rows', 'grid-area',
232
+ 'grid-column', 'grid-column-start', 'grid-column-end', 'grid-row', 'grid-row-start', 'grid-row-end',
233
+ ]);
234
+
235
+ /** The paint families a strict Grammar may only change through a declared vendor override. */
236
+ export function repaintFamilies(rule) {
237
+ return ruleFamilies(rule).filter((family) => !LAYOUT_FAMILIES.has(family));
238
+ }
@@ -51,7 +51,7 @@ export const REFERENCE_EXAMPLE_APP = 'full-app';
51
51
 
52
52
  /** The grammar css families a product repository may declare (`[data-grammar-family="<id>"]`); the
53
53
  * runtime's own product maps to the core sheet. */
54
- export const GRAMMAR_FAMILIES = Object.freeze({ starci: 'core', nivo: 'nivo' });
54
+ export const GRAMMAR_FAMILIES = Object.freeze({ starci: 'core', nivo: 'nivo', 'offset-pop': 'offset-pop' });
55
55
 
56
56
  /** A feature-folder segment that is only the product's own name - no useful leaf (display-names pathLabel). */
57
57
  export const PRODUCT_NAME_SEGMENT = /^(nivo|starci|mia)[-\w]*$/i;
@@ -0,0 +1,48 @@
1
+ // Grammar-family lint selection reads the family catalog; an absent selection never chooses a family.
2
+ import fs from 'node:fs';
3
+ import { parseYamlCached } from './yaml-cached.mjs';
4
+
5
+ const CATALOG_FILE = new URL('../../knowledge/grammars/index.yaml', import.meta.url);
6
+
7
+ function selectedFamily(family) {
8
+ if (typeof family !== 'string' || !family.trim()) throw new Error('sides.fe.grammar must explicitly name the selected Grammar family');
9
+ const catalog = parseYamlCached(fs.readFileSync(CATALOG_FILE, 'utf8'));
10
+ const matches = (catalog.families ?? []).filter(entry => entry.id === family);
11
+ if (matches.length !== 1) throw new Error(`Grammar family ${family} has no declared lint contract in knowledge/grammars/index.yaml`);
12
+ return { catalog, selected: matches[0] };
13
+ }
14
+
15
+ /** The explicitly selected authored family entry; callers share the catalog lookup rather than inferring a family. */
16
+ export function grammarFamilyFor(family) {
17
+ return Object.freeze(selectedFamily(family).selected);
18
+ }
19
+
20
+ /** The declared family's lint contract, including an explicit null package when that family has no rules. */
21
+ export function grammarLintFor(family) {
22
+ const { selected } = selectedFamily(family);
23
+ if (!selected.lint || !Object.hasOwn(selected.lint, 'package')) {
24
+ throw new Error(`Grammar family ${family} has no declared lint contract in knowledge/grammars/index.yaml`);
25
+ }
26
+ const lint = selected.lint;
27
+ if (lint.package === null) return Object.freeze({ family, package: null });
28
+ if (typeof lint.package !== 'string' || !lint.package.startsWith('@starci/eslint-grammar-')
29
+ || typeof lint.factory !== 'string' || !/^[a-zA-Z][a-zA-Z0-9]*$/.test(lint.factory)
30
+ || typeof lint.prefix !== 'string' || !/^[a-z][a-z0-9-]*$/.test(lint.prefix)) {
31
+ throw new Error(`Grammar family ${family} has an invalid lint contract in knowledge/grammars/index.yaml`);
32
+ }
33
+ return Object.freeze({ family, package: lint.package, factory: lint.factory, prefix: lint.prefix });
34
+ }
35
+
36
+ /** The selected family's shared component/export contract, resolved through its catalog reference. */
37
+ export function grammarContractFor(family) {
38
+ const { catalog, selected } = selectedFamily(family);
39
+ const owners = (catalog.families ?? []).filter(entry => entry.id === selected.contractRef);
40
+ const contract = owners.length === 1 ? owners[0].componentContract : null;
41
+ if (!contract || typeof contract.package !== 'string' || !contract.package
42
+ || typeof contract.entry !== 'string' || !contract.entry.startsWith(`${contract.package}/`)
43
+ || typeof contract.styleEntry !== 'string' || !contract.styleEntry.startsWith(`${contract.package}/`)
44
+ || !Array.isArray(contract.peers) || !contract.peers.every(peer => typeof peer === 'string' && peer.trim())) {
45
+ throw new Error(`Grammar family ${family} has no valid component contract in knowledge/grammars/index.yaml`);
46
+ }
47
+ return Object.freeze({ ...contract, peers: Object.freeze([...contract.peers]) });
48
+ }