@starci/hfs 4.4.0 → 4.4.1

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 (69) hide show
  1. package/CHANGELOG.md +3 -0
  2. package/README.md +1 -1
  3. package/db/migrate.mjs +4 -4
  4. package/db/post-cutover.mjs +4 -4
  5. package/emit/contracts.mjs +2 -2
  6. package/emit/db-types.mjs +2 -2
  7. package/lint/run.mjs +2 -2
  8. package/package.json +1 -1
  9. package/release/gate.mjs +4 -4
  10. package/release/index.mjs +2 -2
  11. package/release/push.mjs +1 -1
  12. package/report/sonar.mjs +1 -1
  13. package/runtime/config.example.yaml +7 -6
  14. package/runtime/engine/config.mjs +10 -18
  15. package/runtime/engine/removed-vocabulary.mjs +0 -2
  16. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  17. package/runtime/knowledge/hfs/rules.yaml +410 -967
  18. package/runtime/knowledge/hfs/slots.yaml +13 -24
  19. package/runtime/modules/kernel/failure-codes.yaml +0 -22
  20. package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
  21. package/runtime/modules/models/runtimes.yaml +4 -6
  22. package/runtime/packages/cli/src/catalog.generated.mjs +46 -381
  23. package/runtime/scripts/api/git/lib.mjs +3 -3
  24. package/runtime/scripts/api/sops/lib.mjs +1 -1
  25. package/runtime/scripts/hfs/app-wiring.mjs +2 -2
  26. package/runtime/scripts/hfs/architecture/config.mjs +1 -1
  27. package/runtime/scripts/hfs/architecture/index.mjs +1 -1
  28. package/runtime/scripts/hfs/architecture/supabase.mjs +1 -1
  29. package/runtime/scripts/hfs/architecture/tiers.mjs +1 -1
  30. package/runtime/scripts/hfs/architecture.mjs +1 -1
  31. package/runtime/scripts/hfs/coverage-from.mjs +1 -1
  32. package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
  33. package/runtime/scripts/hfs/example-secrets.mjs +1 -1
  34. package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
  35. package/runtime/scripts/hfs/param-names.mjs +1 -1
  36. package/runtime/scripts/hfs/rule-catalog.mjs +3 -1
  37. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  38. package/runtime/scripts/hfs/rules/assets.mjs +1 -1
  39. package/runtime/scripts/hfs/rules/cli.mjs +1 -1
  40. package/runtime/scripts/hfs/rules/contract-compat.mjs +1 -1
  41. package/runtime/scripts/hfs/rules/contract.mjs +1 -1
  42. package/runtime/scripts/hfs/rules/frontend-tree.mjs +2 -2
  43. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
  44. package/runtime/scripts/hfs/runtime-rules/workflow-source.mjs +2 -2
  45. package/runtime/scripts/hfs/slots.mjs +2 -2
  46. package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
  47. package/runtime/scripts/lib/env.mjs +2 -2
  48. package/runtime/scripts/lib/git.mjs +1 -1
  49. package/runtime/scripts/lib/i18n.mjs +0 -2
  50. package/runtime/scripts/lib/language.mjs +1 -1
  51. package/runtime/scripts/lib/roots.mjs +2 -4
  52. package/scaffold/add-fe-overlay.mjs +0 -121
  53. package/scaffold/add-fe-page.mjs +0 -268
  54. package/scaffold/supabase-ports.mjs +1 -1
  55. package/stack/age-install.mjs +1 -1
  56. package/stack/cli.mjs +2 -2
  57. package/stack/context.mjs +1 -1
  58. package/stack/digest.mjs +1 -1
  59. package/stack/edge-alias.mjs +1 -1
  60. package/stack/image-scan.mjs +1 -1
  61. package/stack/ingress-create.mjs +2 -2
  62. package/stack/ingress.mjs +3 -3
  63. package/stack/remote.mjs +2 -2
  64. package/stack/sealed.mjs +5 -5
  65. package/sync/format.mjs +1 -1
  66. package/sync/hygiene.mjs +1 -1
  67. package/sync/index.mjs +2 -2
  68. package/sync/stack-images.mjs +2 -2
  69. package/upgrade/index.mjs +1 -1
@@ -17,7 +17,7 @@ import { byCodeUnit } from '../lib/list.mjs';
17
17
  import { trimTrailingSlashes } from './trailing-slashes.mjs';
18
18
 
19
19
  /** The component that holds the shared infrastructure: the platform tier and every capability more than one service app composes. */
20
- export const PLATFORM_COMPONENT = 'platform';
20
+ const PLATFORM_COMPONENT = 'platform';
21
21
  const SERVICE_APP_KINDS = new Set(['api', 'worker']);
22
22
  /** `text` with every `<placeholder>` (a `<`, at least one character, the next `>`) replaced by `*`; a `<` with no closing `>` stays. */
23
23
  export const star = (text) => {
@@ -135,7 +135,7 @@ function commandEnvironment() {
135
135
  }
136
136
 
137
137
  /** Run an installed app command in a sealed temporary copy, returning its exit code and preserving report artifacts. */
138
- export async function runMaterializedCommand(repoRoot, command, args = [], { run = runProgram } = {}) {
138
+ async function runMaterializedCommand(repoRoot, command, args = [], { run = runProgram } = {}) {
139
139
  return withMaterializedExample(repoRoot, '.', (app) => {
140
140
  const launch = commandLaunch(command, args);
141
141
  const result = run(launch.file, launch.args, { cwd: app, env: commandEnvironment(), stdio: 'inherit', windowsHide: true });
@@ -56,8 +56,6 @@ export const unitRolesOf = (manifest) => manifest.ruleParams.be.unitRoles;
56
56
  /** The roles that carry logic (ruleParams.be.logicRoles): measured at 100 per file inside a coverage-required slot. */
57
57
  export const logicRolesOf = (manifest) => manifest.ruleParams.be.logicRoles;
58
58
 
59
- /** The roles a feature-tier file is judged thin by (ruleParams.be.thinRoles, R203 BE_FEATURE_THIN). */
60
- export const thinRolesOf = (manifest) => manifest.ruleParams.be.thinRoles;
61
59
 
62
60
  /** Shape problems of ruleParams.be.unitRoles: unique roles of the suffix vocabulary, each spec `<role>.spec`. */
63
61
  export function unitRolesProblems(be) {
@@ -86,7 +84,7 @@ export const MANIFEST_KINDS = [APP_KIND, RUNTIME_KIND];
86
84
  export const manifestKind = (m) => (isPlainObject(m) && m.kind !== undefined ? m.kind : APP_KIND);
87
85
  const strList = (v) => stringList(v);
88
86
 
89
- const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'editionWhy', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'templateNa', 'exemplifiedBy'];
87
+ const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'editionWhy', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'templateNa', 'exampleNa', 'exemplifiedBy'];
90
88
  /** The fields a slot's `lite` overlay may hold: the same keys it would carry in the slot body, resolved under edition lite. */
91
89
  const LITE_OVERLAY_KEYS = ['path', 'requires', 'allows', 'forbids', 'minInstances', 'requiredInstances', 'why'];
92
90
  /** A runtime slot has no app kind, side composition, layer or managed template; it may name the generator of a generated copy. */
@@ -220,6 +218,8 @@ function slotCoverageEvidenceProblems(slot, at) {
220
218
  const bad = [];
221
219
  if (slot.templateNa !== undefined && !(typeof slot.templateNa === 'string' && slot.templateNa.trim()))
222
220
  bad.push(`${at}: templateNa must be non-empty text`);
221
+ if (slot.exampleNa !== undefined && !(typeof slot.exampleNa === 'string' && slot.exampleNa.trim()))
222
+ bad.push(`${at}: exampleNa must be non-empty text`);
223
223
  if (slot.exemplifiedBy === undefined) return bad;
224
224
  const refs = slot.exemplifiedBy;
225
225
  const valid = Array.isArray(refs) && refs.length > 0 && refs.every(slotEvidenceRefValid)
@@ -1,6 +1,6 @@
1
1
  // param-names.mjs - the one shape of `ruleParams.be.paramNames` of knowledge/hfs/slots.yaml (R85 injected-param-name): a non-empty list of unique
2
2
  // entries `{type | typeSuffix, names, nameSuffix?}`. The loader of scripts/hfs/slots.mjs calls it; the schema (modules/schemas/hfs-slots.schema.yaml)
3
- // states the same shape and tests/hfs/hfs-slots.spec.mjs proves the two agree.
3
+ // states the same shape.
4
4
  import { isPlainObject } from '../../engine/plain-object.mjs';
5
5
 
6
6
  const UPPER = /^[A-Z][A-Za-z0-9]*$/u;
@@ -16,7 +16,7 @@ const RULE_KINDS = Object.freeze(['codemod', 'lint', 'check', 'design']);
16
16
  const FINDING_CODE = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/;
17
17
  const ENFORCER_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
18
18
  const FILE_ENFORCERS = new Set(['machine', 'hfs', 'work-validate', 'sonar', 'runtime']);
19
- const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'failureCodes', 'editions', 'codeEditions', 'enforcers', 'cases', 'docs', 'exemplifiedBy', 'proves', 'witness', 'editionWhy', 'amendedBy', 'templateNa']);
19
+ const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'failureCodes', 'editions', 'codeEditions', 'enforcers', 'cases', 'docs', 'exemplifiedBy', 'proves', 'witness', 'editionWhy', 'amendedBy', 'templateNa', 'specNa', 'exampleNa']);
20
20
  const CASE_KEYS = new Set(['id', 'date', 'source', 'ruling', 'quote', 'supersedes', 'status', 'staleWhen', 'amendedBy']);
21
21
  const CASE_DATE = /^\d{4}-\d{2}-\d{2}$/;
22
22
  const ENFORCER_KEYS = new Set(['kind', 'id', 'status', 'at', 'editions', 'editionWhy']);
@@ -279,6 +279,8 @@ function checkRule(rule, index, catalog, codeOwner, bad) {
279
279
  if (rule.amendedBy !== undefined) caseReferenceProblems(rule.amendedBy, `${label}.amendedBy`, bad);
280
280
  optionalText(rule, 'editionWhy', label, bad);
281
281
  optionalText(rule, 'templateNa', label, bad);
282
+ optionalText(rule, 'specNa', label, bad);
283
+ optionalText(rule, 'exampleNa', label, bad);
282
284
  for (const key of ['docs', 'exemplifiedBy', 'proves']) evidenceProblems(rule, key, label, bad);
283
285
  witnessProblems(rule, label, bad);
284
286
  enumListProblems(rule, label, 'kinds', RULE_KINDS, bad);
@@ -41,7 +41,7 @@ export const A15_CODES = [
41
41
  // codes-end
42
42
  ];
43
43
 
44
- export const FINDERS = [
44
+ const FINDERS = [
45
45
  // finders-begin
46
46
  featureFileFindings,
47
47
  lexiconFindings,
@@ -8,7 +8,7 @@ import { found } from './read.mjs';
8
8
 
9
9
  export const HFS_ASSET_OVERSIZE = 'HFS_ASSET_OVERSIZE';
10
10
  export const HFS_ASSET_KIND = 'HFS_ASSET_KIND';
11
- export const DEFAULT_ASSET_BYTES = 512000;
11
+ const DEFAULT_ASSET_BYTES = 512000;
12
12
  const BUILD_EXTENSIONS = ['.ttf', '.otf', '.woff', '.woff2', '.eot', '.png', '.svg', '.webp', '.jpg', '.jpeg', '.gif', '.ico', '.avif', '.bmp', '.tif', '.tiff'];
13
13
  const MEDIA_EXTENSIONS = new Set(['.mp3', '.mp4', '.wav', '.ogg', '.oga', '.opus', '.flac', '.aac', '.m4a', '.wma', '.aiff', '.aif', '.mid', '.midi', '.mov', '.m4v', '.webm', '.avi', '.mkv', '.mpeg', '.mpg', '.wmv', '.flv', '.3gp']);
14
14
  const IMAGE_EXTENSIONS = new Set(BUILD_EXTENSIONS.filter((ext) => !['.ttf', '.otf', '.woff', '.woff2', '.eot'].includes(ext)));
@@ -10,7 +10,7 @@ import { found } from './read.mjs';
10
10
  const CLI_REQUIRED = 'BE_CLI_REQUIRED';
11
11
 
12
12
  /** The one name of the cli app, its kind, and what its image is called below it. */
13
- export const CLI_APP = 'cli';
13
+ const CLI_APP = 'cli';
14
14
  const CLI_KIND = 'cli';
15
15
  const IMAGE = 'Dockerfile';
16
16
  const CLI_FEATURE_SLOT = 'be.cli';
@@ -14,7 +14,7 @@ import { isPlainObject } from '../../../engine/plain-object.mjs';
14
14
  import { found, readJson } from './read.mjs';
15
15
  import { byCodeUnit } from '../../lib/list.mjs';
16
16
 
17
- export const CONTRACT_BREAKING = 'BE_CONTRACT_BREAKING';
17
+ const CONTRACT_BREAKING = 'BE_CONTRACT_BREAKING';
18
18
 
19
19
  const CONTRACTS = /^be\/contracts\/([^/]+)\/events\.pin\.json$/;
20
20
 
@@ -15,7 +15,7 @@ import { safeRemove } from '../../api/fs/safe-remove.mjs';
15
15
  import { makeTempDir } from '../../api/fs/make-temp-dir.mjs';
16
16
  import { loadTypescript } from '../../lib/package-at.mjs';
17
17
 
18
- export const CONTRACT_SNAPSHOT_DRIFT = 'HFS_CONTRACT_SNAPSHOT_DRIFT';
18
+ const CONTRACT_SNAPSHOT_DRIFT = 'HFS_CONTRACT_SNAPSHOT_DRIFT';
19
19
  const GRAPHQL_TRANSPORT_SLOT = 'be.transport.graphql';
20
20
  const SNAPSHOT_OF = { graphql: 'schema.graphql', json: 'openapi.json' };
21
21
  const GRAPHQL_PLATFORM = /^src\/modules\/platform\/graphql(?:\/|\.ts$|$)/;
@@ -22,7 +22,7 @@ const I18N_CATALOG = 'FE_I18N_CATALOG';
22
22
  export const DEFAULT_LOCALE = 'vi';
23
23
  const ROUTE_FILE = /\/(?:page|layout|template|loading|error|not-found)\.[jt]sx?$/;
24
24
  const LOCALE = /^[a-z]{2,3}(?:-[A-Za-z0-9]+)*$/;
25
- export const WIRE_SCHEMA_STAMP = '.schema-sha256';
25
+ const WIRE_SCHEMA_STAMP = '.schema-sha256';
26
26
 
27
27
  /** Every leaf key of a parsed catalog as a dotted path. */
28
28
  function catalogKeys(value, prefix = '') {
@@ -32,7 +32,7 @@ function catalogKeys(value, prefix = '') {
32
32
  }
33
33
 
34
34
  /** Digest protocol for codegen: sorted app-relative schema path, NUL, LF-normalized source, NUL. */
35
- export function wireSchemaDigest(repoRoot, snapshots) {
35
+ function wireSchemaDigest(repoRoot, snapshots) {
36
36
  const hash = createHash('sha256');
37
37
  for (const snapshot of [...snapshots].sort(byCodeUnit)) {
38
38
  let source;
@@ -77,7 +77,7 @@ function providerSecretFindings(repoRoot, file, repo, findings) {
77
77
  }
78
78
 
79
79
  /** Raw L17 findings. The catalog-driven findings filter decides which editions judge them. */
80
- export function liteSecretCustodyFindings({ repoRoot, files, repo }) {
80
+ function liteSecretCustodyFindings({ repoRoot, files, repo }) {
81
81
  const findings = [];
82
82
  for (const file of files) {
83
83
  if (custodyPathFinding(file, repo, findings)) continue;
@@ -4,8 +4,8 @@ import { parseYaml } from '../../../engine/yaml.mjs';
4
4
 
5
5
  const WORKFLOW = /(?:^|\/)\.?(?:github\/)?workflows\/[^/]+\.ya?ml$/;
6
6
 
7
- /** The tracked workflow files of the runtime, its examples and the hfs templates (never a spec fixture under tests/). */
8
- const workflowFiles = (files) => files.filter((file) => WORKFLOW.test(file) && !file.startsWith('tests/'));
7
+ /** The tracked workflow files of the runtime, its examples and the hfs templates. */
8
+ const workflowFiles = (files) => files.filter((file) => WORKFLOW.test(file));
9
9
 
10
10
  /** The parsed workflow, or null when the text is not YAML (the other checks name that). */
11
11
  export function parseWorkflow(text) {
@@ -5,7 +5,7 @@
5
5
  // is this path tracked isTracked(P) / trackingOf(P); the rule catalog (knowledge/hfs/rules.yaml, modules/schemas/hfs-rules.schema.yaml) loads through loadRuleCatalog / rules().
6
6
  // Every check and lint rule of HFS reads knowledge/hfs/slots.yaml through this module; none keeps its own path
7
7
  // list. The manifest shape is modules/schemas/hfs-slots.schema.yaml and hfs.json is modules/schemas/hfs-repo.schema.yaml;
8
- // the installed runtime carries no npm dependency, so this file re-states those shapes instead of loading ajv (tests/hfs/hfs-slots.spec.mjs proves the two agree).
8
+ // the installed runtime carries no npm dependency, so this file re-states those shapes instead of loading ajv.
9
9
  //
10
10
  // A product is ONE app repository: hfs.json at the app root has kind `app` and declares its two sides, `be` and `fe`, each in
11
11
  // the folder of that name. The resolver of the app answers for the whole tree: a root path with the slots of profile `app`,
@@ -41,7 +41,7 @@ export { HfsSlotsError } from './slot-errors.mjs';
41
41
  export { loadRuleCatalog };
42
42
  export { rules } from './rule-catalog.mjs';
43
43
  export { APP_SCOPE };
44
- export const HFS_MANIFEST_FILE = 'knowledge/hfs/slots.yaml';
44
+ const HFS_MANIFEST_FILE = 'knowledge/hfs/slots.yaml';
45
45
  /** The manifest of kind runtime: the standard tree of the StarCi runtime repository (judged by scripts/hfs/runtime-check.mjs). */
46
46
  export const RUNTIME_MANIFEST_FILE = 'knowledge/hfs/runtime-slots.yaml';
47
47
  export const HFS_DECLARATION_FILE = 'hfs.json';
@@ -9,14 +9,6 @@ import { byCodeUnit } from '../lib/list.mjs';
9
9
  // builds afresh.
10
10
  const runs = new AsyncLocalStorage();
11
11
 
12
- /** A program run: run(fn) shares what is built inside fn; release() drops it. */
13
- export function typeScriptProgramRun() {
14
- const values = new Map();
15
- return {
16
- run: fn => runs.run(values, fn),
17
- release: () => values.clear(),
18
- };
19
- }
20
12
 
21
13
  function keyOf(value) {
22
14
  if (value === undefined) return 'u';
@@ -11,8 +11,8 @@ export const isSpecRun = (env = process.env) => Boolean(env.NODE_TEST_CONTEXT);
11
11
 
12
12
  /**
13
13
  * The environment for a child that is itself a test runner: `env` without the mark of an enclosing spec run. A `node --test` that inherits NODE_TEST_CONTEXT believes it is a
14
- * subtest of that run: it reports to its parent and exits 0 whatever its tests did, so a verb that runs specs as a child (starci test affected, the land gate, the release cut, the
15
- * suite) would pass red specs from inside a test process. The runner marks the spec files it starts itself, so isSpecRun stays true inside them.
14
+ * subtest of that run: it reports to its parent and exits 0 whatever its tests did, so a verb that runs specs as a child (the package clean-test, the scaffold e2e, the release cut)
15
+ * would pass red specs from inside a test process. The runner marks the spec files it starts itself, so isSpecRun stays true inside them.
16
16
  */
17
17
  export function withoutTestRunner(env = process.env) {
18
18
  const { NODE_TEST_CONTEXT: _enclosing, ...rest } = env;
@@ -12,7 +12,7 @@ export const unquoteDiffPath = (value) =>
12
12
 
13
13
  // git's repository-local variables (git rev-parse --local-env-vars). A hook or alias run in a linked worktree exports
14
14
  // GIT_DIR=<main>/.git/worktrees/<wt>; a spec inheriting it pointed every fixture git at the LIVE .claude repo and re-inited
15
- // it core.bare=true (2026-09-29, a6f60352c). tests/setup/isolated-registry.mjs drops the same list.
15
+ // it core.bare=true (2026-09-29, a6f60352c). e2e/setup/isolated-registry.mjs drops the same list.
16
16
  export const GIT_LOCAL_ENV_VARS = ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_IMPLICIT_WORK_TREE', 'GIT_PREFIX', 'GIT_CONFIG', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_COUNT', 'GIT_GRAFT_FILE', 'GIT_NO_REPLACE_OBJECTS', 'GIT_REPLACE_REF_BASE', 'GIT_SHALLOW_FILE', 'GIT_INTERNAL_SUPER_PREFIX'];
17
17
  /** `parent` (the caller's env) without git's repository-local variables (GIT_CONFIG_KEY_n/VALUE_n go with GIT_CONFIG_COUNT). Pure. */
18
18
  export function withoutGitLocalEnv(parent) {
@@ -49,8 +49,6 @@ export function loadCatalog(root = skillRoot, { scope = 'runtime' } = {}) {
49
49
  return cache.get(key);
50
50
  }
51
51
 
52
- /** Forget the cached catalogs (a spec that rewrites a catalog file). */
53
- export const resetCatalogCache = () => cache.clear();
54
52
 
55
53
  /** The names each `pattern` match captures (group 1) in `text`, in match order (`sort` sorts them). */
56
54
  export const captureNames = (text, pattern, { sort = false } = {}) => {
@@ -76,7 +76,7 @@ const I18N_CATALOG_DIR = 'modules/i18n/messages/';
76
76
  const OP_MANIFEST_VIETNAMESE_FIELDS = Object.freeze({ fields: Object.freeze(['vi']) });
77
77
  const OP_MANIFEST_DIR = 'modules/ops/ops/';
78
78
  /** The declared Vietnamese fields of `rel`: its own entry, or the op-manifest text field for a manifest of modules/ops/ops. */
79
- export const declaredVietnameseFieldsOf = (rel) => DECLARED_VIETNAMESE_FIELDS[rel]
79
+ const declaredVietnameseFieldsOf = (rel) => DECLARED_VIETNAMESE_FIELDS[rel]
80
80
  ?? (rel.startsWith(I18N_CATALOG_DIR) && !rel.slice(I18N_CATALOG_DIR.length).includes('/') && /\.ya?ml$/.test(rel) ? I18N_CATALOG_VIETNAMESE_FIELDS : null)
81
81
  ?? (rel.startsWith(OP_MANIFEST_DIR) && !rel.slice(OP_MANIFEST_DIR.length).includes('/') && /\.ya?ml$/.test(rel) ? OP_MANIFEST_VIETNAMESE_FIELDS : null);
82
82
 
@@ -17,16 +17,14 @@
17
17
  // directory or from the Work directory name outside this module.
18
18
  import fs from 'node:fs';
19
19
  import path from 'node:path';
20
- import { skillRoot, runtimeStateDir, starciLocalRoot, starciSourceRoot } from '../../engine/runtime-root.mjs';
20
+ import { skillRoot, starciLocalRoot, starciSourceRoot } from '../../engine/runtime-root.mjs';
21
21
  import { tempRoot } from '../../engine/temp-root.mjs';
22
22
 
23
23
  export { tempRoot, starciLocalRoot, starciSourceRoot };
24
24
 
25
25
  /** The runtime tree this code runs from. */
26
- export const runtimeTree = skillRoot;
26
+ const runtimeTree = skillRoot;
27
27
 
28
- /** The state directory of a runtime tree (default: this one). */
29
- export const runtimeState = (tree = runtimeTree) => runtimeStateDir(tree);
30
28
 
31
29
  /** The name of the Work records directory inside a product tree. */
32
30
  export const WORK_DIR_NAME = '.starciwork';
@@ -1,122 +1 @@
1
- import { createRequire } from "node:module";
2
- import path from "node:path";
3
- import { ScaffoldError, pascalOf } from "./service.mjs";
4
- import { feName, readFeContext, readFeFile, requireNewFeOwner, planFeCatalogs, writeFePlan } from "./add-fe-page.mjs";
5
1
 
6
- function loadTypeScript(root) {
7
- try { return createRequire(path.join(root, "package.json"))("typescript"); } catch {
8
- throw new ScaffoldError("HFS_ADD_TYPESCRIPT_MISSING", "add overlay reads the host page with its installed TypeScript compiler");
9
- }
10
- }
11
-
12
- function pageArrow(ts, source, symbol) {
13
- for (const statement of source.statements) {
14
- if (!ts.isVariableStatement(statement) || !statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue;
15
- const candidate = statement.declarationList.declarations.find((declaration) => declaration.name.getText(source) === symbol);
16
- if (candidate?.initializer && ts.isArrowFunction(candidate.initializer)) return candidate.initializer;
17
- }
18
- return null;
19
- }
20
-
21
- function pageRender(ts, arrow) {
22
- if (!arrow) return null;
23
- if (!ts.isBlock(arrow.body)) {
24
- let body = arrow.body;
25
- while (ts.isParenthesizedExpression(body)) body = body.expression;
26
- return body;
27
- }
28
- const returns = arrow.body.statements.filter(ts.isReturnStatement);
29
- const unconditional = arrow.body.statements.every((statement) => ts.isVariableStatement(statement) || ts.isExpressionStatement(statement) || ts.isReturnStatement(statement));
30
- if (!unconditional || returns.length !== 1) return null;
31
- let render = returns[0].expression;
32
- while (render && ts.isParenthesizedExpression(render)) render = render.expression;
33
- return render;
34
- }
35
-
36
- function mountOverlay({ ts, before, relative, pageSymbol, symbol }) {
37
- const source = ts.createSourceFile(relative, before, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
38
- const client = source.statements.some((statement) => ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression) && statement.expression.text === "use client");
39
- const render = pageRender(ts, pageArrow(ts, source, pageSymbol));
40
- const isJsx = render && (ts.isJsxElement(render) || ts.isJsxSelfClosingElement(render) || ts.isJsxFragment(render));
41
- if (source.parseDiagnostics.length || client || !isJsx) {
42
- throw new ScaffoldError("HFS_ADD_WIRE_INVALID", `${relative} must export the server ${pageSymbol} arrow with one unconditional JSX return`);
43
- }
44
- if (before.split(/[^A-Za-z0-9_]/).includes(symbol)) {
45
- throw new ScaffoldError("HFS_ADD_EXISTS", `${relative} already references ${symbol}`);
46
- }
47
- const from = render.getStart(source);
48
- const wrapped = ["(", " <>", ` ${before.slice(from, render.end)}`, ` <${symbol} />`, " </>", " )"].join("\n");
49
- const body = before.slice(0, from) + wrapped + before.slice(render.end);
50
- const imports = source.statements.filter(ts.isImportDeclaration);
51
- const last = imports.at(-1)?.end ?? 0;
52
- const importLine = `import { ${symbol} } from "@/features/overlays/${symbol}"`;
53
- return `${body.slice(0, last)}\n${importLine}${body.slice(last)}`;
54
- }
55
-
56
- function overlaySources(symbol, namespace) {
57
- return {
58
- entry: `"use client"
59
-
60
- import { useTranslations } from "next-intl"
61
- import { ${symbol}Base } from "./component"
62
-
63
- /** Resolve the overlay's copy at its client interaction boundary. */
64
- export const ${symbol} = () => {
65
- const t = useTranslations(${JSON.stringify(namespace)})
66
- return <${symbol}Base props={{ title: t("title"), open: t("open"), close: t("close"), description: t("description") }} />
67
- }
68
- `,
69
- component: `import { Button, Dialog, Text } from "@starci/grammar/common"
70
-
71
- /** Settled labels and content for the overlay's accessible dialog. */
72
- export interface ${symbol}BaseProps {
73
- readonly props: {
74
- readonly title: string
75
- readonly open: string
76
- readonly close: string
77
- readonly description: string
78
- }
79
- }
80
-
81
- /** Draw an accessible dialog with an intrinsic trigger and dismissal. */
82
- export const ${symbol}Base = (props: ${symbol}BaseProps) => (
83
- <Dialog
84
- title={props.props.title}
85
- closeLabel={props.props.close}
86
- trigger={<Button>{props.props.open}</Button>}
87
- >
88
- <Text>{props.props.description}</Text>
89
- </Dialog>
90
- )
91
- `,
92
- };
93
- }
94
-
95
- /** Add a localized client overlay and mount its public entry in a server feature page. */
96
- export function addFeOverlay({ root, app, name, page, messages, ts }) {
97
- feName(name, "overlay name");
98
- if (page === undefined) throw new ScaffoldError("HFS_ADD_OPTION_MISSING", "add overlay needs --page naming its server feature page");
99
- feName(page, "host page name");
100
- const context = readFeContext({ root, app });
101
- const symbol = `${pascalOf(name)}Overlay`;
102
- const owner = `${context.source}/features/overlays/${symbol}`;
103
- requireNewFeOwner(context, owner);
104
- const pageSymbol = `${pascalOf(page)}Page`;
105
- const relative = `${context.source}/features/pages/${pageSymbol}/index.tsx`;
106
- const before = readFeFile(context, relative);
107
- const body = mountOverlay({ ts: ts ?? loadTypeScript(context.root), before, relative, pageSymbol, symbol });
108
- const catalogs = planFeCatalogs(context, {
109
- group: "overlays", name, messages,
110
- defaults: {
111
- vi: { title: "Th\u00f4ng tin", open: "M\u1edf th\u00f4ng tin", close: "\u0110\u00f3ng", description: "Xem th\u00f4ng tin c\u1ee7a trang n\u00e0y." },
112
- en: { title: "Information", open: "Open information", close: "Close", description: "View information about this page." },
113
- },
114
- });
115
- const sources = overlaySources(symbol, catalogs.namespace);
116
- const created = [
117
- { relative: `${owner}/index.tsx`, body: sources.entry },
118
- { relative: `${owner}/component.tsx`, body: sources.component },
119
- ];
120
- const updates = [...catalogs.updates, { relative, before, body }];
121
- return { ...writeFePlan(context, created, updates), registered: { app: context.app, overlay: symbol, page: pageSymbol } };
122
- }