@starci/hfs 4.17.3 → 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 +14 -0
- package/README.md +7 -4
- package/coverage/cli.mjs +2 -2
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +6 -6
- package/runtime/modules/kernel/failure-codes.yaml +2 -2
- package/runtime/scripts/hfs/app-wiring.d.mts +1 -1
- package/runtime/scripts/hfs/app-wiring.mjs +15 -7
- package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +47 -3
- package/runtime/scripts/hfs/architecture/contracts.mjs +3 -0
- package/runtime/scripts/hfs/architecture/function-spec-providers.d.mts +5 -5
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +19 -5
- package/runtime/scripts/hfs/architecture/unit-spec-providers.d.mts +1 -1
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +2 -2
- package/src/main.mjs +33 -17
- package/templates/app/ci-workflows/github/workflows/ci.yml +1 -1
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +1 -1
- package/templates/app/package-scripts/db.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
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
|
+
|
|
5
19
|
## 4.17.3 - 2026-11-02
|
|
6
20
|
|
|
7
21
|
- Changed: bundled canonical pins follow the consolidated Grammar 0.21.3 preservation API cohort; runtime rules 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>] [--
|
|
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
|
|
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. `--
|
|
213
|
-
`--wiring`,
|
|
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
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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 (
|
|
225
|
-
|
|
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
|
|
32
|
-
|
|
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
|
|
7
|
-
*
|
|
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
|
|
24
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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] [--
|
|
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
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
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
|
|
149
|
-
const
|
|
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', '--
|
|
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 =
|
|
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', '--
|
|
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
|
-
//
|
|
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 (
|
|
574
|
-
throw new Error('--shard <i>/<n>
|
|
575
|
-
|
|
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
|
|
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${[...
|
|
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 }) {
|
|
@@ -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",
|