@starci/hfs 4.7.34 → 4.7.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +8 -11
- package/codemod/button-intent.mjs +356 -0
- package/lint/run.mjs +70 -29
- package/package.json +3 -2
- package/runtime/knowledge/grammars/index.yaml +111 -0
- package/runtime/knowledge/grammars/offset-pop/playbook.yaml +302 -0
- package/runtime/knowledge/grammars/starci/playbook.yaml +1190 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +22 -8
- package/runtime/knowledge/hfs/fe-stack.yaml +12 -4
- package/runtime/knowledge/hfs/rules.yaml +163 -83
- package/runtime/knowledge/hfs/slots.yaml +3 -3
- package/runtime/knowledge/ui/presentation/font.yaml +101 -0
- package/runtime/knowledge/ui/presentation/padding.yaml +217 -0
- package/runtime/modules/kernel/failure-codes.yaml +102 -20
- package/runtime/modules/kernel/removed-vocabulary.yaml +29 -0
- package/runtime/modules/kernel/roles.yaml +1 -1
- package/runtime/packages/cli/src/catalog.generated.mjs +119 -9
- package/runtime/scripts/gates/canon-pins.mjs +103 -0
- package/runtime/scripts/gates/gate.mjs +3 -36
- package/runtime/scripts/gates/output.mjs +40 -0
- package/runtime/scripts/gates/test-world-run.mjs +2 -4
- package/runtime/scripts/guards/release-definition.mjs +22 -3
- package/runtime/scripts/hfs/architecture/config.mjs +8 -8
- package/runtime/scripts/hfs/check.mjs +1 -1
- package/runtime/scripts/hfs/db-restamp-verb.mjs +2 -2
- package/runtime/scripts/hfs/declaration-shape.mjs +8 -2
- package/runtime/scripts/hfs/grammar-presentation.mjs +152 -0
- package/runtime/scripts/hfs/rule-catalog.mjs +2 -2
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -0
- package/runtime/scripts/hfs/rules/fe-stack.mjs +44 -1
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/hfs/view.mjs +1 -0
- package/runtime/scripts/lib/canon-binding-text.mjs +129 -0
- package/runtime/scripts/lib/css-rules.mjs +18 -0
- package/runtime/scripts/lib/example-refs.mjs +1 -1
- package/runtime/scripts/lib/grammar-lint.mjs +48 -0
- package/runtime/scripts/lib/grammar-vendor-parity-jsx.mjs +360 -0
- package/runtime/scripts/lib/grammar-vendor-parity.mjs +110 -157
- package/runtime/scripts/lib/numeric-match.mjs +5 -0
- package/runtime/scripts/lib/release-version.mjs +119 -0
- package/runtime/scripts/lib/runtime-publication-tags.mjs +3 -46
- package/runtime/scripts/lib/source-package-imports.mjs +111 -0
- package/runtime/scripts/work/draw/draw-layer.mjs +5 -4
- package/scaffold/app.mjs +7 -5
- package/src/main.mjs +30 -15
- package/stack/config.mjs +2 -1
- package/stack/render.mjs +24 -3
- package/sync/index.mjs +8 -2
- package/templates/app/skeleton/.starcistacks/dev/infra/compose/stack.yaml +0 -1
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/stack.yaml +0 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +0 -1
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +0 -1
- package/templates/fe/tool-config/eslint.config.mjs +4 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// GENERATED by scripts/cli/gen-catalog.mjs — do not edit
|
|
2
2
|
export const CATALOG = {
|
|
3
3
|
"schema": "starci/cli-catalog@1",
|
|
4
|
-
"hash": "
|
|
4
|
+
"hash": "3ac967b6f4bb2136cbc7b29a8da5d28994140c84c3543ef0748a379e4667c443",
|
|
5
5
|
"global": [
|
|
6
6
|
{
|
|
7
7
|
"name": "json",
|
|
@@ -634,7 +634,7 @@ export const CATALOG = {
|
|
|
634
634
|
"runtimeSide": "host"
|
|
635
635
|
},
|
|
636
636
|
"check": {
|
|
637
|
-
"summary": "
|
|
637
|
+
"summary": "run the app ESLint canons and judge the tree, managed output, contracts, formatting and architecture",
|
|
638
638
|
"owner": "@starci/hfs",
|
|
639
639
|
"flags": [
|
|
640
640
|
{
|
|
@@ -661,7 +661,7 @@ export const CATALOG = {
|
|
|
661
661
|
"exit": {
|
|
662
662
|
"0": "clean",
|
|
663
663
|
"1": "findings",
|
|
664
|
-
"2": "refusal or bad usage"
|
|
664
|
+
"2": "a tool could not run, refusal or bad usage"
|
|
665
665
|
},
|
|
666
666
|
"json": "flag",
|
|
667
667
|
"examples": [
|
|
@@ -789,6 +789,58 @@ export const CATALOG = {
|
|
|
789
789
|
],
|
|
790
790
|
"runtimeSide": "host"
|
|
791
791
|
},
|
|
792
|
+
"codemod": {
|
|
793
|
+
"summary": "migrate front-end button variants to declared intent and report cases requiring a human decision",
|
|
794
|
+
"owner": "@starci/hfs",
|
|
795
|
+
"positional": [
|
|
796
|
+
{
|
|
797
|
+
"name": "migration",
|
|
798
|
+
"enum": [
|
|
799
|
+
"button-intent"
|
|
800
|
+
],
|
|
801
|
+
"required": true,
|
|
802
|
+
"variadic": false
|
|
803
|
+
}
|
|
804
|
+
],
|
|
805
|
+
"flags": [
|
|
806
|
+
{
|
|
807
|
+
"name": "write",
|
|
808
|
+
"type": "boolean",
|
|
809
|
+
"summary": "apply safe source edits; otherwise preview without changing source"
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
"name": "format",
|
|
813
|
+
"type": "enum",
|
|
814
|
+
"enum": [
|
|
815
|
+
"json",
|
|
816
|
+
"markdown"
|
|
817
|
+
],
|
|
818
|
+
"default": "markdown",
|
|
819
|
+
"summary": "migration report format"
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
"name": "report",
|
|
823
|
+
"type": "string",
|
|
824
|
+
"summary": "also save the complete report to a .json or .md file"
|
|
825
|
+
}
|
|
826
|
+
],
|
|
827
|
+
"exit": {
|
|
828
|
+
"0": "migration has no unresolved cases",
|
|
829
|
+
"1": "unresolved cases require a human decision",
|
|
830
|
+
"2": "refusal or bad usage"
|
|
831
|
+
},
|
|
832
|
+
"json": "flag",
|
|
833
|
+
"examples": [
|
|
834
|
+
"starci app codemod button-intent",
|
|
835
|
+
"starci app codemod button-intent --write --format json --report button-intent.json"
|
|
836
|
+
],
|
|
837
|
+
"editions": [
|
|
838
|
+
"full",
|
|
839
|
+
"lite"
|
|
840
|
+
],
|
|
841
|
+
"impl": null,
|
|
842
|
+
"runtimeSide": "host"
|
|
843
|
+
},
|
|
792
844
|
"coverage": {
|
|
793
845
|
"summary": "merge the sharded unit coverage into one lcov report, or prove that report at 100 per file",
|
|
794
846
|
"owner": "@starci/hfs",
|
|
@@ -3124,7 +3176,7 @@ export const CATALOG = {
|
|
|
3124
3176
|
"json": "always",
|
|
3125
3177
|
"examples": [
|
|
3126
3178
|
"starci gate unit --root <app> --out unit-run.json",
|
|
3127
|
-
"starci gate unit --root <app> --tests orders --subjects '
|
|
3179
|
+
"starci gate unit --root <app> --tests orders --subjects '<subject-path>' --out unit-run.json"
|
|
3128
3180
|
],
|
|
3129
3181
|
"editions": [
|
|
3130
3182
|
"full"
|
|
@@ -3269,7 +3321,7 @@ export const CATALOG = {
|
|
|
3269
3321
|
"full"
|
|
3270
3322
|
],
|
|
3271
3323
|
"impl": {
|
|
3272
|
-
"module": "scripts/
|
|
3324
|
+
"module": "scripts/guards/git-commit.mjs",
|
|
3273
3325
|
"export": "gitCommit"
|
|
3274
3326
|
},
|
|
3275
3327
|
"effect": "local-write",
|
|
@@ -8617,13 +8669,14 @@ export const CATALOG = {
|
|
|
8617
8669
|
],
|
|
8618
8670
|
"exit": {
|
|
8619
8671
|
"0": "the candidate CI of the release sha was green, then main plus its tag were pushed atomically, or --plan found nothing missing",
|
|
8620
|
-
"1": "refused (RELEASE_CANDIDATE_CI_RED, RELEASE_CANDIDATE_CI_TIMEOUT, RELEASE_CI_UNKNOWN, dirty tree, no or wrong tag, release notes incomplete, version not moved past the remote main, suite red, main moved, secret scan) and nothing was pushed",
|
|
8672
|
+
"1": "refused (RELEASE_CANDIDATE_CI_RED, RELEASE_CANDIDATE_CI_TIMEOUT, RELEASE_CI_UNKNOWN, dirty tree, no or wrong tag, release notes incomplete, version not moved past the remote main, RELEASE_VERSION_NOT_NEXT, suite red, main moved, secret scan) and nothing was pushed",
|
|
8621
8673
|
"2": "bad usage"
|
|
8622
8674
|
},
|
|
8623
8675
|
"json": "flag",
|
|
8624
8676
|
"examples": [
|
|
8625
8677
|
"starci release cut",
|
|
8626
8678
|
"starci release cut --plan --tag v1.0.0-alpha.9",
|
|
8679
|
+
"starci release cut --tag v1.0.0-alpha.31.1",
|
|
8627
8680
|
"starci release cut --tag v1.0.0-alpha.9 --rows \"npm run test:e2e,linux-parity\"",
|
|
8628
8681
|
"starci release cut --repo <path> --tag v1.0.0-alpha.9 --json"
|
|
8629
8682
|
],
|
|
@@ -8634,6 +8687,9 @@ export const CATALOG = {
|
|
|
8634
8687
|
"script": "scripts/supervisor/release-cut-cli.mjs"
|
|
8635
8688
|
},
|
|
8636
8689
|
"conventions": [
|
|
8690
|
+
"Naming (owner ruling release-waves): the next release is alpha.N.M+1 of the highest v* tag (a wave or a fix) or the next line alpha.N+1,",
|
|
8691
|
+
"derived from the v* tags, never guessed (RELEASE_VERSION_NOT_NEXT); without --tag the refusal names both.",
|
|
8692
|
+
"The legacy -fix form is read and never cut.",
|
|
8637
8693
|
"OWNER RULING candidate-ci (2026-10-18): never again have main receive a release whose CI is red, with the owner confirming afterwards.",
|
|
8638
8694
|
"Before the suite, including --plan, the workflows the candidate CI waits for (release-cut.yaml candidate.workflows) must be readable,",
|
|
8639
8695
|
"else RELEASE_CI_UNKNOWN. The latest run on main is no longer read: a red main needs no owner override before the next release.",
|
|
@@ -8884,6 +8940,7 @@ export const CATALOG = {
|
|
|
8884
8940
|
"runtime-package refuses changed inputs or registry state after its cold proof and never performs rebind or example writes",
|
|
8885
8941
|
"runtime-package plans the upload channel and release-declared npm dist-tags, repairs stale tags without republishing, and reads every target back",
|
|
8886
8942
|
"runtime-package refuses an older release on its line and preserves tags pointing to newer releases on other lines",
|
|
8943
|
+
"every alpha.N and wave alpha.N.M share the one alpha line, so a wave never moves a dist-tag behind a later wave or line",
|
|
8887
8944
|
"publication never commits, pushes or creates Git tags; release cut owns those effects"
|
|
8888
8945
|
],
|
|
8889
8946
|
"runtimeSide": "tree"
|
|
@@ -14289,6 +14346,44 @@ export const CATALOG = {
|
|
|
14289
14346
|
},
|
|
14290
14347
|
"runtimeSide": "host"
|
|
14291
14348
|
},
|
|
14349
|
+
"export-repaints": {
|
|
14350
|
+
"summary": "export strict Grammar vendor findings as CSS repair assignments",
|
|
14351
|
+
"owner": "runtime",
|
|
14352
|
+
"flags": [
|
|
14353
|
+
{
|
|
14354
|
+
"name": "out-dir",
|
|
14355
|
+
"type": "string",
|
|
14356
|
+
"required": true,
|
|
14357
|
+
"summary": "directory for the repaint exports"
|
|
14358
|
+
},
|
|
14359
|
+
{
|
|
14360
|
+
"name": "root",
|
|
14361
|
+
"type": "string",
|
|
14362
|
+
"summary": "runtime root whose Grammar sources and vendor reference are judged"
|
|
14363
|
+
},
|
|
14364
|
+
{
|
|
14365
|
+
"name": "check",
|
|
14366
|
+
"type": "boolean",
|
|
14367
|
+
"summary": "report export drift without writing"
|
|
14368
|
+
}
|
|
14369
|
+
],
|
|
14370
|
+
"exit": {
|
|
14371
|
+
"0": "exports written or verified",
|
|
14372
|
+
"1": "export drift or unreadable judge inputs",
|
|
14373
|
+
"2": "bad usage"
|
|
14374
|
+
},
|
|
14375
|
+
"json": "none",
|
|
14376
|
+
"examples": [
|
|
14377
|
+
"starci work export-repaints --out-dir <directory>"
|
|
14378
|
+
],
|
|
14379
|
+
"editions": [
|
|
14380
|
+
"full"
|
|
14381
|
+
],
|
|
14382
|
+
"impl": {
|
|
14383
|
+
"script": "scripts/work/ui/export-repaints.mjs"
|
|
14384
|
+
},
|
|
14385
|
+
"runtimeSide": "host"
|
|
14386
|
+
},
|
|
14292
14387
|
"grammar-geometry": {
|
|
14293
14388
|
"summary": "emit grammar geometry guidance or check captured geometry",
|
|
14294
14389
|
"owner": "runtime",
|
|
@@ -14345,7 +14440,22 @@ export const CATALOG = {
|
|
|
14345
14440
|
{
|
|
14346
14441
|
"name": "write",
|
|
14347
14442
|
"type": "boolean",
|
|
14348
|
-
"summary": "rewrite
|
|
14443
|
+
"summary": "rewrite selected generated output; mutually exclusive with --check"
|
|
14444
|
+
},
|
|
14445
|
+
{
|
|
14446
|
+
"name": "check",
|
|
14447
|
+
"type": "boolean",
|
|
14448
|
+
"summary": "compare selected generated output without writing (the default); mutually exclusive with --write"
|
|
14449
|
+
},
|
|
14450
|
+
{
|
|
14451
|
+
"name": "intent-only",
|
|
14452
|
+
"type": "boolean",
|
|
14453
|
+
"summary": "scope generated output to DNA intent mapping blocks; may combine with --catalog-only"
|
|
14454
|
+
},
|
|
14455
|
+
{
|
|
14456
|
+
"name": "catalog-only",
|
|
14457
|
+
"type": "boolean",
|
|
14458
|
+
"summary": "scope generated output to the Button and IconButton intent props catalog; may combine with --intent-only"
|
|
14349
14459
|
}
|
|
14350
14460
|
],
|
|
14351
14461
|
"exit": {
|
|
@@ -14919,7 +15029,7 @@ export const CATALOG = {
|
|
|
14919
15029
|
{
|
|
14920
15030
|
"name": "family",
|
|
14921
15031
|
"type": "string",
|
|
14922
|
-
"summary": "
|
|
15032
|
+
"summary": "explicit registered Grammar family; otherwise read sides.fe.grammar from the app declaration"
|
|
14923
15033
|
},
|
|
14924
15034
|
{
|
|
14925
15035
|
"name": "elements",
|
|
@@ -14941,7 +15051,7 @@ export const CATALOG = {
|
|
|
14941
15051
|
"exit": {
|
|
14942
15052
|
"0": "brief printed or the score is clean",
|
|
14943
15053
|
"1": "a scoring finding",
|
|
14944
|
-
"2": "bad usage"
|
|
15054
|
+
"2": "bad usage or unavailable presentation source"
|
|
14945
15055
|
},
|
|
14946
15056
|
"json": "flag",
|
|
14947
15057
|
"examples": [
|
|
@@ -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;
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// pre-push hook (scripts/guards/release-push-gate.mjs) and the release cut (scripts/supervisor/release-cut.mjs). The remote
|
|
3
3
|
// main of the runtime moves exactly when a release is cut, so a push of main or of a v* tag is judged against this list:
|
|
4
4
|
// RELEASE_VERSION_UNCHANGED package.json `version` at the pushed commit differs from the version at the remote main's head
|
|
5
|
+
// RELEASE_VERSION_NOT_NEXT that version is the next release after the highest v* tag (scripts/lib/release-version.mjs, owner ruling release-waves):
|
|
6
|
+
// the next wave alpha.N.M+1 of its line or the next line alpha.N+1, never the legacy -fix form
|
|
5
7
|
// RELEASE_TAG_VERSION the release tag is v<version> of the pushed commit
|
|
6
8
|
// RELEASE_CHANGELOG_SECTION CHANGELOG.md holds `## [<version>] - <date>` (a dated heading) with no unfinished marker (R222)
|
|
7
9
|
// RELEASE_TAG_MISSING an ANNOTATED tag v<version> points at the pushed commit
|
|
@@ -17,6 +19,7 @@ import { tag as gitTag } from '../api/git/tag.mjs';
|
|
|
17
19
|
import { mergeBaseQuery } from '../api/git/merge-base-query.mjs';
|
|
18
20
|
import { pathKey, realPath } from '../lib/path-key.mjs';
|
|
19
21
|
import { changelogSection, releaseNotesFindings } from '../hfs/runtime-rules/release-notes.mjs';
|
|
22
|
+
import { nextReleases, releaseVersionRefusal, taggedVersions } from '../lib/release-version.mjs';
|
|
20
23
|
import { readL4Record } from './release-record.mjs';
|
|
21
24
|
import { suiteModeOf } from './release-suite-mode.mjs';
|
|
22
25
|
|
|
@@ -122,14 +125,27 @@ function releaseAncestor(repo, before, after) {
|
|
|
122
125
|
return null;
|
|
123
126
|
}
|
|
124
127
|
|
|
125
|
-
|
|
128
|
+
/** The next releases named from the released versions, for a fix text: "v<wave> or v<line>". */
|
|
129
|
+
const nextNamed = (released) => {
|
|
130
|
+
const next = nextReleases(released);
|
|
131
|
+
return next ? `v${next.wave} or v${next.line}` : 'v<new version>';
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
function versionFindings({ cwd, remoteCommit, version, released }) {
|
|
126
135
|
if (!remoteCommit) return [];
|
|
127
136
|
const remoteVersion = versionAt(cwd, remoteCommit);
|
|
128
137
|
if (remoteVersion === null) return [finding('RELEASE_REMOTE_HEAD_UNKNOWN', `the remote main head ${remoteCommit.slice(0, 9)} is not in this repository, so its version cannot be compared`, 'git fetch origin main')];
|
|
129
|
-
if (remoteVersion === version) return [finding('RELEASE_VERSION_UNCHANGED', `package.json version ${version} is the version the remote main already carries`, `
|
|
138
|
+
if (remoteVersion === version) return [finding('RELEASE_VERSION_UNCHANGED', `package.json version ${version} is the version the remote main already carries`, `set package.json version to the next release, move the CHANGELOG section to it, commit, then ${CUT_COMMAND(nextNamed(released))}`)];
|
|
130
139
|
return [];
|
|
131
140
|
}
|
|
132
141
|
|
|
142
|
+
/** The version must be the next release after the highest v* tag other than its own: a wave alpha.N.M is counted, never guessed. */
|
|
143
|
+
function nextReleaseFindings({ version, released }) {
|
|
144
|
+
if (released === null) return [finding('RELEASE_VERSION_NOT_NEXT', 'the v* tags are unreadable, so the next release cannot be derived', 'git fetch --tags')];
|
|
145
|
+
const refusal = releaseVersionRefusal(version, released);
|
|
146
|
+
return refusal ? [finding('RELEASE_VERSION_NOT_NEXT', refusal, `set package.json version to the next release, move the CHANGELOG section to it, commit, then ${CUT_COMMAND(nextNamed(released.filter((entry) => entry !== version)))}`)] : [];
|
|
147
|
+
}
|
|
148
|
+
|
|
133
149
|
function changelogFindings({ cwd, commit, version, tag }) {
|
|
134
150
|
const changelog = gitText(show, [`${commit}:CHANGELOG.md`], cwd);
|
|
135
151
|
if (changelog === null) return [finding('RELEASE_CHANGELOG_SECTION', 'CHANGELOG.md is not readable at the pushed commit', 'restore CHANGELOG.md')];
|
|
@@ -176,8 +192,11 @@ export function releaseFindings({ cwd, commit, remoteCommit = null, tag = null,
|
|
|
176
192
|
const expected = `v${version}`;
|
|
177
193
|
const named = tag ?? expected;
|
|
178
194
|
if (named !== expected) return [finding('RELEASE_TAG_VERSION', `the tag ${named} is not v${version}, the version of ${commit.slice(0, 9)}`, `tag v${version}, or set package.json version to ${named.replace(/^v/, '')}`)];
|
|
195
|
+
const listed = gitText(gitTag, ['--list', 'v*'], cwd);
|
|
196
|
+
const released = listed === null ? null : taggedVersions(listed);
|
|
179
197
|
return [
|
|
180
|
-
...versionFindings({ cwd,
|
|
198
|
+
...versionFindings({ cwd, remoteCommit, version, released: released ?? [] }),
|
|
199
|
+
...nextReleaseFindings({ version, released }),
|
|
181
200
|
...changelogFindings({ cwd, commit, version, tag: named }),
|
|
182
201
|
...(needTag ? tagFindings({ cwd, commit, tag: named }) : []),
|
|
183
202
|
...(needReceipt ? receiptFindings({ cwd, commit, tag: named }) : []),
|
|
@@ -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 {
|
|
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)
|
|
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
|
|