@starci/hfs 4.17.2 → 5.0.2

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 CHANGED
@@ -1,7 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.0.2
4
+
5
+ - Changed: Align bundled canon pins and exact sibling requirements with Grammar and its lint families at 0.21.6. Runtime behavior and rule severity are unchanged.
6
+
3
7
  ## Unreleased
4
8
 
9
+ ## 5.0.1 - 2026-11-02
10
+
11
+ - Fixed: function-subject provenance follows callable re-exports to the canonical declaration, so unit provider checks recognize the real subject through barrel exports while retaining invocation and dependency checks.
12
+
13
+ ## 5.0.0 - 2026-11-02
14
+
15
+ - Changed: repeatable `app check --gate` selectors dispatch to the existing check families. Sonar and Codecov selectors reuse the combined quality-baseline engine and retain its prediction labels.
16
+ - Changed: `--no-cache` invokes the existing recompute-and-refresh mode; ordinary `app check` retains content-keyed cache reuse.
17
+ - Breaking: replaced individual family flags and `app check --full` with `--gate <name>` and `--no-cache`.
18
+
19
+ ## 4.17.3 - 2026-11-02
20
+
21
+ - Changed: bundled canonical pins follow the consolidated Grammar 0.21.3 preservation API cohort; runtime rules are unchanged.
22
+
5
23
  ## 4.17.2 - 2026-11-02
6
24
 
7
25
  - Changed: bundled pins follow Grammar 0.21.2 with disabled tab items and its coherent canon cohort; existing runtime checks are unchanged.
package/README.md CHANGED
@@ -18,7 +18,7 @@ starci app add spec <file>.service.ts [--cwd <dir>]
18
18
  starci app add image [--cwd <dir>]
19
19
  starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--fix] [--json] [--out <sonar file>]
20
20
  starci app sync [--execute] [--cwd <dir>]
21
- starci app check [--cwd <dir>] [--json] [--full] [--hfs] [--format] [--db] [--secrets] [--wiring] [--types] [--lint] [--test] [--sonar] [--coverage-from <lcov>] [--quiet]
21
+ starci app check [--cwd <dir>] [--gate <hfs|format|db|secrets|wiring|types|lint|test|sonar|codecov> ...] [--no-cache] [--json] [--coverage-from <lcov>] [--quiet]
22
22
  starci app check --staged [--cwd <dir>]
23
23
  starci app upgrade [--execute]
24
24
  starci app world <up|down|status> [--stack <dir>] [--services <list>] [--k3d] [--force] [--json] [--cwd <dir>]
@@ -193,7 +193,7 @@ Its own checks (`scripts/hfs/check.mjs`, `scripts/hfs/rules/`; the rendered-file
193
193
  | `FE_NO_TESTS` | error | a front end holds a `*.spec.*`, `*.test.*` or `*-spec.*` file, an `e2e/`, `__tests__/`, `__mocks__/` or `test-support/` directory, a vitest, Playwright, jest or Cypress file, a test script, or a test dependency in a `package.json`; no exception (R97; `scripts/hfs/rules/fe-no-tests.mjs`) |
194
194
  | `HFS_GITIGNORE_BLOCK_DRIFT` | error | the managed `.gitignore` block differs from its render (R04; `sync/managed.mjs`) |
195
195
  | `HFS_SONAR_CONFIG` | error | `sonar-project.properties` differs from its render: no `sonar.host.url`, the `sonar.exclusions` of the installed jest preset, the be lcov import with the coverage scope the slot manifest derives as the only coverage scope (R11; `sync/managed.mjs`) |
196
- | `HFS_FORMAT` | error | a tracked file the repository's own prettier would change (R19; `sync/format.mjs`, run in batches by `--format`) |
196
+ | `HFS_FORMAT` | error | a tracked file the repository's own prettier would change (R19; `sync/format.mjs`, run in batches by the format family) |
197
197
  | `HFS_FORMAT_TOOL_MISSING` | refusal (exit 2) | prettier is not installed in the repository; the format check is never skipped |
198
198
 
199
199
  The managed files are judged by `sync/managed.mjs` and `sync/ts-strict.mjs` (the package renders the templates; the runtime copy does not). The list of managed
@@ -209,8 +209,11 @@ finding under the machine's own rule id (`BE_TIER_DIRECTION`, `HFS_UNUSED_EXPORT
209
209
  repository without `typescript` installed fails with `ARCH_TYPESCRIPT_MISSING`; the check never passes because it could not run.
210
210
 
211
211
  With no selector `starci app check` runs every family diff-only against the committed cache `.starcistacks/cache/` (contexts/project/check-cache.md) and
212
- writes what it judged back; the result equals a full run's. `--full` runs the selected families in full. A selector (`--hfs`, `--format`, `--db`, `--secrets`,
213
- `--wiring`, `--types`, `--lint`, `--test`, `--sonar`) runs one family; selectors combine. Each family prints `<family>: <full|diff> (<reason>), checked n, reused n;
212
+ writes what it judged back; the result equals a full run's. `--no-cache` runs selected work fresh and writes the refreshed cache records.
213
+ Repeat `--gate <name>` to combine `hfs`, `format`, `db`, `secrets`, `wiring`, `types`, `lint`, `test`, `sonar` and `codecov`.
214
+ The last two names dispatch to the same existing combined prediction family and execute it once, so ten public choices retain nine core families.
215
+ Predictions remain diagnostics; actual Sonar CI/release checks and Codecov CI uploads keep their existing ownership and verdicts.
216
+ `app check --staged` remains the separate pre-commit guard. Each family prints `<family>: <full|diff> (<reason>), checked n, reused n;
214
217
  findings n new, n known, n fixed`. Progress goes to stderr (`--quiet` silences it). `STARCI_APP_CHECK_CACHE=verify` runs diff then full and exits 2 on any difference;
215
218
  `STARCI_APP_CHECK_CACHE=full` never reads the cache (the release gate).
216
219
 
package/coverage/cli.mjs CHANGED
@@ -68,10 +68,10 @@ function recordFilesUnder(dir) {
68
68
  function mergeRecords(cwd, dir, out, merge) {
69
69
  const absolute = path.resolve(cwd, dir);
70
70
  if (!fs.existsSync(absolute) || !fs.statSync(absolute).isDirectory())
71
- throw new Error(`no ${dir} folder: the check cache holds no unit coverage yet (run starci app check --test)`);
71
+ throw new Error(`no ${dir} folder: the check cache holds no unit coverage yet (run starci app check --gate test)`);
72
72
  const records = recordFilesUnder(absolute).flatMap((file) => parseRecords(fs.readFileSync(file, 'utf8')).records).filter((record) => record.step === SPEC_STEP);
73
73
  if (records.length === 0)
74
- throw new Error(`no unit-spec record under ${dir}: the check cache holds no unit coverage yet (run starci app check --test)`);
74
+ throw new Error(`no unit-spec record under ${dir}: the check cache holds no unit coverage yet (run starci app check --gate test)`);
75
75
  const outDir = path.dirname(path.resolve(cwd, out));
76
76
  merge(records.map(sourcesOf), { outDir, reportRoot: path.dirname(outDir), recordRoot: cwd });
77
77
  return { specs: records.length };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.17.2",
3
+ "version": "5.0.2",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,35 +31,35 @@ pins:
31
31
  source: packages/event-stream/package.json
32
32
  why: 'the one server-sent-events client of every front end (fe-stack event-stream, owner:event-stream-package): the transport client layer (modules/api/sse/clients) creates one instance for the app with the bearer token source and the stream operations follow on it; it carries Last-Event-ID resume, the capped doubling backoff, the heartbeat timeout, the single 401 renewal and the useSWRSubscription adapter, so no app keeps a hand-written reader.'
33
33
  '@starci/grammar':
34
- version: 0.21.2
34
+ version: 0.21.6
35
35
  group: starci
36
36
  install: registry
37
37
  side: fe
38
38
  source: packages/grammar/package.json
39
39
  why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
40
40
  '@starci/eslint-canon-be':
41
- version: 3.11.9
41
+ version: 3.11.13
42
42
  group: starci
43
43
  install: registry
44
44
  side: be
45
45
  source: packages/eslint-canon-be/package.json
46
46
  why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
47
47
  '@starci/eslint-canon-fe':
48
- version: 9.9.2
48
+ version: 9.9.6
49
49
  group: starci
50
50
  install: registry
51
51
  side: fe
52
52
  source: packages/eslint-canon-fe/package.json
53
53
  why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
54
54
  '@starci/eslint-grammar-core':
55
- version: 0.21.2
55
+ version: 0.21.6
56
56
  group: starci
57
57
  install: registry
58
58
  side: fe
59
59
  source: packages/eslint-grammar-core/package.json
60
60
  why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
61
61
  '@starci/eslint-grammar-offset-pop':
62
- version: 0.21.2
62
+ version: 0.21.6
63
63
  group: starci
64
64
  install: registry
65
65
  side: fe
@@ -106,7 +106,7 @@ pins:
106
106
  source: packages/ai-router/package.json
107
107
  why: 'the AI pool router of a back end: a NestJS module that routes OpenAI-compatible chat, stream and embeddings calls across keys, models and providers, with @starci/ai-router/testing for its specs; @starci/jest-preset compiles it as an ESM-only dependency.'
108
108
  '@starci/hfs':
109
- version: 4.17.2
109
+ version: 5.0.2
110
110
  group: starci
111
111
  install: registry
112
112
  side: both
@@ -294,7 +294,7 @@ BE_APP_BOOT_PROOF_MISSING:
294
294
  - "Thiếu provider, thiếu export của module hoặc có phụ thuộc vòng"
295
295
  - "Thiếu AppModule, dữ liệu mẫu cho đăng ký động hoặc đối tượng thay thế hạ tầng"
296
296
  - "Tiến trình chứng minh ghép nối thất bại hoặc hết thời gian"
297
- nextStep_vi: "Sửa đăng ký và phạm vi provider trong AppModule thật, cung cấp dữ liệu mẫu cùng đối tượng thay thế hạ tầng rồi chạy lại starci app check --wiring."
297
+ nextStep_vi: "Sửa đăng ký và phạm vi provider trong AppModule thật, cung cấp dữ liệu mẫu cùng đối tượng thay thế hạ tầng rồi chạy lại starci app check --gate wiring."
298
298
  owner: op-retry
299
299
  kind: check-finding
300
300
 
@@ -1727,7 +1727,7 @@ BOOT_PROOF_TIMEOUT:
1727
1727
  meaning_vi: "Tiến trình chứng minh ghép nối Nest chạy quá ngân sách thời gian riêng của nó (STARCI_BOOT_PROOF_TIMEOUT_MS, lấy từ heavy.bootProofTimeoutSeconds của ops/liveness.yaml) cả lần đầu lẫn lần thử lại. Đây không phải lỗi ghép nối: máy đang quá tải nên chưa phán được."
1728
1728
  causes_vi:
1729
1729
  - "Máy chủ đang chạy nhiều lệnh nặng cùng lúc nên trình biên dịch TypeScript của bằng chứng chạy quá chậm"
1730
- nextStep_vi: "Chạy lại starci app check --wiring khi máy rảnh hơn; nếu vẫn hết thời gian khi máy rảnh, nâng heavy.bootProofTimeoutSeconds trong ops/liveness.yaml."
1730
+ nextStep_vi: "Chạy lại starci app check --gate wiring khi máy rảnh hơn; nếu vẫn hết thời gian khi máy rảnh, nâng heavy.bootProofTimeoutSeconds trong ops/liveness.yaml."
1731
1731
  owner: op-retry
1732
1732
  kind: check-finding
1733
1733
 
@@ -63,7 +63,7 @@ export interface AppWiringInput {
63
63
  run?: WorkerRun;
64
64
  }
65
65
  export declare function appWiring({ repoRoot, repo, files, fixtures, timeout, run }: AppWiringInput): WiringResult;
66
- /** Optional standalone entry; coordinator connects the same seam to app check --wiring. */
66
+ /** Optional standalone entry; coordinator connects the same seam to app check --gate wiring. */
67
67
  export interface AppWiringMainIo {
68
68
  cwd?: string;
69
69
  stdout?: (text: string) => unknown;
@@ -30,9 +30,9 @@ const SAFE_REASONS = new Set([...DI_FAILURES.values(), COMPILATION_FAILURE]);
30
30
  const modulePath = (app) => `apps/${app}/src/app.module.ts`;
31
31
  export const wiringFixturePath = (app) => `src/tests/fixtures/builders/${app}.builder.ts`;
32
32
  const refusal = (file, reason) => ({ code: BOOT_PROOF_CODE, level: 'error', path: file,
33
- message: `${file}: lite boot proof failed (${reason}); compile the real AppModule with explicit fixture options and infrastructure doubles using starci app check --wiring`, reason });
33
+ message: `${file}: lite boot proof failed (${reason}); compile the real AppModule with explicit fixture options and infrastructure doubles using starci app check --gate wiring`, reason });
34
34
  const timedOut = (file, budget) => ({ code: BOOT_PROOF_TIMEOUT, level: 'warning', path: file, reason: 'proof worker timed out',
35
- message: `${file}: the lite boot proof reached no verdict: its worker ran out of its ${Math.round(budget / 1000)} s budget twice (the host is loaded); this is no failure of the proof, rerun starci app check --wiring when the host is quieter` });
35
+ message: `${file}: the lite boot proof reached no verdict: its worker ran out of its ${Math.round(budget / 1000)} s budget twice (the host is loaded); this is no failure of the proof, rerun starci app check --gate wiring when the host is quieter` });
36
36
  /** The worker's budget in ms, from its spawn: the CLI's STARCI_BOOT_PROOF_TIMEOUT_MS, else four compile deadlines (at least a minute). */
37
37
  export function workerBudget(timeout, env = process.env) {
38
38
  const configured = Number(env[BOOT_PROOF_TIMEOUT_ENV]);
@@ -217,12 +217,20 @@ export function appWiring({ repoRoot, repo = readJson(repoRoot, 'hfs.json'), fil
217
217
  export function appWiringMain(argv, { cwd = invocationDir(), stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text) } = {}) {
218
218
  let directory = cwd, json = false;
219
219
  for (let at = 0; at < argv.length; at += 1) {
220
- if (argv[at] === '--cwd' && argv[at + 1])
221
- directory = path.resolve(cwd, argv[++at]);
222
- else if (argv[at] === '--json')
220
+ const argument = argv[at];
221
+ const next = argv[at + 1];
222
+ if (argument === '--cwd' && next && !next.startsWith('--')) {
223
+ directory = path.resolve(cwd, next);
224
+ at += 1;
225
+ }
226
+ else if (argument?.startsWith('--cwd=') && argument.length > '--cwd='.length)
227
+ directory = path.resolve(cwd, argument.slice('--cwd='.length));
228
+ else if (argument === '--json')
223
229
  json = true;
224
- else if (argv[at] !== '--wiring') {
225
- stderr('usage: starci app check --wiring [--cwd <dir>] [--json]\n');
230
+ else if (argument === '--gate' && next === 'wiring')
231
+ at += 1;
232
+ else if (argument !== '--gate=wiring') {
233
+ stderr('usage: starci app check --gate wiring [--cwd <dir>] [--json]\n');
226
234
  return 2;
227
235
  }
228
236
  }
@@ -17,7 +17,7 @@ function importBindings(ts, scope, statement) {
17
17
  return;
18
18
  const module = statement.moduleSpecifier.text;
19
19
  if (clause.name)
20
- scope.bindings.set(clause.name.text, { kind: 'component' });
20
+ scope.bindings.set(clause.name.text, { kind: 'component', module, name: 'default' });
21
21
  const named = clause.namedBindings;
22
22
  if (!named)
23
23
  return;
@@ -28,8 +28,9 @@ function importBindings(ts, scope, statement) {
28
28
  if (element.isTypeOnly)
29
29
  continue;
30
30
  const imported = element.propertyName?.text ?? element.name.text;
31
- const kind = TRANSLATION_MODULES.has(module) && TRANSLATION_FACTORIES.has(imported) ? 'translation-factory' : 'component';
32
- scope.bindings.set(element.name.text, { kind });
31
+ const binding = TRANSLATION_MODULES.has(module) && TRANSLATION_FACTORIES.has(imported)
32
+ ? { kind: 'translation-factory' } : { kind: 'component', module, name: imported };
33
+ scope.bindings.set(element.name.text, binding);
33
34
  }
34
35
  }
35
36
  function lexicalBindings(ts, sourceFile) {
@@ -151,12 +152,55 @@ function nestedDataObjects(ts, expression, bindingOf, excluded) {
151
152
  nestedDataObjects(ts, element, bindingOf, excluded);
152
153
  }
153
154
  }
155
+ /** Resolve the canonical status owner through its import binding, including aliases and namespaces. */
156
+ function statusOwner(ts, tag, bindingOf) {
157
+ const binding = ts.isIdentifier(tag) ? bindingOf(tag) : ts.isPropertyAccessExpression(tag) && ts.isIdentifier(tag.expression) ? bindingOf(tag.expression) : null;
158
+ const module = binding?.kind === 'component' || binding?.kind === 'namespace' ? binding.module : null;
159
+ const name = binding?.kind === 'component' ? binding.name : ts.isPropertyAccessExpression(tag) ? tag.name.text : null;
160
+ return name === 'SlotView' && module !== null && /(?:^|\/)components\/composites\/SlotView(?:\/index)?(?:\.[cm]?tsx?)?$/.test(module);
161
+ }
162
+ /** The mandated status wiring breaks clone windows; its render body remains ordinary compared code. */
163
+ function statusRecipe(ts, opening, bindingOf, excluded) {
164
+ if (!statusOwner(ts, opening.tagName, bindingOf))
165
+ return;
166
+ const attributes = opening.attributes.properties;
167
+ const render = attributes.find(attribute => ts.isJsxAttribute(attribute) && ts.isIdentifier(attribute.name) && attribute.name.text === 'render');
168
+ const value = render && ts.isJsxAttribute(render) && render.initializer && ts.isJsxExpression(render.initializer) ? unwrap(ts, render.initializer.expression) : undefined;
169
+ if (!value || !ts.isArrowFunction(value) || value.parameters.length !== 2 || ts.isBlock(value.body))
170
+ return;
171
+ const waiting = value.parameters[1];
172
+ if (!waiting || !ts.isIdentifier(waiting.name) || waiting.initializer || waiting.dotDotDotToken)
173
+ return;
174
+ const waitingBinding = bindingOf(waiting.name);
175
+ let forwardsLoading = false;
176
+ const visit = (node) => {
177
+ if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && node.name.text === 'isLoading'
178
+ && node.initializer && ts.isJsxExpression(node.initializer)) {
179
+ const flag = unwrap(ts, node.initializer.expression);
180
+ if (flag && ts.isIdentifier(flag) && waitingBinding && bindingOf(flag) === waitingBinding)
181
+ forwardsLoading = true;
182
+ }
183
+ ts.forEachChild(node, visit);
184
+ };
185
+ visit(value.body);
186
+ if (!forwardsLoading)
187
+ return;
188
+ const wiring = attributes.filter(attribute => ts.isJsxSpreadAttribute(attribute) ? dataMapping(ts, attribute.expression, bindingOf)
189
+ : ts.isIdentifier(attribute.name) && ['slot', 'placeholder', 'labels', 'onRetry'].includes(attribute.name.text)
190
+ && !!attribute.initializer && ts.isJsxExpression(attribute.initializer) && dataMapping(ts, attribute.initializer.expression, bindingOf));
191
+ const hasSlot = wiring.some(attribute => ts.isJsxSpreadAttribute(attribute) || ts.isJsxAttribute(attribute) && ts.isIdentifier(attribute.name) && attribute.name.text === 'slot');
192
+ if (!hasSlot)
193
+ return;
194
+ for (const attribute of wiring)
195
+ excluded.add(attribute);
196
+ }
154
197
  /** Canon data prop objects qualify through an imported component binding, never its chosen name or file path. */
155
198
  export function canonJsxCloneNodes(ts, sourceFile) {
156
199
  const bindingOf = lexicalBindings(ts, sourceFile);
157
200
  const excluded = new Set();
158
201
  const visit = (node) => {
159
202
  if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && importedComponent(ts, node.tagName, bindingOf)) {
203
+ statusRecipe(ts, node, bindingOf, excluded);
160
204
  for (const attribute of node.attributes.properties) {
161
205
  if (!ts.isJsxAttribute(attribute) || !attribute.initializer || !ts.isJsxExpression(attribute.initializer))
162
206
  continue;
@@ -203,6 +203,9 @@ function signatureParameterStatus(ts, checker, signature, location, parameter, s
203
203
  const actual = parameterSymbol ? checker.getTypeOfSymbolAtLocation(parameterSymbol, location) : null;
204
204
  if (actual && parameter.questionToken && !ts.isUnionTypeNode(parameter.type) && hasAnyFlag(actual.flags, ts.TypeFlags.Union)) {
205
205
  const selected = actual.types.filter((type) => !hasAnyFlag(type.flags, ts.TypeFlags.Undefined));
206
+ // An optional boolean is represented as true | false | undefined, not one Boolean member.
207
+ if (selected.length > 0 && selected.every((type) => hasAnyFlag(type.flags, ts.TypeFlags.BooleanLike)))
208
+ return 'scalar';
206
209
  return selected.length === 1 ? contractTypeStatusFromType(ts, checker, selected[0]) : 'inline';
207
210
  }
208
211
  return actual ? contractTypeStatusFromType(ts, checker, actual) : contractTypeStatus(ts, checker, parameter.type);
@@ -1,13 +1,13 @@
1
- import type { ArrayLiteralExpression, CallExpression, Expression, Node, ObjectLiteralExpression, SourceFile } from 'typescript';
1
+ import type { ArrayLiteralExpression, CallExpression, Expression, Node, ObjectLiteralExpression, SourceFile, TypeChecker } from 'typescript';
2
2
  import type { TypeScriptApi } from './config.mjs';
3
3
  import type { ImportBinding } from './machine-ast.mjs';
4
4
  export declare const keyOf: (ts: TypeScriptApi, property: Node) => string | null;
5
5
  /**
6
- * The callable subset of a function subject's exports. Constants, enums, objects, types, `export ... from` re-exports and overload
7
- * signatures do not change which functions the spec must provide; a class or a default-export assignment keeps the class-provider
8
- * check (null), as does a module with no callable export.
6
+ * The callable subset of a function subject's exports, including named re-exports resolved to real implementations. Constants,
7
+ * enums, objects, type-only exports and bodyless declarations do not count; a class or a default-export assignment keeps the
8
+ * class-provider check (null), as does a module with no callable export.
9
9
  */
10
- export declare function functionSubjectExports(ts: TypeScriptApi, sourceFile: SourceFile): string[] | null;
10
+ export declare function functionSubjectExports(ts: TypeScriptApi, sourceFile: SourceFile, checker: TypeChecker): string[] | null;
11
11
  /** The names of the value-object classes a module exports, or null when it exports a class that is not one (or none). */
12
12
  export declare function valueObjectSubjects(ts: TypeScriptApi, sourceFile: SourceFile): string[] | null;
13
13
  /** The value of one literal object property. */
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { normalizedSymbol } from './ast-walks.mjs';
2
3
  export const keyOf = (ts, property) => (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) && (ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)) ? property.name.text : null;
3
4
  const exported = (ts, statement) => statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false;
4
5
  /** The directly defined functions among the declarations of an exported variable statement; other values are not callable. */
@@ -19,17 +20,30 @@ function callableExports(ts, statement) {
19
20
  return statement.name && statement.body ? [statement.name.text] : [];
20
21
  return ts.isVariableStatement(statement) ? callableVariables(ts, statement) : [];
21
22
  }
23
+ /** Named value re-exports count only when the checker resolves an actual function implementation. */
24
+ function callableReexports(ts, checker, statement) {
25
+ if (!ts.isExportDeclaration(statement) || statement.isTypeOnly || !statement.exportClause || !ts.isNamedExports(statement.exportClause))
26
+ return [];
27
+ return statement.exportClause.elements.filter(element => !element.isTypeOnly && (normalizedSymbol(ts, checker, element.name)?.declarations ?? []).some(declaration => {
28
+ if (declaration.getSourceFile().isDeclarationFile)
29
+ return false;
30
+ if (ts.isFunctionDeclaration(declaration))
31
+ return Boolean(declaration.body);
32
+ return ts.isVariableDeclaration(declaration) && Boolean(declaration.initializer
33
+ && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer)));
34
+ })).map(element => element.name.text);
35
+ }
22
36
  /**
23
- * The callable subset of a function subject's exports. Constants, enums, objects, types, `export ... from` re-exports and overload
24
- * signatures do not change which functions the spec must provide; a class or a default-export assignment keeps the class-provider
25
- * check (null), as does a module with no callable export.
37
+ * The callable subset of a function subject's exports, including named re-exports resolved to real implementations. Constants,
38
+ * enums, objects, type-only exports and bodyless declarations do not count; a class or a default-export assignment keeps the
39
+ * class-provider check (null), as does a module with no callable export.
26
40
  */
27
- export function functionSubjectExports(ts, sourceFile) {
41
+ export function functionSubjectExports(ts, sourceFile, checker) {
28
42
  const names = [];
29
43
  for (const statement of sourceFile.statements) {
30
44
  if (ts.isClassDeclaration(statement) || ts.isExportAssignment(statement))
31
45
  return null;
32
- for (const name of callableExports(ts, statement))
46
+ for (const name of [...callableExports(ts, statement), ...callableReexports(ts, checker, statement)])
33
47
  if (!names.includes(name))
34
48
  names.push(name);
35
49
  }
@@ -13,7 +13,7 @@ import type { MachineContext } from './machine-ast.mjs';
13
13
  * constructor-deps.mjs). Providers are matched by the exported name of the imported binding (`import { CACHE as C }` is
14
14
  * CACHE), never by text. A provider is a class, or `{ provide: TOKEN, useValue: double }` and nothing else. A sibling
15
15
  * containing only exported functions instead provides its complete real function object and exercises it through module.get;
16
- * the constants, enums, re-exports and overload signatures beside those functions do not count. A value-object subject (an
16
+ * callable named re-exports count, while constants, enums, type-only exports and overload signatures do not. A value-object subject (an
17
17
  * undecorated class whose constructor parameters carry no injection token) is built with `new`, Nest never constructs it.
18
18
  *
19
19
  * Findings: an extra provider, a missing provider, a service that is not the first provider, a dependency whose token cannot
@@ -18,7 +18,7 @@ import { checkFunctionSubject, functionSubjectExports, keyOf as literalKey, valu
18
18
  * constructor-deps.mjs). Providers are matched by the exported name of the imported binding (`import { CACHE as C }` is
19
19
  * CACHE), never by text. A provider is a class, or `{ provide: TOKEN, useValue: double }` and nothing else. A sibling
20
20
  * containing only exported functions instead provides its complete real function object and exercises it through module.get;
21
- * the constants, enums, re-exports and overload signatures beside those functions do not count. A value-object subject (an
21
+ * callable named re-exports count, while constants, enums, type-only exports and overload signatures do not. A value-object subject (an
22
22
  * undecorated class whose constructor parameters carry no injection token) is built with `new`, Nest never constructs it.
23
23
  *
24
24
  * Findings: an extra provider, a missing provider, a service that is not the first provider, a dependency whose token cannot
@@ -111,7 +111,7 @@ function checkTestingModule({ ts, kit, rel, serviceRel, serviceFile, imports, ca
111
111
  const list = providerList(ts, call, rel, report);
112
112
  if (!list)
113
113
  return;
114
- const names = functionSubjectExports(ts, serviceFile.sourceFile);
114
+ const names = functionSubjectExports(ts, serviceFile.sourceFile, kit.checkerOf(serviceFile.sourceFile));
115
115
  if (names) {
116
116
  checkFunctionSubject({ ts, rel, serviceRel, imports, call, list, names, report });
117
117
  return;
package/src/main.mjs CHANGED
@@ -8,7 +8,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
8
8
  };
9
9
  // The @starci/hfs implementation behind `starci app`: one repository, `<app>/`, with one package.json, lockfile and node_modules
10
10
  // at its root and two sides, be/ and fe/, declared by the one hfs.json of kind app. Every verb runs at the app root.
11
- // starci app check [--cwd <dir>] [--json] [--full] [--hfs] [--format] [--db] [--secrets] [--wiring] [--types] [--lint] [--test] [--sonar] [--coverage-from <lcov>] | check --staged
11
+ // starci app check [--cwd <dir>] [--json] [--gate <hfs|format|db|secrets|wiring|types|lint|test|sonar|codecov>]... [--no-cache] [--coverage-from <lcov>] [--shard <i>/<n>] | check --staged
12
12
  // every tracked path has a slot; required files exist; nothing forbidden or
13
13
  // tracked-that-must-be-ignored; pins match; every managed file equals its render
14
14
  // (sync/managed.mjs, the .gitignore block and sonar-project.properties included); no empty or ghost
@@ -21,10 +21,12 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
21
21
  // ESLint executes once per side through the installed managed config; the FE config composes
22
22
  // the universal canon and the explicitly selected Grammar-family canon.
23
23
  // The results are committed in .starcistacks/cache/ (cache/, contexts/project/check-cache.md): with no selector every
24
- // family runs diff-only against it and writes what it judged back; --full runs the selected families in full; a
25
- // selector (--hfs, --format, --db, --secrets, --wiring, --types, --lint, --test, --sonar) runs only that family, and
26
- // selectors combine. Each family prints one diagnosis line (full or diff and why, checked, reused, findings new, known,
27
- // fixed); progress goes to stderr (--quiet silences it). STARCI_APP_CHECK_CACHE=verify runs diff then full and fails (exit 2)
24
+ // local family reuses valid cache records and writes what it judged back; a missing cache, fingerprint change or
25
+ // release selects full automatically. Repeat --gate to select gates; no selector runs every existing family.
26
+ // sonar and codecov select the same combined baseline predictions, not server verdicts. --no-cache recomputes the
27
+ // selected families and writes the refreshed cache; it does not bypass writes.
28
+ // Each family prints one diagnosis line (full or diff and why, checked, reused, findings new, known, fixed);
29
+ // progress goes to stderr (--quiet silences it). STARCI_APP_CHECK_CACHE=verify runs diff then full and fails (exit 2)
28
30
  // on any difference; =full never reads the cache (the release gate).
29
31
  // --coverage-from <lcov> judges a coverage report produced elsewhere (the merge of a sharded unit run) and records nothing.
30
32
  // --staged: the pre-commit guard alone (sync/hygiene.mjs: staged .starciwork and .starcistacks paths, the secrets
@@ -145,8 +147,10 @@ const DATABASE_VERBS = new Map([['baseline', dbBaselineMain], ['restamp', dbRest
145
147
  */
146
148
  /** The flags of the add nouns that generate a file tree from a pattern (service, spec and image take none of them, and --inject is only theirs). */
147
149
  const ADD_FLAGS = { value: ['--event', '--from', '--service', '--connection', '--owner', '--failed', '--done', '--shapes', '--read', '--app', '--kind', '--blocks', '--tabs'], boolean: ['--fe', '--form'] };
148
- /** The family selectors of `app check`, one flag per family of the cache (contexts/project/check-cache.md). */
149
- const FAMILY_FLAGS = FAMILIES.map((family) => `--${family}`);
150
+ /** The repeatable selector of `app check`; its values are the canonical family vocabulary. */
151
+ const GATE_FLAGS = ['--gate'];
152
+ /** Both quality selectors dispatch to the existing combined Sonar/Codecov baseline family. */
153
+ const CHECK_GATES = [...FAMILIES, 'codecov'];
150
154
  const PARSED = {
151
155
  scaffold: { run: scaffoldMain, value: ['--registry-visibility'], edition: true, forms: [{ operands: '<name>' }] },
152
156
  add: {
@@ -158,7 +162,7 @@ const PARSED = {
158
162
  { operands: 'image', flags: [] },
159
163
  ],
160
164
  },
161
- check: { run: checkMain, value: ['--coverage-from', '--shard'], boolean: ['--json', '--staged', '--full', ...FAMILY_FLAGS], edition: true, forms: [{ only: { boolean: ['--json', '--full', ...FAMILY_FLAGS], value: ['--coverage-from', '--shard'] } }, { only: { boolean: ['--staged'] }, edition: false }] },
165
+ check: { run: checkMain, value: ['--coverage-from', '--shard'], list: GATE_FLAGS, boolean: ['--json', '--staged', '--no-cache'], edition: true, forms: [{ only: { boolean: ['--json', '--no-cache'], list: GATE_FLAGS, value: ['--coverage-from', '--shard'] } }, { only: { boolean: ['--staged'] }, edition: false }] },
162
166
  slot: { run: slotMain, boolean: ['--json'], forms: [{ operands: '<path>' }] },
163
167
  emit: { run: emitMain, forms: [{}] },
164
168
  };
@@ -168,6 +172,7 @@ const ADD_FLAGS_ALL = flagsOf(ADD_FLAGS);
168
172
  const PARSED_FLAGS = new Set([...Object.values(PARSED).flatMap(flagsOf), '--edition']);
169
173
  /** The placeholder of a value flag in the usage; any other prints as <name>. */
170
174
  const PLACEHOLDERS = {
175
+ '--gate': `<${CHECK_GATES.join('|')}>`,
171
176
  '--registry-visibility': 'public|private', '--edition': 'full|lite', '--base': '<ref>', '--coverage-from': '<lcov>', '--shard': '<i>/<n>', '--inject': '<binding>',
172
177
  '--event': '<event>', '--from': '<service>', '--service': '<Class>=<module>', '--owner': '<service>', '--failed': '<event>', '--done': '<event>',
173
178
  '--shapes': '<a,b,c>', '--blocks': '<a,b>', '--tabs': '<a,b>', '--kind': 'modal|drawer', '--image': '<service>=<ref>', '--sha': '<sha>',
@@ -331,7 +336,14 @@ function printExplain(e, out) {
331
336
  }
332
337
  /** The family selectors given, as families; none given selects `fallback`. */
333
338
  function selectedFamilies(opts, fallback) {
334
- const given = FAMILIES.filter((family) => opts[family] === true);
339
+ const given = (listFlag(opts, 'gate') ?? []).map((value) => {
340
+ if (value === 'codecov')
341
+ return 'sonar';
342
+ const family = FAMILIES.find((candidate) => candidate === value);
343
+ if (family === undefined)
344
+ throw new Error(`--gate accepts ${CHECK_GATES.join('|')}; got ${JSON.stringify(value)}`);
345
+ return family;
346
+ });
335
347
  return new Set(given.length > 0 ? given : fallback);
336
348
  }
337
349
  /** Opens the session of a verb over the tracked files of the app: progress on stderr, `--quiet` silent, a terminal rewriting running lines in place. */
@@ -483,7 +495,7 @@ function printCheckProofs(result, out) {
483
495
  out(formatCoverageReport(result.coverage));
484
496
  }
485
497
  /** The flags `check --staged` refuses: it is the pre-commit guard alone, so none of the whole-app pass's options means anything with it. */
486
- const STAGED_REFUSES = ['--json', '--full', '--coverage-from', '--shard', ...FAMILY_FLAGS];
498
+ const STAGED_REFUSES = ['--json', '--no-cache', '--coverage-from', '--shard', ...GATE_FLAGS];
487
499
  /** The family a finding belongs to, for naming it when a verified run disagrees with the cache. */
488
500
  function familyOf(finding) {
489
501
  const code = finding.code ?? '';
@@ -567,17 +579,21 @@ async function checkMain(opts, io) {
567
579
  }
568
580
  const coverageFrom = valueFlag(opts, 'coverage-from');
569
581
  const shard = valueFlag(opts, 'shard');
582
+ const explicit = selectedFamilies(opts, []);
570
583
  if (shard !== undefined) {
571
- // The sharded full unit run of CI (--test --full --shard <i>/<n>): the shard's Jest run in the shape the shard merge reads; nothing is recorded.
584
+ // A selected test shard is always a whole-unit shard, in the shape the coverage merge reads; nothing is recorded.
572
585
  const match = /^(\d+)\/(\d+)$/.exec(shard);
573
- if (match === null || opts.test !== true || opts.full !== true)
574
- throw new Error('--shard <i>/<n> belongs to `starci app check --test --full`');
575
- return runTestShard(repoRoot, Number(match[1]), Number(match[2]));
586
+ if (explicit.size !== 1 || !explicit.has('test') || coverageFrom !== undefined)
587
+ throw new Error('--shard <i>/<n> requires --gate test alone and takes no --coverage-from');
588
+ const index = Number(match?.[1]);
589
+ const total = Number(match?.[2]);
590
+ if (match === null || !Number.isSafeInteger(index) || !Number.isSafeInteger(total) || index < 1 || total < index)
591
+ throw new Error('--shard needs <i>/<n> with 1 <= i <= n');
592
+ return runTestShard(repoRoot, index, total);
576
593
  }
577
- const explicit = selectedFamilies(opts, []);
578
594
  // A coverage report judged on its own (the merge job of a sharded run) runs no family; with selectors it comes on top of them.
579
595
  const selected = explicit.size > 0 ? explicit : coverageFrom === undefined ? new Set(DEFAULT_FAMILIES) : new Set();
580
- const full = opts.full === true ? new Set(selected) : undefined;
596
+ const full = opts['no-cache'] === true ? new Set(selected) : undefined;
581
597
  if (io.env[CACHE_ENV] === 'verify')
582
598
  return verifyCache(io, selected, coverageFrom);
583
599
  const { result, seconds } = await checkRun({ io, selected, full, use: coverageFrom !== undefined && explicit.size === 0 ? 'bypass' : 'normal', coverageFrom });
@@ -590,7 +606,7 @@ async function checkMain(opts, io) {
590
606
  out(`${result.gate.line}\n`);
591
607
  }
592
608
  const exit = result.eslint?.errors.length ? 2 : result.ok ? 0 : 1;
593
- writeRunLog(repoRoot, { command: `check${[...selected].map((family) => ` --${family}`).join('')}${opts.full === true ? ' --full' : ''}`, seconds, exit, families: result.families.map((report) => ({ ...report, seconds: 0 })) }, io.env);
609
+ writeRunLog(repoRoot, { command: `check${[...new Set(listFlag(opts, 'gate') ?? [])].map((gate) => ` --gate ${gate}`).join('')}${opts['no-cache'] === true ? ' --no-cache' : ''}${coverageFrom === undefined ? '' : ` --coverage-from ${JSON.stringify(coverageFrom)}`}`, seconds, exit, families: result.families.map((report) => ({ ...report, seconds: 0 })) }, io.env);
594
610
  return exit;
595
611
  }
596
612
  function emitMain(opts, { cwd: repoRoot, stdout: out }) {
@@ -87,7 +87,7 @@ jobs:
87
87
  - name: production dependency audit
88
88
  run: {{auditCommand}}
89
89
  - name: format
90
- run: npx --no-install starci app check --format
90
+ run: npx --no-install starci app check --gate format
91
91
  - name: typecheck
92
92
  run: npm run typecheck
93
93
  env:
@@ -87,7 +87,7 @@ jobs:
87
87
  - name: production dependency audit
88
88
  run: {{auditCommand}}
89
89
  - name: format
90
- run: npx --no-install starci app check --format
90
+ run: npx --no-install starci app check --gate format
91
91
  - name: typecheck
92
92
  run: npm run typecheck
93
93
  env:
@@ -7,4 +7,4 @@
7
7
  "db:push": "{{appBinary}} db migrate --db-url-env DATABASE_URL --execute",
8
8
  "db:plan": "{{appBinary}} db migrate --db-url-env DATABASE_URL",
9
9
  "db:dump": "{{appBinary}} db dump --db-url-env DATABASE_URL --out ../{{project}}-db-backups",
10
- "db:lint": "{{appBinary}} check --db",
10
+ "db:lint": "{{appBinary}} check --gate db",