@starci/hfs 4.7.33 → 4.7.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -1
- package/README.md +8 -11
- package/lint/run.mjs +70 -29
- package/package.json +2 -2
- package/runtime/knowledge/grammars/index.yaml +102 -0
- package/runtime/knowledge/grammars/offset-pop/playbook.yaml +302 -0
- package/runtime/knowledge/grammars/starci/playbook.yaml +1159 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +22 -8
- package/runtime/knowledge/hfs/fe-stack.yaml +7 -0
- package/runtime/knowledge/hfs/rules.yaml +133 -83
- package/runtime/knowledge/hfs/slots.yaml +3 -3
- package/runtime/knowledge/ui/presentation/font.yaml +101 -0
- package/runtime/knowledge/ui/presentation/padding.yaml +216 -0
- package/runtime/modules/kernel/failure-codes.yaml +40 -20
- package/runtime/modules/kernel/removed-vocabulary.yaml +29 -0
- package/runtime/packages/cli/src/catalog.generated.mjs +7 -7
- package/runtime/scripts/gates/canon-pins.mjs +103 -0
- package/runtime/scripts/gates/gate.mjs +3 -36
- package/runtime/scripts/gates/output.mjs +40 -0
- package/runtime/scripts/gates/test-world-run.mjs +2 -4
- package/runtime/scripts/hfs/architecture/config.mjs +8 -8
- package/runtime/scripts/hfs/check.mjs +1 -1
- package/runtime/scripts/hfs/db-restamp-verb.mjs +2 -2
- package/runtime/scripts/hfs/declaration-shape.mjs +8 -2
- package/runtime/scripts/hfs/grammar-presentation.mjs +152 -0
- package/runtime/scripts/hfs/rule-catalog.mjs +2 -2
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -0
- package/runtime/scripts/hfs/rules/fe-stack.mjs +44 -1
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/hfs/view.mjs +1 -0
- package/runtime/scripts/lib/canon-binding-text.mjs +129 -0
- package/runtime/scripts/lib/example-refs.mjs +1 -1
- package/runtime/scripts/lib/grammar-lint.mjs +48 -0
- package/runtime/scripts/lib/grammar-vendor-parity.mjs +31 -111
- package/runtime/scripts/lib/grammar-vendor-source.mjs +345 -0
- package/runtime/scripts/lib/numeric-match.mjs +5 -0
- package/runtime/scripts/lib/source-package-imports.mjs +111 -0
- package/runtime/scripts/work/draw/draw-layer.mjs +5 -4
- package/scaffold/app.mjs +7 -5
- package/src/main.mjs +26 -13
- package/stack/config.mjs +2 -1
- package/stack/render.mjs +24 -3
- package/sync/index.mjs +8 -2
- package/templates/app/skeleton/.starcistacks/dev/infra/compose/stack.yaml +0 -1
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/stack.yaml +0 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +0 -1
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +0 -1
- package/templates/fe/tool-config/eslint.config.mjs +4 -1
|
@@ -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 {
|
|
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(`${
|
|
468
|
-
|
|
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
|
-
|
|
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
|
|
277
|
-
* source, every app and every workspace package that depends on
|
|
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,
|
|
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 {
|
|
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 { MIGRATIONS_DIR
|
|
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. */
|
|
@@ -56,7 +56,7 @@ function restampPlan(root, mapFile, { local = false, ports = {} } = {}) {
|
|
|
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
58
|
const active = dbSqlFiles(root);
|
|
59
|
-
const occupied = new Set(
|
|
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]) => {
|
|
62
62
|
const entry = mapEntry(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
|
|
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 ?? [])]),
|