@starci/hfs 4.4.0 → 4.5.0

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 (113) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +1 -1
  3. package/ci/cli.mjs +64 -0
  4. package/ci/github.mjs +41 -0
  5. package/ci/secret.mjs +28 -0
  6. package/ci/vars.mjs +33 -0
  7. package/ci/verify.mjs +36 -0
  8. package/db/backup-receipt.mjs +129 -0
  9. package/db/dump.mjs +66 -0
  10. package/db/migrate.mjs +8 -5
  11. package/db/post-cutover.mjs +4 -4
  12. package/db/reset.mjs +125 -0
  13. package/emit/contracts.mjs +2 -2
  14. package/emit/db-types.mjs +100 -7
  15. package/lint/run.mjs +30 -8
  16. package/package.json +2 -1
  17. package/release/ci-red.mjs +70 -0
  18. package/release/gate.mjs +53 -13
  19. package/release/index.mjs +8 -6
  20. package/release/push.mjs +1 -1
  21. package/report/sonar.mjs +1 -1
  22. package/runtime/config.example.yaml +7 -6
  23. package/runtime/engine/config.mjs +10 -18
  24. package/runtime/engine/removed-vocabulary.mjs +0 -2
  25. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  26. package/runtime/knowledge/hfs/rules.yaml +505 -971
  27. package/runtime/knowledge/hfs/slots.yaml +13 -24
  28. package/runtime/modules/kernel/failure-codes.yaml +60 -22
  29. package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
  30. package/runtime/modules/models/runtimes.yaml +4 -6
  31. package/runtime/modules/schemas/application-stacks.schema.yaml +131 -1
  32. package/runtime/packages/cli/src/catalog.generated.mjs +286 -398
  33. package/runtime/scripts/api/git/lib.mjs +3 -3
  34. package/runtime/scripts/api/sops/lib.mjs +1 -1
  35. package/runtime/scripts/hfs/app-wiring.mjs +2 -2
  36. package/runtime/scripts/hfs/architecture/config.mjs +1 -1
  37. package/runtime/scripts/hfs/architecture/index.mjs +1 -1
  38. package/runtime/scripts/hfs/architecture/supabase.mjs +1 -1
  39. package/runtime/scripts/hfs/architecture/tiers.mjs +1 -1
  40. package/runtime/scripts/hfs/architecture.mjs +1 -1
  41. package/runtime/scripts/hfs/ci-declaration.mjs +102 -0
  42. package/runtime/scripts/hfs/coverage-from.mjs +1 -1
  43. package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
  44. package/runtime/scripts/hfs/example-secrets.mjs +1 -1
  45. package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
  46. package/runtime/scripts/hfs/param-names.mjs +1 -1
  47. package/runtime/scripts/hfs/rule-catalog.mjs +3 -1
  48. package/runtime/scripts/hfs/rules/a15-findings.mjs +10 -1
  49. package/runtime/scripts/hfs/rules/app-workflow-findings.mjs +10 -0
  50. package/runtime/scripts/hfs/rules/assets.mjs +1 -1
  51. package/runtime/scripts/hfs/rules/ci-custody.mjs +104 -0
  52. package/runtime/scripts/hfs/rules/cli.mjs +1 -1
  53. package/runtime/scripts/hfs/rules/contract-compat.mjs +1 -1
  54. package/runtime/scripts/hfs/rules/contract.mjs +1 -1
  55. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +6 -19
  56. package/runtime/scripts/hfs/rules/fe-build-args.mjs +142 -0
  57. package/runtime/scripts/hfs/rules/frontend-tree.mjs +2 -2
  58. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
  59. package/runtime/scripts/hfs/rules/supply-chain.mjs +40 -3
  60. package/runtime/scripts/hfs/rules/workflow-contexts.mjs +103 -0
  61. package/runtime/scripts/hfs/runtime-rules/workflow-source.mjs +2 -2
  62. package/runtime/scripts/hfs/slots.mjs +2 -2
  63. package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
  64. package/runtime/scripts/lib/env.mjs +2 -2
  65. package/runtime/scripts/lib/git.mjs +1 -1
  66. package/runtime/scripts/lib/i18n.mjs +0 -2
  67. package/runtime/scripts/lib/language.mjs +1 -1
  68. package/runtime/scripts/lib/roots.mjs +2 -4
  69. package/runtime/scripts/lib/secret-patterns.mjs +2 -2
  70. package/scaffold/add-fe-overlay.mjs +0 -121
  71. package/scaffold/add-fe-page.mjs +0 -268
  72. package/scaffold/supabase-ports.mjs +1 -1
  73. package/src/main.mjs +17 -6
  74. package/stack/age-install.mjs +1 -1
  75. package/stack/cli.mjs +2 -2
  76. package/stack/context.mjs +14 -2
  77. package/stack/digest.mjs +1 -1
  78. package/stack/docker-transport.mjs +93 -15
  79. package/stack/edge-alias.mjs +1 -1
  80. package/stack/image-scan.mjs +1 -1
  81. package/stack/ingress-create.mjs +2 -2
  82. package/stack/ingress.mjs +3 -3
  83. package/stack/release-store.mjs +12 -7
  84. package/stack/remote.mjs +16 -8
  85. package/stack/sealed.mjs +5 -5
  86. package/stack/ssh-session.mjs +178 -0
  87. package/sync/ci-commands.mjs +103 -1
  88. package/sync/format.mjs +1 -1
  89. package/sync/hygiene.mjs +1 -1
  90. package/sync/index.mjs +43 -18
  91. package/sync/quality-services.mjs +61 -0
  92. package/sync/readme-badges.mjs +44 -0
  93. package/sync/stack-images.mjs +31 -5
  94. package/templates/app/ci-workflows/github/workflows/ci.yml +26 -24
  95. package/templates/app/ci-workflows/github/workflows/images.yml +24 -2
  96. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +8 -2
  97. package/templates/app/ci-workflows/install-steps.yml +21 -0
  98. package/templates/app/ci-workflows/sonar-steps.yml +3 -9
  99. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +28 -35
  100. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +2 -1
  101. package/templates/app/ci-workflows-lite/github/workflows/images.yml +24 -2
  102. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +8 -2
  103. package/templates/app/quality-config/codecov.yml +6 -6
  104. package/templates/app/quality-config/sonar-project.properties +1 -0
  105. package/templates/app/readme-badges/README.md +1 -0
  106. package/templates/app/skeleton/.github/dependabot.yml +1 -14
  107. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +61 -3
  108. package/templates/app/skeleton/README.md +6 -0
  109. package/templates/app/skeleton-lite/.github/dependabot.yml +1 -14
  110. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +57 -3
  111. package/templates/app/skeleton-lite/README.md +6 -0
  112. package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +52 -1
  113. package/upgrade/index.mjs +1 -1
@@ -0,0 +1,142 @@
1
+ // R300 FE_PUBLIC_BUILD_ARG_MISSING: every required browser build value has a Dockerfile ARG default or a buildArgs name in every environment deploying that app; literal fallback reads are optional.
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { loadTypescript } from '../../lib/package-at.mjs';
5
+ import { parseDockerfile, words } from '../../lib/dockerfile.mjs';
6
+ import { parseYaml } from '../../../engine/yaml.mjs';
7
+ import { byCodeUnit } from '../../lib/list.mjs';
8
+ import { found, readText } from './read.mjs';
9
+
10
+ export const FE_PUBLIC_BUILD_ARG_MISSING = 'FE_PUBLIC_BUILD_ARG_MISSING';
11
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
12
+ const PUBLIC = /^NEXT_PUBLIC_[A-Z0-9_]+$/u;
13
+ const SOURCE = /\.[cm]?[jt]sx?$/u;
14
+ const SCHEMA = /(?:^|[/.-])(?:env|schema)(?:[/.-]|$)/u;
15
+ const DECLARATION = '.starcistacks/application-stacks.yaml';
16
+
17
+ function declarationOf(root) {
18
+ try { return parseYaml(readText(root, DECLARATION) ?? '') ?? {}; } catch { return {}; }
19
+ }
20
+
21
+ /** Parentheses and TypeScript assertions do not alter whether a read has a literal fallback. */
22
+ function unwrappedParent(ts, node) {
23
+ let current = node;
24
+ while (current.parent && (ts.isParenthesizedExpression(current.parent) || ts.isAsExpression(current.parent) || ts.isNonNullExpression(current.parent))) current = current.parent;
25
+ return current;
26
+ }
27
+
28
+ function literalFallback(ts, node) {
29
+ const current = unwrappedParent(ts, node);
30
+ const parent = current.parent;
31
+ if (!parent || !ts.isBinaryExpression(parent) || parent.left !== current) return false;
32
+ const fallback = parent.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken || parent.operatorToken.kind === ts.SyntaxKind.BarBarToken;
33
+ return fallback && ts.isStringLiteralLike(parent.right);
34
+ }
35
+
36
+ function publicRead(ts, node) {
37
+ if (!ts.isPropertyAccessExpression(node) || !PUBLIC.test(node.name.text)) return null;
38
+ const owner = node.expression;
39
+ const env = ts.isIdentifier(owner) && owner.text === 'env';
40
+ const processEnv = ts.isPropertyAccessExpression(owner) && owner.name.text === 'env' && ts.isIdentifier(owner.expression) && owner.expression.text === 'process';
41
+ return env || processEnv ? node.name.text : null;
42
+ }
43
+
44
+ function optionalSchema(ts, node) {
45
+ if (!node) return false;
46
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
47
+ if (['optional', 'default', 'catch', 'nullish'].includes(node.expression.name.text)) return true;
48
+ return optionalSchema(ts, node.expression.expression);
49
+ }
50
+ return false;
51
+ }
52
+
53
+ function schemaKey(ts, node) {
54
+ if (!ts.isPropertyAssignment(node) && !ts.isPropertySignature(node)) return null;
55
+ const name = node.name?.text;
56
+ if (!PUBLIC.test(name ?? '') || node.questionToken || optionalSchema(ts, node.initializer)) return null;
57
+ return name;
58
+ }
59
+
60
+ function sourceRequirements(ts, file, text) {
61
+ const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
62
+ const required = new Set();
63
+ const schema = SCHEMA.test(file);
64
+ const visit = node => {
65
+ const name = publicRead(ts, node);
66
+ if (name && !literalFallback(ts, node)) required.add(name);
67
+ const key = schema ? schemaKey(ts, node) : null;
68
+ if (key) required.add(key);
69
+ ts.forEachChild(node, visit);
70
+ };
71
+ visit(source);
72
+ return required;
73
+ }
74
+
75
+ /** JSON/YAML environment schemas declare required names explicitly; optional properties impose no build requirement. */
76
+ function schemaRequirements(text) {
77
+ let schema;
78
+ try { schema = parseYaml(text); } catch { return []; }
79
+ const names = [];
80
+ const visit = value => {
81
+ if (!value || typeof value !== 'object') return;
82
+ if (Array.isArray(value.required)) names.push(...value.required.filter(name => typeof name === 'string' && PUBLIC.test(name)));
83
+ Object.values(value).forEach(child => { if (child && typeof child === 'object') visit(child); });
84
+ };
85
+ visit(schema);
86
+ return names;
87
+ }
88
+
89
+ function requiredNames(root, files, app, ts) {
90
+ const prefix = `fe/apps/${app}/`;
91
+ const names = new Set();
92
+ for (const file of files.filter(file => file.startsWith(prefix))) {
93
+ const local = file.slice(prefix.length);
94
+ const source = SOURCE.test(local) && (local.startsWith('src/') || /^next\.config\./u.test(local) || SCHEMA.test(local));
95
+ const schema = SCHEMA.test(local) && /\.(?:json|ya?ml)$/u.test(local);
96
+ if (!source && !schema) continue;
97
+ const text = readText(root, file);
98
+ if (text === null) continue;
99
+ const required = source ? sourceRequirements(ts, local, text) : schemaRequirements(text);
100
+ for (const name of required) names.add(name);
101
+ }
102
+ return [...names].sort(byCodeUnit);
103
+ }
104
+
105
+ function dockerDefaults(text) {
106
+ return new Set(parseDockerfile(text).instructions.filter(item => item.keyword === 'ARG')
107
+ .flatMap(item => words(item.text)).filter(word => word.includes('='))
108
+ .map(word => word.slice(0, word.indexOf('='))));
109
+ }
110
+
111
+ function missingForEnvironment(environment, app, required) {
112
+ const images = Object.values(environment?.deploy?.images ?? {}).filter(image => image?.app === app);
113
+ if (!images.length) return [];
114
+ return required.filter(name => images.some(image => {
115
+ const args = image.buildArgs ?? {};
116
+ return !Object.hasOwn(args.values ?? {}, name) && !(args.sealed?.keys ?? []).includes(name);
117
+ }));
118
+ }
119
+
120
+ function appFindings(context, app, environments, ts) {
121
+ const { repoRoot, files } = context;
122
+ const file = `fe/apps/${app}/Dockerfile`;
123
+ if (!files.includes(file)) return [];
124
+ const defaults = dockerDefaults(readText(repoRoot, file) ?? '');
125
+ const required = requiredNames(repoRoot, files, app, ts).filter(name => !defaults.has(name));
126
+ const missing = Object.entries(environments).flatMap(([environment, entry]) => {
127
+ const names = missingForEnvironment(entry, app, required);
128
+ return names.length ? [{ environment, names }] : [];
129
+ });
130
+ if (!missing.length) return [];
131
+ const detail = missing.map(entry => `${entry.environment}: ${entry.names.join(', ')}`).join('; ');
132
+ return [found(FE_PUBLIC_BUILD_ARG_MISSING, file, `${FE_PUBLIC_BUILD_ARG_MISSING}: ${file} requires public build values without ARG defaults or deploy image buildArgs (${detail}).`, { missing })];
133
+ }
134
+
135
+ /** Judge front-end Dockerfiles at the app root in both editions; only names are read, and sealed members are never opened. */
136
+ export function feBuildArgsFindings({ repoRoot, files = [], all = files, repo, isRoot = true }) {
137
+ if (!repoRoot || !isRoot || !repo?.sides?.fe) return [];
138
+ const ts = loadTypescript(repoRoot, HERE);
139
+ if (!ts) return [];
140
+ const environments = declarationOf(repoRoot).environments ?? {};
141
+ return repo.sides.fe.apps.flatMap(app => appFindings({ repoRoot, files: all }, app.name, environments, ts));
142
+ }
@@ -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;
@@ -1,5 +1,5 @@
1
1
  // R269: app repositories in both editions declare dependency updates, a production audit gate, and image SBOM/provenance.
2
- import { parseWorkflow } from '../runtime-rules/workflow-source.mjs';
2
+ import { parseWorkflow, workflowFindings } from '../runtime-rules/workflow-source.mjs';
3
3
  import { found, readText } from './read.mjs';
4
4
 
5
5
  const DEPENDABOT = '.github/dependabot.yml';
@@ -85,12 +85,48 @@ function stepsOf(workflow) {
85
85
  return Object.values(workflow?.jobs ?? {}).filter(enabled).flatMap((job) => Array.isArray(job.steps) ? job.steps.filter(enabled) : []);
86
86
  }
87
87
 
88
+ /** npm groups admit only minor and patch updates; canon packages are ignored for every update type. */
89
+ function hasNpmPolicy(update) {
90
+ const groups = update.groups && typeof update.groups === 'object' && !Array.isArray(update.groups) ? Object.values(update.groups) : [];
91
+ const minorPatch = (group) => Array.isArray(group?.['update-types']) && group['update-types'].includes('minor') && group['update-types'].includes('patch');
92
+ return groups.length > 0 && groups.every((group) => Array.isArray(group?.['update-types']) && group['update-types'].length > 0 && group['update-types'].every((type) => ['minor', 'patch'].includes(type))) &&
93
+ groups.some((group) => minorPatch(group) && [undefined, 'version-updates'].includes(group['applies-to'])) &&
94
+ groups.some((group) => group['applies-to'] === 'security-updates') &&
95
+ Array.isArray(update.ignore) && update.ignore.some((entry) => entry?.['dependency-name'] === '@starci/*' && entry.versions === undefined && entry['update-types'] === undefined);
96
+ }
97
+
88
98
  function hasDependabot(document) {
89
99
  const directory = (value) => typeof value === 'string' && value.startsWith('/');
90
100
  return document?.version === 2 && Array.isArray(document.updates) && ECOSYSTEMS.every((ecosystem) => document.updates.some((update) =>
91
101
  update?.['package-ecosystem'] === ecosystem &&
92
102
  (directory(update.directory) || Array.isArray(update.directories) && update.directories.length > 0 && update.directories.every(directory)) &&
93
- ['daily', 'weekly', 'monthly'].includes(update.schedule?.interval)));
103
+ ['daily', 'weekly', 'monthly'].includes(update.schedule?.interval))) &&
104
+ document.updates.filter((update) => update?.['package-ecosystem'] === 'npm').every(hasNpmPolicy);
105
+ }
106
+
107
+ function pinnedAction(value) {
108
+ return typeof value === 'string' && (/^\.\/[\w./-]+$/.test(value) ||
109
+ /^[\w.-]+\/[\w.-]+(?:\/[\w.-]+)*@[a-f\d]{40}$/i.test(value) ||
110
+ /^docker:\/\/[^\s@]+@sha256:[a-f\d]{64}$/i.test(value));
111
+ }
112
+
113
+ /** Mark YAML keys before parsing so comments and script scalars are ignored while flow maps and quoted keys retain their source lines. */
114
+ function workflowPinFindings({ path: file, text }) {
115
+ const marked = text.replace(/(^|[\s{,])(?:"uses"|'uses'|uses)([ \t]*:)/gm, (match, prefix, colon, offset) => {
116
+ const line = text.slice(0, offset + prefix.length).split('\n').length;
117
+ return `${prefix}__hfs_action_uses_line_${line}__${colon}`;
118
+ });
119
+ const findings = [];
120
+ function visit(value) {
121
+ if (!value || typeof value !== 'object') return;
122
+ for (const [key, member] of Object.entries(value)) {
123
+ const line = /^__hfs_action_uses_line_(\d+)__$/.exec(key)?.[1];
124
+ if (line && !pinnedAction(member)) findings.push(found('HFS_WORKFLOW_PIN', file, `${file}:${line}: every remote uses entry needs a full 40-hex commit SHA; container actions need a sha256 digest.`, { line: Number(line) }));
125
+ visit(member);
126
+ }
127
+ }
128
+ visit(parseWorkflow(marked));
129
+ return findings;
94
130
  }
95
131
 
96
132
  function hasAudit(steps) {
@@ -160,8 +196,9 @@ export function supplyChainFindings({ repoRoot, files, all = files, repo, isRoot
160
196
  const tracked = new Set(all);
161
197
  const parsed = (file) => tracked.has(file) ? parseWorkflow(readText(repoRoot, file) ?? '') : null;
162
198
  const findings = [];
163
- if (inScope(DEPENDABOT) && !hasDependabot(parsed(DEPENDABOT))) findings.push(found('HFS_DEPENDABOT_MISSING', DEPENDABOT, 'Declare Dependabot version 2 updates for npm, github-actions and docker, each with a directory and schedule.'));
199
+ if (inScope(DEPENDABOT) && !hasDependabot(parsed(DEPENDABOT))) findings.push(found('HFS_DEPENDABOT_MISSING', DEPENDABOT, 'Declare Dependabot version 2 updates for npm, github-actions and docker, each with a directory and schedule; every npm update must group minor+patch version updates, restrict every group to minor/patch (never major), include a security-updates group, and ignore all @starci/* updates (canon pins move only by starci upgrade).'));
164
200
  if (inScope(CI) && !hasAudit(stepsOf(parsed(CI)))) findings.push(found('HFS_AUDIT_STEP_MISSING', CI, 'CI must run npm audit --omit=dev in an enabled step whose failure stops the job.'));
165
201
  if (inScope(IMAGES) && !imageEvidence(stepsOf(parsed(IMAGES)))) findings.push(found('HFS_SBOM_MISSING', IMAGES, 'The images workflow must generate an SBOM and provenance for its release images in enabled steps.'));
202
+ findings.push(...workflowFindings({ files: [...tracked].filter((file) => inScope(file)), read: (file) => readText(repoRoot, file) }, workflowPinFindings));
166
203
  return findings;
167
204
  }
@@ -0,0 +1,103 @@
1
+ // R299: GitHub Actions expressions use only contexts available at their workflow key.
2
+ import { parseWorkflow } from '../runtime-rules/workflow-source.mjs';
3
+ import { found } from './read.mjs';
4
+ import { appWorkflowFindings } from './app-workflow-findings.mjs';
5
+
6
+ const WORKFLOW = ['github', 'inputs', 'vars'];
7
+ const JOB_IF = ['github', 'needs', 'vars', 'inputs'];
8
+ const JOB = [...JOB_IF, 'strategy', 'matrix'];
9
+ const JOB_ENV = [...JOB, 'secrets'];
10
+ const EXECUTION = [...JOB_ENV, 'job', 'runner', 'env'];
11
+ const CONTEXTS = [...EXECUTION, 'steps'];
12
+ const JOB_KEYS = new Set(['name', 'runs-on', 'concurrency', 'strategy', 'timeout-minutes', 'continue-on-error', 'with']);
13
+
14
+ /** Container and service images are chosen before a runner exists; their other properties use execution contexts. */
15
+ function containerContexts(keys) {
16
+ if (keys[2] === 'services') return keys[4] === 'image' ? JOB : EXECUTION;
17
+ return keys.length === 3 || keys[3] === 'image' ? JOB : EXECUTION;
18
+ }
19
+
20
+ /** The most specific row of GitHub's Context availability table governs each descendant value. */
21
+ function jobContexts(keys) {
22
+ const key = keys[2];
23
+ if (key === 'steps') return CONTEXTS;
24
+ if (key === 'if') return JOB_IF;
25
+ if (key === 'strategy') return JOB_IF;
26
+ if (key === 'env' || key === 'secrets') return JOB_ENV;
27
+ if (key === 'outputs') return CONTEXTS;
28
+ if (key === 'defaults' && keys[3] === 'run') return [...JOB, 'env'];
29
+ if (key === 'environment') return keys[3] === 'url' ? CONTEXTS.filter((context) => context !== 'secrets') : JOB;
30
+ if (key === 'container' || key === 'services') return containerContexts(keys);
31
+ if (JOB_KEYS.has(key)) return JOB;
32
+ return [];
33
+ }
34
+
35
+ /** Unlisted workflow keys admit no contexts; reusable workflow outputs alone may read jobs. */
36
+ function allowedContexts(keys) {
37
+ if (keys[0] === 'jobs') return jobContexts(keys);
38
+ if (keys[0] === 'env') return [...WORKFLOW, 'secrets'];
39
+ if (keys[0] === 'run-name' || keys[0] === 'concurrency') return WORKFLOW;
40
+ if (keys.slice(0, 3).join('.') === 'on.workflow_call.inputs' && keys[4] === 'default') return WORKFLOW;
41
+ if (keys.slice(0, 3).join('.') === 'on.workflow_call.outputs' && keys[4] === 'value') return [...WORKFLOW, 'jobs'];
42
+ return [];
43
+ }
44
+
45
+ /** Strings and property names are not context roots; bracket access and case-insensitive roots still are. */
46
+ function expressionContexts(expression) {
47
+ const unquoted = expression.replace(/'(?:[^']|'')*'/g, ' ');
48
+ const tokens = unquoted.match(/[A-Za-z_][\w-]*|[^\s]/g) ?? [];
49
+ const roots = tokens.filter((token, index) => tokens[index - 1] !== '.' &&
50
+ ([...CONTEXTS, 'jobs'].includes(token.toLowerCase()) || ['.', '['].includes(tokens[index + 1]))).map((token) => token.toLowerCase());
51
+ return [...new Set(roots)];
52
+ }
53
+
54
+ /** A closing delimiter inside an Actions single-quoted string cannot end an expression. */
55
+ function expressionEnd(value, start) {
56
+ let quoted = false;
57
+ for (let index = start; index < value.length; index++) {
58
+ if (value[index] === "'") quoted = !quoted;
59
+ if (!quoted && value.slice(index, index + 2) === '}}') return index;
60
+ }
61
+ return value.length;
62
+ }
63
+
64
+ /** Read marked expressions without ambiguous regex repetition or losing delimiters inside string literals. */
65
+ function expressions(value) {
66
+ const result = [];
67
+ const marker = /\$\{\{ __hfs_context_line_(\d+)__ /g;
68
+ let match;
69
+ while ((match = marker.exec(value)) !== null) {
70
+ const end = expressionEnd(value, marker.lastIndex);
71
+ result.push({ line: Number(match[1]), body: value.slice(marker.lastIndex, end) });
72
+ marker.lastIndex = end + 2;
73
+ }
74
+ return result;
75
+ }
76
+
77
+ /** Source markers travel inside parsed scalar expressions, preserving flow maps, quoted keys and repeated values without matching comments. */
78
+ function workflowContextFindings({ path: file, text }) {
79
+ const marked = text.replace(/\$\{\{/g, (match, offset) => `${match} __hfs_context_line_${text.slice(0, offset).split('\n').length}__ `);
80
+ const findings = [];
81
+ function inspect(value, keys) {
82
+ const allowed = allowedContexts(keys);
83
+ for (const { line, body } of expressions(value)) {
84
+ for (const context of expressionContexts(body)) {
85
+ if (allowed.includes(context)) continue;
86
+ const keyPath = keys.join('.');
87
+ findings.push(found('HFS_WORKFLOW_CONTEXT', file, `HFS_WORKFLOW_CONTEXT: ${file}:${line}: ${keyPath} forbids context ${context}.`, { line, keyPath, context }));
88
+ }
89
+ }
90
+ }
91
+ function visit(value, keys = []) {
92
+ if (typeof value === 'string') inspect(value, keys);
93
+ if (!value || typeof value !== 'object') return;
94
+ for (const [key, member] of Object.entries(value)) visit(member, [...keys, key]);
95
+ }
96
+ visit(parseWorkflow(marked));
97
+ return findings;
98
+ }
99
+
100
+ /** Judge every tracked workflow in scope at the full or lite app root, using the shared workflow selector. */
101
+ export function workflowContextsFindings(ctx) {
102
+ return appWorkflowFindings(ctx, workflowContextFindings, { dedupe: true });
103
+ }
@@ -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';
@@ -43,11 +43,11 @@ const SECRET_NAME = [
43
43
  String.raw`[A-Za-z_$][A-Za-z0-9_$.-]*\s*[:=]\s*`,
44
44
  ].join('');
45
45
 
46
- // `password = "hunter2hunter2"` — the quoted literal. `env(NAME)` is a reference, never a secret.
46
+ // `password = "example-password-1"` — the quoted literal. `env(NAME)` is a reference, never a secret.
47
47
  const ASSIGNED_QUOTED = new RegExp(
48
48
  SECRET_NAME + String.raw`['"\`](?!env\([A-Z_][A-Z0-9_]*\)['"\`])([^'"\`\n\s$<{]{12,})['"\`]`,
49
49
  );
50
- // `DB_PASSWORD=hunter2hunter2` — the compose list-item and dotenv form; the dotenv value has no `:` so
50
+ // `DB_PASSWORD=example-password-1` — the compose list-item and dotenv form; the dotenv value has no `:` so
51
51
  // `password: secret:db/password` and `token: ref:vault/api` stay references, and the end boundary keeps
52
52
  // calls and member/index references such as `secret = credentialsSource.current` out of the literals.
53
53
  const ASSIGNED_UNQUOTED = new RegExp(
@@ -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
- }