@starci/hfs 4.0.4 → 4.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +7 -4
- package/package.json +1 -1
- package/runtime/engine/machine-db.mjs +4 -4
- package/runtime/engine/migrations/machine/0003-worktrees-workflow-orca.sql +21 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +14 -7
- package/runtime/knowledge/hfs/slots.yaml +8 -8
- package/runtime/knowledge/sonar-gate.yaml +29 -6
- package/runtime/modules/kernel/failure-codes.yaml +13 -3
- package/runtime/scripts/api/fs/rmdir-link.mjs +12 -0
- package/runtime/scripts/{lib/git.mjs → api/git/lib.mjs} +13 -14
- package/runtime/scripts/checks/architecture/dead-exports.mjs +32 -6
- package/runtime/scripts/checks/architecture/hfs.mjs +1 -1
- package/runtime/scripts/checks/architecture/required-files.mjs +1 -1
- package/runtime/scripts/lib/hfs-check.mjs +5 -2
- package/runtime/scripts/lib/hfs-rules/integration-specs.mjs +222 -0
- package/runtime/scripts/lib/hfs-tree.mjs +1 -1
- package/runtime/scripts/lib/package-at.mjs +25 -0
- package/runtime/scripts/lib/repo-identity.mjs +1 -1
- package/runtime/scripts/lib/safe-remove.mjs +21 -10
- package/sync/index.mjs +24 -3
- package/sync/managed.mjs +1 -1
- package/templates/app/ci-workflows/github/workflows/ci.yml +9 -0
- package/templates/app/quality-config/codecov.yml +22 -0
- package/templates/app/quality-config/sonar-project.properties +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.0.6 - 2026-10-01
|
|
4
|
+
|
|
5
|
+
- Changed: R112 integration specs, the runtime api layer, the examples-root CI scope and the new pins.
|
|
6
|
+
|
|
7
|
+
## 4.0.5 - 2026-10-01
|
|
8
|
+
|
|
9
|
+
- Changed: workflow worktree runtime copies, the services coverage scope for Sonar and codecov.yml, sonar-gate, canon-pins.
|
|
10
|
+
|
|
11
|
+
## Unreleased (alpha.4, bumped in C0's batch)
|
|
12
|
+
|
|
13
|
+
- Changed (contract change `sonar-services-coverage`): Sonar and Codecov judge the services' coverage. The managed `sonar-project.properties` adds `sonar.javascript.lcov.reportPaths=be/coverage/lcov.info` and `sonar.coverage.inclusions=be/src/**/*.service.ts`; a new managed `codecov.yml` (slot `app.quality-config`, now `{sonar-project.properties,codecov.yml}`) holds the same paths at 100 on the project and the patch and ignores `fe/**`; the managed CI workflow uploads the be lcov with `codecov/codecov-action@v5` and the `CODECOV_TOKEN` secret after the unit run. Both files are rendered from one scope, `coverageScope(presets)`: the installed jest preset's `COVERAGE_SOURCES` on the be side (`loadPresets` now returns `coverageSources`). An app re-renders with `hfs sync --write`.
|
|
14
|
+
- Changed: the bundled runtime copies of `knowledge/sonar-gate.yaml` (coverage 100 overall and on new code, per file; every hotspot reviewed), `knowledge/hfs/slots.yaml` (the quality-config slot) and `modules/kernel/failure-codes.yaml` (the R11 law and HFS_SONAR_CONFIG text).
|
|
15
|
+
|
|
3
16
|
## 4.0.4 - 2026-10-01
|
|
4
17
|
|
|
5
18
|
- Changed: the bundled runtime copy of knowledge/hfs/canon-pins.yaml pins @starci/test-world 1.0.3. No rule or command changed.
|
package/README.md
CHANGED
|
@@ -63,6 +63,7 @@ Its own checks (`scripts/lib/hfs-check.mjs`, `scripts/lib/hfs-rules/`; the rende
|
|
|
63
63
|
| `HFS_REPO_LOCAL_CHECK` | error | a `check-*` file in `scripts/` or `tools/`, an `eslint-local-rules*` file or local eslint plugin, or a script that runs a local check (R103) |
|
|
64
64
|
| `HFS_LINT_SUPPRESSION_FILE` | error | an `eslint.suppressions*` file, a `lint:suppressions` script, an eslint suppress flag or a suppressions config (R104) |
|
|
65
65
|
| `HFS_PROOF_COMMAND_FILE_MISSING` | error | a `.starciwork` `requiresProof.<kind>.command` that runs a file the repository does not hold (R105) |
|
|
66
|
+
| `BE_INTEGRATION_SPEC_MISSING` | error | an integration (`src/modules/integrations/<provider>/` with `<provider>.config.ts`) without a `src/tests/integration/<provider>/*.integration-spec.ts` that registers its module through `useTestWorld({ modules })`, references its ErrorCode enum and drives an outage through the world (R112) |
|
|
66
67
|
| `HFS_PEER_INTEGRATION_MISSING` | error | the app root `package.json` depends on a driver integration (a pair of `knowledge/hfs/peer-integrations.yaml`) without its runtime peer, e.g. `@nestjs/apollo` on `@nestjs/platform-express` 11 without `@as-integrations/express5` (R111) |
|
|
67
68
|
| `FE_WIRE_GENERATED` | error | a contract copy with no `codegen` script wired before `build` and `typecheck`, or generated types older than the copy (R52) |
|
|
68
69
|
| `FE_I18N_PLACEMENT` | error | no `next-intl`, no `src/proxy.ts`, a `middleware.ts`, a route file outside `[locale]`, no `vi.json` catalog (R59) |
|
|
@@ -70,7 +71,7 @@ Its own checks (`scripts/lib/hfs-check.mjs`, `scripts/lib/hfs-rules/`; the rende
|
|
|
70
71
|
| `FE_I18N_KEYS` | error | a literal key read through `next-intl` that a locale lacks, or a catalog key no source reads (R106; the architecture machine) |
|
|
71
72
|
| `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/lib/hfs-rules/fe-no-tests.mjs`) |
|
|
72
73
|
| `HFS_GITIGNORE_BLOCK_DRIFT` | error | the managed `.gitignore` block differs from its render (R04; `sync/managed.mjs`) |
|
|
73
|
-
| `HFS_SONAR_CONFIG` | error | `sonar-project.properties` differs from its render: no `sonar.host.url`, the `sonar.exclusions` of the installed jest preset,
|
|
74
|
+
| `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 services as the only coverage scope (R11; `sync/managed.mjs`) |
|
|
74
75
|
| `HFS_FORMAT` | error | a tracked file the repository's own prettier would change (R19; `sync/format.mjs`, not under `--fast`) |
|
|
75
76
|
| `HFS_FORMAT_TOOL_MISSING` | refusal (exit 2) | prettier is not installed in the repository; the format check is never skipped |
|
|
76
77
|
|
|
@@ -114,12 +115,14 @@ The managed `sonar-project.properties` carries `sonar.externalIssuesReportPaths`
|
|
|
114
115
|
reaches Sonar while the job stays failed. There is no `continue-on-error`. CI runs `npm run lint -- --sonar reports/lint.sonar.json` once at the app root, before the scan. The duplicate-block threshold (`ruleParams.<side>.duplicateBlock`) has no Sonar property for TypeScript (SonarJS detects
|
|
115
116
|
clones with its own token rule), so the machine enforces it (R21) and its findings are imported like every other.
|
|
116
117
|
|
|
117
|
-
The gate is `knowledge/sonar-gate.yaml`, the one declaration: the new-code conditions (duplication, blocker and critical issues, hotspots
|
|
118
|
-
duplicated lines density, cognitive complexity through the S3776 rule). A SonarQube gate condition cannot filter by engine, so the condition counts every
|
|
118
|
+
The gate is `knowledge/sonar-gate.yaml`, the one declaration: the new-code conditions (coverage 100, duplication, blocker and critical issues, hotspots) and an `overall` part (coverage 100, 0 open issues on the whole code,
|
|
119
|
+
every hotspot reviewed, duplicated lines density, cognitive complexity through the S3776 rule). A SonarQube gate condition cannot filter by engine, so the condition counts every
|
|
119
120
|
open issue, imported or native; that is stricter than the three imports alone and is intended. `hfs check` reports `HFS_SONAR_CONFIG` (R11) when the
|
|
120
121
|
properties file is not its render or the stack declaration names another gate. R20 and R21 have their Sonar enforcers as conditions of that file.
|
|
121
122
|
|
|
122
|
-
|
|
123
|
+
Coverage is the services' alone, from one scope. The managed `test` script is `jest --selectProjects unit --coverage`: it fails below the per-file 100 threshold on `src/**/*.service.ts` and writes `be/coverage/lcov.info` (the jest preset's lcov reporter). The managed `sonar-project.properties` imports that report (`sonar.javascript.lcov.reportPaths=be/coverage/lcov.info`) with `sonar.coverage.inclusions=be/src/**/*.service.ts` and no other coverage key, so a handler, resolver, controller, module, config file or test is not a coverage target and `fe/` is outside coverage. The managed `codecov.yml` holds the same paths at 100 on the project and the patch and ignores `fe/**`; the managed CI workflow uploads the lcov with `codecov/codecov-action` after the unit run. `hfs sync` renders the scope into both files from the installed jest preset's `COVERAGE_SOURCES` (`coverageScope` in `sync/index.mjs`), so they can never drift. The runtime judges the coverage per file: `sonar-local.mjs scan` holds every service a slice touched at 100, and `sonar-local.mjs dashboard` fails a project unless every service is at 100.
|
|
124
|
+
|
|
125
|
+
The upload needs the repository secret `CODECOV_TOKEN` (each product monorepo gets its own; the runtime repository's root `.github/workflows/examples.yml` uses the runtime repository's one for the example apps; the owner adds it once per repository: Codecov, the repository's settings, then GitHub Settings > Secrets and variables > Actions > New repository secret `CODECOV_TOKEN`). Without it the step is skipped like the Sonar steps without `SONAR_TOKEN`.
|
|
123
126
|
|
|
124
127
|
## Maintaining the bundle
|
|
125
128
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/hfs",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.6",
|
|
4
4
|
"description": "The HFS command line of a StarCi app (one repository: the root, be/ and fe/): hfs lint, check, scaffold app, explain, sync and work-hygiene. Self-contained: it carries the runtime files it reads.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -35,7 +35,7 @@ import path from 'node:path';
|
|
|
35
35
|
import crypto from 'node:crypto';
|
|
36
36
|
import { createRequire } from 'node:module';
|
|
37
37
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
38
|
-
import { runGit } from '../scripts/
|
|
38
|
+
import { runGit } from '../scripts/api/git/lib.mjs';
|
|
39
39
|
import { sleepSync as scaledSleepSync } from '../scripts/lib/sleep-sync.mjs';
|
|
40
40
|
import { putBlob as storeBlob, blobPath, artifactRoot, getBlob } from '../scripts/lib/artifact-store.mjs';
|
|
41
41
|
import { redactBytes, redactData, redactText } from '../scripts/lib/redact.mjs';
|
|
@@ -43,9 +43,9 @@ import { redactBytes, redactData, redactText } from '../scripts/lib/redact.mjs';
|
|
|
43
43
|
const require = createRequire(import.meta.url);
|
|
44
44
|
const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
45
45
|
export const MACHINE_SCHEMA = 'starci/machine@1';
|
|
46
|
-
export const MACHINE_VERSION =
|
|
46
|
+
export const MACHINE_VERSION = 3;
|
|
47
47
|
/** Forward migrations after 0001-init, in order; each bumps user_version to its `version` (migrateMachine). */
|
|
48
|
-
export const MACHINE_MIGRATIONS = Object.freeze([{ version: 2, name: '0002-worktrees-no-workflow-kind' }]);
|
|
48
|
+
export const MACHINE_MIGRATIONS = Object.freeze([{ version: 2, name: '0002-worktrees-no-workflow-kind' }, { version: 3, name: '0003-worktrees-workflow-orca' }]);
|
|
49
49
|
export const MACHINE_BUSY_TIMEOUT_MS = 15000;
|
|
50
50
|
/** Test seam: STARCI_MACHINE_BUSY_TIMEOUT_MS (a positive integer) replaces the writer's busy_timeout; unset in production. */
|
|
51
51
|
export const busyTimeoutOf = (env = process.env) => { const n = Number(env?.STARCI_MACHINE_BUSY_TIMEOUT_MS); return Number.isInteger(n) && n > 0 ? n : MACHINE_BUSY_TIMEOUT_MS; };
|
|
@@ -1303,7 +1303,7 @@ const upsertWorktree = (m, wt) => upsertRow(m.db, 'worktrees', { created_at: m.n
|
|
|
1303
1303
|
const removedWorktree = (m, wtPath, { error = null, archivedRef = null } = {}) => m.db.prepare('UPDATE worktrees SET removed_at=CASE WHEN ? IS NULL THEN ? ELSE removed_at END, remove_error=?, archived_ref=COALESCE(?,archived_ref) WHERE path=?')
|
|
1304
1304
|
.run(error, m.now(), error, archivedRef, path.resolve(wtPath)).changes > 0;
|
|
1305
1305
|
/**
|
|
1306
|
-
* The worktree registry (scripts/lib/
|
|
1306
|
+
* The worktree registry (scripts/lib/worktree-registry.mjs and the worktree api files are its callers): reserve a row for a worktree about to be created,
|
|
1307
1307
|
* atomically against the per-repo cap. BEGIN IMMEDIATE: two dispatches never both take the last slot. `cap` null: no cap.
|
|
1308
1308
|
* {ok, live} | {ok:false, reason:'worktree-cap', live, cap}
|
|
1309
1309
|
*/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
-- 0003-worktrees-workflow-orca (starci/machine@1, user_version 2 -> 3). Applied by engine/machine-db.mjs migrateMachine on
|
|
2
|
+
-- the first writer open of an older machine.sqlite (after an integrity_check and a VACUUM INTO backup, with foreign_key_check
|
|
3
|
+
-- + quick_check before COMMIT); a fresh machine.sqlite runs 0001 then the forward files.
|
|
4
|
+
--
|
|
5
|
+
-- One worktree per Kernel workflow replaces the per-op worktree (owner decision WFWT, final). Orca creates and owns it (the
|
|
6
|
+
-- Kernel launch), so the registry keys it by Orca's worktree id (orca_id, unique among the rows that have one), records
|
|
7
|
+
-- the workflow's last checkpoint (checkpoint_sha) and the moment its finish asked for its release (release_pending_at: the
|
|
8
|
+
-- host-side GC removes it once the Kernel's and the ops' terminals are released; never from inside itself). The draw
|
|
9
|
+
-- critic's placement is an Orca worktree too (kind critic). worktrees.kind no longer accepts 'op': a row of that kind
|
|
10
|
+
-- describes the removed mechanism and is deleted (a directory it named, if any is left, is an unregistered worktree the
|
|
11
|
+
-- worktree GC and the footprint scan report); then the table's CHECK is rewritten in place (writable_schema: the views over worktrees stay as they are).
|
|
12
|
+
DELETE FROM worktrees WHERE kind='op';
|
|
13
|
+
ALTER TABLE worktrees ADD COLUMN orca_id TEXT;
|
|
14
|
+
ALTER TABLE worktrees ADD COLUMN checkpoint_sha TEXT;
|
|
15
|
+
ALTER TABLE worktrees ADD COLUMN release_pending_at INTEGER;
|
|
16
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ux_worktrees_orca_id ON worktrees(orca_id) WHERE orca_id IS NOT NULL;
|
|
17
|
+
CREATE INDEX IF NOT EXISTS ix_worktrees_workflow ON worktrees(workflow_id,kind,removed_at);
|
|
18
|
+
PRAGMA writable_schema=ON;
|
|
19
|
+
UPDATE sqlite_master
|
|
20
|
+
SET sql=replace(sql, 'CHECK(kind IN (''op'',''land-scratch'',', 'CHECK(kind IN (''workflow'',''critic'',''land-scratch'',')
|
|
21
|
+
WHERE type='table' AND name='worktrees';
|
|
@@ -16,6 +16,13 @@ purpose: >-
|
|
|
16
16
|
pins:
|
|
17
17
|
# --- @starci packages. Every @starci package is a PUBLISHED npm package (install: registry): a product repository
|
|
18
18
|
# installs the exact pinned version from the npm registry in the root and every workspace package.json, never `file:`.
|
|
19
|
+
'@starci/heroicons':
|
|
20
|
+
version: 0.3.1
|
|
21
|
+
group: starci
|
|
22
|
+
install: registry
|
|
23
|
+
side: fe
|
|
24
|
+
source: packages/heroicons/package.json
|
|
25
|
+
why: 'StarCi custom Heroicons-compatible cuts; eslint-canon-fe (icon.mjs) admits @starci/heroicons/24/outline and /16/solid as icon sources, so an app that uses them gets this exact version.'
|
|
19
26
|
'@starci/grammar':
|
|
20
27
|
version: 0.8.1
|
|
21
28
|
group: starci
|
|
@@ -24,14 +31,14 @@ pins:
|
|
|
24
31
|
source: packages/grammar/package.json
|
|
25
32
|
why: nivo-fe pins 0.4.11 and 0.5.0 in one workspace, starci-next-fe 0.5.1, miamia-fe 0.5.0; the runtime source is 0.8.0 (the brand layer sets `--font-sans` and `--font-mono`; the grammar reads them; 0.7.2 added the Input tel kind and IconButton disclosure props).
|
|
26
33
|
'@starci/eslint-canon-be':
|
|
27
|
-
version: 3.0.
|
|
34
|
+
version: 3.0.6
|
|
28
35
|
group: starci
|
|
29
36
|
install: registry
|
|
30
37
|
side: be
|
|
31
38
|
source: packages/eslint/be/package.json
|
|
32
39
|
why: '3.0.3: its bundled canon-pins copy pins stylelint-canon 2.0.2 and hfs 4.0.3; no rule changed. 3.0.2: its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root (app.starcistacks, app.sops) and pin hfs 4.0.2. 3.0.1: its bundled canon-pins copy pins hfs 4.0.1. 3.0.0: `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; the project graph is built per side. 2.0.0 (C0 release): starciBeConfig({ hfs: loadHfs(import.meta.url) }) typed factory and the BE-CONVENTION laws.'
|
|
33
40
|
'@starci/eslint-canon-fe':
|
|
34
|
-
version: 8.0.
|
|
41
|
+
version: 8.0.6
|
|
35
42
|
group: starci
|
|
36
43
|
install: registry
|
|
37
44
|
side: fe
|
|
@@ -57,21 +64,21 @@ pins:
|
|
|
57
64
|
side: both
|
|
58
65
|
source: packages/prettier-config/package.json
|
|
59
66
|
'@starci/jest-preset':
|
|
60
|
-
version: 2.2.
|
|
67
|
+
version: 2.2.2
|
|
61
68
|
group: starci
|
|
62
69
|
install: registry
|
|
63
70
|
side: be
|
|
64
71
|
source: packages/jest-preset/package.json
|
|
65
|
-
why: '2.2.0: the integration, e2e and contract projects run every spec file in a worker process of its own (world-runner.cjs), so a process-global framework registry (@nestjs/graphql type metadata) never leaks between e2e files; 2.1.0: four projects on the test world, the unit kit (mockEntityManager, fakeTransaction, fakeIds, FakeClock, Outcome matchers) and the recordingOutbox claim side.'
|
|
72
|
+
why: '2.2.2: the unit run writes the lcov Sonar and Codecov import (services only); 2.2.0: the integration, e2e and contract projects run every spec file in a worker process of its own (world-runner.cjs), so a process-global framework registry (@nestjs/graphql type metadata) never leaks between e2e files; 2.1.0: four projects on the test world, the unit kit (mockEntityManager, fakeTransaction, fakeIds, FakeClock, Outcome matchers) and the recordingOutbox claim side.'
|
|
66
73
|
'@starci/test-world':
|
|
67
|
-
version: 1.0.
|
|
74
|
+
version: 1.0.5
|
|
68
75
|
group: starci
|
|
69
76
|
install: registry
|
|
70
77
|
side: be
|
|
71
78
|
source: packages/test-world/package.json
|
|
72
|
-
why: '1.0.3: the globalSetup registers the path aliases as TypeScript resolves them (the extends chain, paths from the config that declares them, the effective baseUrl), so a tests tsconfig that only extends the side config loads the declaration. 1.0.2: every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json), where .starcistacks lives, never from the be side. The shared e2e library of every back end (R47, R48): the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs, the outage lock and the starci-test-stack bin behind the managed test:stack script; a devDependency of every back end. 1.0.1: world.keycloak.events/sessions (the user events of the realm and live sessions through the admin API) and world.infra.postgresql.connection(name) (the database of one connection down while the others serve, under the outage lock).'
|
|
79
|
+
why: '1.0.5: a modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, and w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client. 1.0.4: world.resolve and scope.resolve take any Nest token (class, string or symbol). 1.0.3: the globalSetup registers the path aliases as TypeScript resolves them (the extends chain, paths from the config that declares them, the effective baseUrl), so a tests tsconfig that only extends the side config loads the declaration. 1.0.2: every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json), where .starcistacks lives, never from the be side. The shared e2e library of every back end (R47, R48): the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs, the outage lock and the starci-test-stack bin behind the managed test:stack script; a devDependency of every back end. 1.0.1: world.keycloak.events/sessions (the user events of the realm and live sessions through the admin API) and world.infra.postgresql.connection(name) (the database of one connection down while the others serve, under the outage lock).'
|
|
73
80
|
'@starci/hfs':
|
|
74
|
-
version: 4.0.
|
|
81
|
+
version: 4.0.6
|
|
75
82
|
group: starci
|
|
76
83
|
install: registry
|
|
77
84
|
side: both
|
|
@@ -246,7 +246,7 @@ slots:
|
|
|
246
246
|
rules: [HFS_TOOL_CONFIG_LOCAL]
|
|
247
247
|
- id: app.quality-config
|
|
248
248
|
profiles: [app]
|
|
249
|
-
path: sonar-project.properties
|
|
249
|
+
path: "{sonar-project.properties,codecov.yml}"
|
|
250
250
|
presence: required
|
|
251
251
|
tracked: tracked
|
|
252
252
|
tier: none
|
|
@@ -373,7 +373,7 @@ slots:
|
|
|
373
373
|
tracked: external
|
|
374
374
|
tier: none
|
|
375
375
|
tests: none
|
|
376
|
-
goesTo: "D:/starci-lanes/<project>/<lane>/ (outside every repository) for a lane; a
|
|
376
|
+
goesTo: "D:/starci-lanes/<project>/<lane>/ (outside every repository) for a lane; a Kernel workflow's worktree is created and owned by Orca outside the app checkout, never tracked, and removed by the runtime's host-side controller once the workflow's finish marked it release-pending"
|
|
377
377
|
- id: app.plaintext-env
|
|
378
378
|
profiles: [app]
|
|
379
379
|
path: "{.env,.env.*,.secrets/,**/*.pem,**/*.key}"
|
|
@@ -387,12 +387,12 @@ slots:
|
|
|
387
387
|
# ----- side root (both sides): what the old standalone repository root held, less the app-root files ----------
|
|
388
388
|
- id: repo.side-root-forbidden
|
|
389
389
|
profiles: [be, fe]
|
|
390
|
-
path: "{package.json,package-lock.json,hfs.json,README.md,.gitignore,.gitattributes,.husky/,.github/,.starciwork/,.starcistacks/,.sops.yaml,sonar-project.properties,.prettierrc,.prettierignore,scripts/}"
|
|
390
|
+
path: "{package.json,package-lock.json,hfs.json,README.md,.gitignore,.gitattributes,.husky/,.github/,.starciwork/,.starcistacks/,.sops.yaml,sonar-project.properties,codecov.yml,.prettierrc,.prettierignore,scripts/}"
|
|
391
391
|
presence: forbidden
|
|
392
392
|
tracked: external
|
|
393
393
|
tier: none
|
|
394
394
|
tests: none
|
|
395
|
-
goesTo: "the app root: one package.json, lockfile, hfs.json, README, git and CI files, hooks, formatter, Sonar configuration, scripts/, .starciwork, .starcistacks and .sops.yaml per app"
|
|
395
|
+
goesTo: "the app root: one package.json, lockfile, hfs.json, README, git and CI files, hooks, formatter, Sonar and Codecov configuration, scripts/, .starciwork, .starcistacks and .sops.yaml per app"
|
|
396
396
|
- id: be.tool-config
|
|
397
397
|
profiles: [be]
|
|
398
398
|
# Managed files (hfs sync renders them, hfs check compares them): the whole tool configuration of a back end. Each is
|
|
@@ -818,9 +818,9 @@ slots:
|
|
|
818
818
|
tier: integrations
|
|
819
819
|
owner: true
|
|
820
820
|
requires: [index.ts, "<provider>.config.ts"]
|
|
821
|
-
tests: unit-beside # <name>.service.spec.ts beside each <name>.service.ts
|
|
821
|
+
tests: unit-beside # <name>.service.spec.ts beside each <name>.service.ts; plus src/tests/integration/<provider>/ (R112)
|
|
822
822
|
budget: {indexExports: 60}
|
|
823
|
-
rules: [BE_TIER_DIRECTION, BE_ERROR_HOME, BE_SECRET_DEFAULT]
|
|
823
|
+
rules: [BE_TIER_DIRECTION, BE_ERROR_HOME, BE_SECRET_DEFAULT, BE_INTEGRATION_SPEC_MISSING]
|
|
824
824
|
- id: be.integrations.model
|
|
825
825
|
profiles: [be]
|
|
826
826
|
path: "src/modules/integrations/<provider>/model/"
|
|
@@ -897,8 +897,8 @@ slots:
|
|
|
897
897
|
tracked: tracked
|
|
898
898
|
tier: e2e
|
|
899
899
|
tests: e2e
|
|
900
|
-
why: one capability module
|
|
901
|
-
rules: [BE_TEST_TOPOLOGY]
|
|
900
|
+
why: one capability module through useTestWorld({ modules }), no HTTP door of ours (SQL, transactions, concurrency, inbox claims on the real database; and every integration client, `<capability>` being its provider folder, against our real infra, a real peer app or the library fake at the network edge, with its success, its ErrorCode refusal mapping and an outage through the world, R112); run by test:integration
|
|
901
|
+
rules: [BE_TEST_TOPOLOGY, BE_INTEGRATION_SPEC_MISSING]
|
|
902
902
|
- id: be.tests.e2e
|
|
903
903
|
profiles: [be]
|
|
904
904
|
path: "src/tests/e2e/<area>/*.e2e-spec.ts"
|
|
@@ -8,16 +8,24 @@ purpose: |
|
|
|
8
8
|
conditions and selects it for the project, and (2) judges an op's slice - the lines it changed - against
|
|
9
9
|
the same numbers. Code-writing ops cannot settle done while the slice is red
|
|
10
10
|
(scripts/kernel/sonar-settle.mjs). Changing a number here changes every repository on the next scan.
|
|
11
|
-
The gate has two parts: `newCode` (the recent work: duplication, blocker/critical issues, hotspots) and
|
|
12
|
-
`overall` (the whole code: no imported HFS, ESLint or stylelint finding, no
|
|
13
|
-
|
|
11
|
+
The gate has two parts: `newCode` (the recent work: coverage, duplication, blocker/critical issues, hotspots) and
|
|
12
|
+
`overall` (the whole code: coverage, no imported HFS, ESLint or stylelint finding, no bug, code smell or
|
|
13
|
+
vulnerability, every hotspot reviewed, no duplicated-lines excess, no cognitive-complexity issue). The one Sonar
|
|
14
|
+
mechanism of the HFS canon is packages/hfs/README.md section "Sonar".
|
|
14
15
|
gate:
|
|
15
16
|
name: starci-new-code
|
|
16
17
|
# A project with no new-code baseline judges the whole project as new code (nivo inc-f92febebbb64); a fixed
|
|
17
18
|
# window keeps "new code" the recent work on main, so the server gate is passable on a healthy main.
|
|
18
19
|
newCodePeriod: {type: NUMBER_OF_DAYS, value: 30}
|
|
19
20
|
newCode:
|
|
20
|
-
#
|
|
21
|
+
# Coverage of the services only. The be unit run writes coverage/lcov.info (@starci/jest-preset coverageReporters lcov) and the
|
|
22
|
+
# managed sonar-project.properties imports it (sonar.javascript.lcov.reportPaths=be/coverage/lcov.info) with
|
|
23
|
+
# sonar.coverage.inclusions=be/src/**/*.service.ts, the one scope rendered by hfs sync from the preset's COVERAGE_SOURCES (codecov.yml
|
|
24
|
+
# is rendered from the same constant). A handler, resolver, controller, module, config file or test is not a coverage target and
|
|
25
|
+
# fe/ is outside coverage entirely, so coverage on these conditions is the services' coverage alone.
|
|
26
|
+
coverage:
|
|
27
|
+
metric: new_coverage
|
|
28
|
+
minPercent: 100
|
|
21
29
|
# SonarQube's ignoreSmallChanges: fewer changed lines than this are not held to duplication.
|
|
22
30
|
ignoreBelowChangedLines: 20
|
|
23
31
|
duplication:
|
|
@@ -49,6 +57,19 @@ overall:
|
|
|
49
57
|
metric: violations
|
|
50
58
|
max: 0
|
|
51
59
|
engines: [starci-hfs, eslint, stylelint]
|
|
60
|
+
# The dashboard counts (`sonar-local dashboard`): each type of open issue, each held at `max`.
|
|
61
|
+
types: {bugs: BUG, code_smells: CODE_SMELL, vulnerabilities: VULNERABILITY}
|
|
62
|
+
# Every security hotspot of the whole code is reviewed (the dashboard's "hotspots reviewed").
|
|
63
|
+
hotspots:
|
|
64
|
+
metric: security_hotspots_reviewed
|
|
65
|
+
minReviewedPercent: 100
|
|
66
|
+
# The services' coverage (see newCode.coverage), overall and per file: with the inclusions in place each `*.service.ts` is its
|
|
67
|
+
# own measure, so one service below 100 fails the slice verdict (sonar-local evaluateSlice) and the dashboard verdict
|
|
68
|
+
# (sonar-local dashboard) although the project average may round to 100. A file outside the inclusions is never read.
|
|
69
|
+
coverage:
|
|
70
|
+
metric: coverage
|
|
71
|
+
minPercent: 100
|
|
72
|
+
perFile: true
|
|
52
73
|
# HFS_DUPLICATE_CODE (R21): duplicated lines density of the whole project, the Sonar enforcer of the machine's clone check.
|
|
53
74
|
# SonarJS runs its own token-based detection (sonar.cpd.* minimums are not read for TypeScript), so ruleParams.<profile>.duplicateBlock
|
|
54
75
|
# is enforced by the machine and imported as starci-hfs issues, and this density is the Sonar-side backstop.
|
|
@@ -70,8 +91,10 @@ enforcedOps: [backend.implement, interface.implement, code.refactor]
|
|
|
70
91
|
reasoning: |
|
|
71
92
|
Chosen against the four projects the local server holds on 2026-09-29 (whole-project duplicated lines
|
|
72
93
|
1.6 / 2.1 / 1.7 / 0.0 percent), so new code that meets them is at or below what main already carries.
|
|
73
|
-
|
|
74
|
-
imports
|
|
94
|
+
coverage 100 on services, overall and new code (owner 2026-10-01): the unit project already fails below its per-file
|
|
95
|
+
100 threshold on `*.service.ts`; Sonar imports the same run's lcov with the same scope, so the dashboard, the slice
|
|
96
|
+
verdict and the server gate show the number the runner enforces, file by file. Only services count: business logic
|
|
97
|
+
lives there, the thin layers are covered through them, and the front end has no unit tests.
|
|
75
98
|
duplication 3: the Sonar way value; the worst project today is 2.1, so 3 rejects a real copy-paste block and
|
|
76
99
|
passes ordinary work.
|
|
77
100
|
blocker/critical 0: the two severities that are defects, not style. main carries old debt of both kinds
|
|
@@ -384,6 +384,16 @@ BE_FEATURE_SHAPE:
|
|
|
384
384
|
owner: op-retry
|
|
385
385
|
kind: check-finding
|
|
386
386
|
|
|
387
|
+
BE_INTEGRATION_SPEC_MISSING:
|
|
388
|
+
title: "Every integration has an integration spec that proves its client through the test world"
|
|
389
|
+
title_vi: "Tích hợp thiếu spec integration chứng minh client qua test world"
|
|
390
|
+
meaning_vi: "Thư mục `src/modules/integrations/<provider>/` (có `<provider>.config.ts`) không có `src/tests/integration/<provider>/*.integration-spec.ts` nào vừa gọi `useTestWorld({ modules })` với factory đăng ký module của chính tích hợp đó (`<Provider>Module.register`, import từ thư mục tích hợp), vừa tham chiếu enum ErrorCode của tích hợp, vừa gây một sự cố qua world (`world.infra.<service>`, `world.fake.<name>.failNext`, `world.apps.<peer>.during`, `world.interruptDatabase`)."
|
|
391
|
+
causes_vi:
|
|
392
|
+
- "Vi phạm luật R112: client của tích hợp chỉ được kiểm bằng unit spec với double, nên ánh xạ lỗi và đường sự cố của nhà cung cấp thật (hoặc fake ở biên mạng) chưa bao giờ chạy; lỗi phân loại timeout của FetchHttpClient chỉ lộ ra khi spec integration đầu tiên chạy."
|
|
393
|
+
nextStep_vi: "Op vừa tạo ra bản này sẽ được chạy lại để sửa theo lời nhắn của bộ kiểm tra: thêm hoặc bổ sung spec integration của đúng thư mục provider cho phần còn thiếu (đăng ký module, enum ErrorCode, sự cố qua world); không thêm ngoại lệ."
|
|
394
|
+
owner: op-retry
|
|
395
|
+
kind: check-finding
|
|
396
|
+
|
|
387
397
|
BE_MODULE_HANDLER_REGISTRATION:
|
|
388
398
|
title: "Handler not registered exactly once"
|
|
389
399
|
title_vi: "Handler chưa đăng ký đúng một lần"
|
|
@@ -1482,11 +1492,11 @@ HFS_SLOT_UNDECLARED:
|
|
|
1482
1492
|
kind: check-finding
|
|
1483
1493
|
|
|
1484
1494
|
HFS_SONAR_CONFIG:
|
|
1485
|
-
title: "Sonar config is generated, with no host URL
|
|
1495
|
+
title: "Sonar config is generated, with no host URL, importing the be lcov with the services as the only coverage scope"
|
|
1486
1496
|
title_vi: "Cấu hình Sonar lệch"
|
|
1487
|
-
meaning_vi: "Cấu hình Sonar lệch: `<detail>`. Sonar phải đọc đúng cấu hình sinh ra
|
|
1497
|
+
meaning_vi: "Cấu hình Sonar lệch: `<detail>`. Sonar phải đọc đúng cấu hình sinh ra và nhập báo cáo độ phủ lcov của be, chỉ tính các service."
|
|
1488
1498
|
causes_vi:
|
|
1489
|
-
- "Vi phạm luật R11: `sonar-project.properties` do template sinh: không `sonar.host.url`, `sources`/`tests` không chồng, `sonar.exclusions` = đúng danh sách loại trừ của jest,
|
|
1499
|
+
- "Vi phạm luật R11: `sonar-project.properties` do template sinh: không `sonar.host.url`, `sources`/`tests` không chồng, `sonar.exclusions` = đúng danh sách loại trừ của jest, nhập lcov của be (`sonar.javascript.lcov.reportPaths=be/coverage/lcov.info`) với `sonar.coverage.inclusions=be/src/**/*.service.ts` và không khóa độ phủ nào khác, không nhắc kiểu spec đã bỏ, và nhận báo cáo ESLint cùng tệp nhập lỗi HFS (`sonar.eslint.reportPaths`, `sonar.externalIssuesReportPaths`) để mọi lỗi canon hiện trong Sonar."
|
|
1490
1500
|
- "Khối `services.sonar.qualityGate` của khai báo stack phải gọi đúng cổng chất lượng duy nhất của `knowledge/sonar-gate.yaml`; repo không tự ghi ngưỡng."
|
|
1491
1501
|
nextStep_vi: "Op vừa tạo ra bản này sẽ được chạy lại để sửa theo lời nhắn của bộ kiểm tra (nợ hàng loạt có codemod của HFS); không cần ai can thiệp thêm."
|
|
1492
1502
|
owner: op-retry
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// rmdir-link.mjs — the link-safe removal primitive: `cmd /d /c rmdir <link>` (no /s) removes a Windows junction or
|
|
2
|
+
// directory symlink as a link and never touches its target. scripts/lib/safe-remove.mjs removeLink is its one caller and
|
|
3
|
+
// checks afterwards that the link is gone (unlinkOnly); this file only issues the call.
|
|
4
|
+
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
/** Issue `cmd /d /c rmdir <p>` on Windows; elsewhere nothing (a POSIX link is unlinked by the caller). {status, error}. */
|
|
8
|
+
export function rmdirLink(p, { platform = process.platform } = {}) {
|
|
9
|
+
if (platform !== 'win32') return { status: null, error: null };
|
|
10
|
+
const r = spawnSync('cmd', ['/d', '/c', 'rmdir', p], { windowsHide: true, encoding: 'utf8' });
|
|
11
|
+
return { status: r.status, error: r.error?.message ?? null };
|
|
12
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// git.mjs — the one
|
|
1
|
+
// scripts/api/git/lib.mjs — the one place the runtime spawns git (scripts/checks/check-layers.mjs enforces it).
|
|
2
2
|
//
|
|
3
|
-
// Every
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// Every caller spelt the same options by hand - encoding:'utf8', windowsHide:true, sometimes a timeout - with two shapes:
|
|
4
|
+
// `git args` in a cwd and `git -C dir args`. gitOutput is the throwing shape (stdout text, or an Error on a non-zero exit).
|
|
5
|
+
// gitSpawn keeps the spawnSync(file, args, options) signature so an injected runner or a spec's fake takes the same three
|
|
6
|
+
// arguments; runGit is the `-C` convenience; gitResult folds the result into the {ok, stdout, error} envelope; gitRunner
|
|
7
|
+
// folds any caller's runner into {ok, stdout, stderr}. The call files beside this one (worktree-*.mjs, rev-parse.mjs, ...)
|
|
8
|
+
// each name one git verb.
|
|
9
9
|
import { spawnSync } from 'node:child_process';
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -43,11 +43,10 @@ export function gitResult(args, options = {}) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* a value not wrapped in quotes passes through unchanged.
|
|
46
|
+
* A caller's git runner folded to (args, opts) -> {ok, stdout, stderr}. `git`: the caller's runner (args, {cwd}) ->
|
|
47
|
+
* {ok|status, stdout|out, stderr|err} (a spec's fake); null: runGit with a 5-minute timeout and a 64 MB buffer.
|
|
49
48
|
*/
|
|
50
|
-
export const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
export const gitRunner = (git = null) => (args, opts = {}) => {
|
|
50
|
+
const r = git ? git(args, opts) : runGit(args, { timeout: 300_000, maxBuffer: 64 * 1024 * 1024, ...opts });
|
|
51
|
+
return { ok: r?.ok ?? (!r?.error && r?.status === 0), stdout: String(r?.stdout ?? r?.out ?? '').trim(), stderr: String(r?.stderr ?? r?.err ?? r?.error?.message ?? r?.error ?? '').trim() };
|
|
52
|
+
};
|
|
@@ -18,8 +18,12 @@
|
|
|
18
18
|
* used by the importers of that file; a re-exporting file nobody imports is a framework entry (a route file) and uses it.
|
|
19
19
|
* Specs and tests are not in the graph, so an export used only by a spec is dead, on purpose, with one exception (unit test
|
|
20
20
|
* standard): a `<name>.service.spec.ts` (the only unit spec kind) and a `*.builder.ts` under src/tests/fixtures/builders read from
|
|
21
|
-
* disk count as consumers, because a spec can only provide an Inject*() token or a param type the entry exports.
|
|
22
|
-
*
|
|
21
|
+
* disk count as consumers, because a spec can only provide an Inject*() token or a param type the entry exports. An integration
|
|
22
|
+
* spec (slot be.tests.integration, `src/tests/integration/<capability>/*.integration-spec.ts`) counts as a consumer of exactly one
|
|
23
|
+
* owner: the integration (slot be.integrations, `src/modules/integrations/<provider>/`) whose provider folder is its capability
|
|
24
|
+
* folder, because R112 makes that spec register the integration module and reference its ErrorCode enum. Specs of any other kind,
|
|
25
|
+
* an integration spec importing another owner, e2e specs and world files still do not count. A consumer inside the owner is
|
|
26
|
+
* skipped like any inside consumer.
|
|
23
27
|
*/
|
|
24
28
|
import fs from 'node:fs';
|
|
25
29
|
import { canonical } from './config.mjs';
|
|
@@ -168,13 +172,35 @@ function deadFiles(graph, config) {
|
|
|
168
172
|
}
|
|
169
173
|
|
|
170
174
|
const TEST_CONSUMER = /^(?:(?:src|apps)\/.+\.service\.spec\.ts|src\/tests\/fixtures\/builders\/.+\.builder\.ts)$/u;
|
|
175
|
+
/** The slot of integration specs and the slot of the integrations they pair with by folder (knowledge/hfs/slots.yaml). */
|
|
176
|
+
const INTEGRATION_SPEC_SLOT = 'be.tests.integration';
|
|
177
|
+
const INTEGRATION_SLOT = 'be.integrations';
|
|
171
178
|
|
|
172
|
-
/**
|
|
179
|
+
/** The provider an integration spec pairs with (its `<capability>` folder), or null when `rel` is no integration spec. */
|
|
180
|
+
function integrationSpecProvider(graph, rel) {
|
|
181
|
+
const classified = graph.resolver.classifyPath(rel);
|
|
182
|
+
return classified.slot === INTEGRATION_SPEC_SLOT && classified.status !== 'forbidden' ? classified.bindings?.capability ?? null : null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Whether `to` belongs to the integration of `provider` (slot be.integrations, its `<provider>` binding). */
|
|
186
|
+
function inIntegrationOf(graph, to, provider) {
|
|
187
|
+
const owner = graph.resolver.ownerOf(to);
|
|
188
|
+
return owner?.slot === INTEGRATION_SLOT && owner.bindings?.provider === provider;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Pseudo edges (read from disk, outside the production program) to graph files: from the unit specs of services and the fixture
|
|
193
|
+
* builders to anything, and from an integration spec only to the integration of its own provider folder.
|
|
194
|
+
*/
|
|
173
195
|
function testConsumerEdges({ context, graph, config }) {
|
|
174
196
|
const { ts } = context;
|
|
175
197
|
const options = context.projects?.[0]?.options ?? {};
|
|
176
198
|
const edges = [];
|
|
177
|
-
|
|
199
|
+
const consumers = [...treeOf(config.root).files]
|
|
200
|
+
.map(file => ({ file, provider: TEST_CONSUMER.test(file) ? null : integrationSpecProvider(graph, file) }))
|
|
201
|
+
.filter(({ file, provider }) => provider !== null || TEST_CONSUMER.test(file))
|
|
202
|
+
.sort((a, b) => a.file.localeCompare(b.file));
|
|
203
|
+
for (const { file: rel, provider } of consumers) {
|
|
178
204
|
const abs = path.join(config.root, ...rel.split('/'));
|
|
179
205
|
let text;
|
|
180
206
|
try { text = fs.readFileSync(abs, 'utf8'); } catch { continue; }
|
|
@@ -183,7 +209,7 @@ function testConsumerEdges({ context, graph, config }) {
|
|
|
183
209
|
if (!(ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) || !statement.moduleSpecifier || !ts.isStringLiteralLike(statement.moduleSpecifier)) continue;
|
|
184
210
|
const resolved = ts.resolveModuleName(statement.moduleSpecifier.text, abs, options, ts.sys).resolvedModule?.resolvedFileName;
|
|
185
211
|
const to = resolved ? graph.abs(canonical(resolved)) : null;
|
|
186
|
-
if (to) edges.push({ from: rel, to, edge: { declaration: statement }, reexport: false });
|
|
212
|
+
if (to && (provider === null || inIntegrationOf(graph, to, provider))) edges.push({ from: rel, to, edge: { declaration: statement }, reexport: false });
|
|
187
213
|
}
|
|
188
214
|
}
|
|
189
215
|
return edges;
|
|
@@ -254,7 +280,7 @@ export function checkDeadExports({ context, graph, config }) {
|
|
|
254
280
|
ruleId: 'HFS_UNUSED_EXPORT',
|
|
255
281
|
path: entry, line, column: 1,
|
|
256
282
|
name, owner: owner.root, slot: owner.slot,
|
|
257
|
-
message: `${entry} exports ${name}, but no production file outside ${owner.root || 'the repository root'} imports it; remove the export (only a service unit spec
|
|
283
|
+
message: `${entry} exports ${name}, but no production file outside ${owner.root || 'the repository root'} imports it; remove the export (only a service unit spec, a fixture builder, or an integration spec of this integration's own provider folder counts besides production files).`,
|
|
258
284
|
});
|
|
259
285
|
}
|
|
260
286
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { isIP } from 'node:net';
|
|
4
|
-
import { gitOutput } from '../../
|
|
4
|
+
import { gitOutput } from '../../api/git/lib.mjs';
|
|
5
5
|
import { repositoryName } from '../../lib/repo-identity.mjs';
|
|
6
6
|
import { braceVariants } from '../../lib/glob.mjs';
|
|
7
7
|
import { createSlotResolver, loadSlotManifest, openHfs } from '../../lib/hfs-slots.mjs';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { gitOutput } from '../../
|
|
3
|
+
import { gitOutput } from '../../api/git/lib.mjs';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* HFS check 5: the files and directories the slot manifest requires (knowledge/hfs/slots.yaml `requires`,
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
// HFS_LINT_SUPPRESSION_FILE (R104, hfs-rules/lint-suppression.mjs) an eslint suppressions file, script or option
|
|
28
28
|
// HFS_PROOF_COMMAND_FILE_MISSING (R105, hfs-rules/proof-commands.mjs) a .starciwork proof command that runs a file the repository does not hold
|
|
29
29
|
// HFS_PEER_INTEGRATION_MISSING (R111, hfs-rules/peer-integrations.mjs) the app root package.json lacks the runtime peer a driver integration needs
|
|
30
|
+
// BE_INTEGRATION_SPEC_MISSING (R112, hfs-rules/integration-specs.mjs) an integration with no integration spec that registers its module, maps its refusals and drives an outage
|
|
30
31
|
// FE_WIRE_GENERATED, FE_I18N_PLACEMENT, FE_I18N_CATALOG (R52, R59, R60, hfs-rules/frontend.mjs) the front-end tree of each app
|
|
31
32
|
// HFS_GITIGNORE_BLOCK_DRIFT, HFS_SONAR_CONFIG (R04, R11) produced by packages/hfs/sync/managed.mjs, which renders the templates
|
|
32
33
|
// HFS_FORMAT (R19) produced by packages/hfs/sync/format.mjs, which runs the repository's own prettier
|
|
@@ -48,12 +49,13 @@ import { ARCHITECTURE_RULE_IDS, checkArchitecture } from '../checks/architecture
|
|
|
48
49
|
import { parseYaml } from '../../engine/yaml.mjs';
|
|
49
50
|
import { APP_SCOPE, HFS_DECLARATION_FILE, appRelativeMessages, HfsSlotsError, SIDES, createSlotResolver, loadSlotManifest, readRepoDeclaration, resolveRepoDeclaration } from './hfs-slots.mjs';
|
|
50
51
|
import { allowsFile } from './hfs-allows.mjs';
|
|
51
|
-
import { gitOutput } from '
|
|
52
|
+
import { gitOutput } from '../api/git/lib.mjs';
|
|
52
53
|
import { posixPath } from './path-key.mjs';
|
|
53
54
|
import { readTree, treeFacts, untrackedEntries } from './hfs-tree.mjs';
|
|
54
55
|
import { contractFindings } from './hfs-rules/contract.mjs';
|
|
55
56
|
import { depFindings } from './hfs-rules/deps.mjs';
|
|
56
57
|
import { appFrontendFindings, frontendFindings } from './hfs-rules/frontend.mjs';
|
|
58
|
+
import { integrationSpecFindings } from './hfs-rules/integration-specs.mjs';
|
|
57
59
|
import { lintSuppressionFindings } from './hfs-rules/lint-suppression.mjs';
|
|
58
60
|
import { peerIntegrationFindings } from './hfs-rules/peer-integrations.mjs';
|
|
59
61
|
import { pipelineFindings } from './hfs-rules/pipeline.mjs';
|
|
@@ -84,7 +86,7 @@ export const CHECK_CODES = Object.freeze([
|
|
|
84
86
|
'HFS_SLOT_REQUIRED_MISSING', 'HFS_MIN_INSTANCES', 'HFS_CANON_PIN_DRIFT', 'HFS_SIZE_SOFT_BACKLOG', 'BE_SOURCE_FORM',
|
|
85
87
|
'HFS_MANAGED_FILE_DRIFT', 'HFS_TOOL_CONFIG_LOCAL', 'HFS_RULE_OFF_WITHOUT_REPLACEMENT', 'HFS_TS_STRICT',
|
|
86
88
|
'HFS_PLAINTEXT_SECRET', 'HFS_STACKS_SHAPE', 'HFS_CI_MISSING_CANON', 'HFS_DEP_VERSION_SKEW', 'HFS_CONTRACT_SNAPSHOT_DRIFT',
|
|
87
|
-
'BE_TEST_TOPOLOGY', 'BE_SPEC_PLACEMENT', 'HFS_REPO_LOCAL_CHECK', 'HFS_LINT_SUPPRESSION_FILE', 'HFS_PROOF_COMMAND_FILE_MISSING', 'HFS_PEER_INTEGRATION_MISSING', 'FE_NO_TESTS', 'FE_WIRE_GENERATED', 'FE_I18N_PLACEMENT', 'FE_I18N_CATALOG',
|
|
89
|
+
'BE_TEST_TOPOLOGY', 'BE_SPEC_PLACEMENT', 'HFS_REPO_LOCAL_CHECK', 'HFS_LINT_SUPPRESSION_FILE', 'HFS_PROOF_COMMAND_FILE_MISSING', 'HFS_PEER_INTEGRATION_MISSING', 'BE_INTEGRATION_SPEC_MISSING', 'FE_NO_TESTS', 'FE_WIRE_GENERATED', 'FE_I18N_PLACEMENT', 'FE_I18N_CATALOG',
|
|
88
90
|
'HFS_GITIGNORE_BLOCK_DRIFT', 'HFS_SONAR_CONFIG', 'HFS_FORMAT',
|
|
89
91
|
'HFS_EMPTY_DIR', 'HFS_GHOST_TREE', 'HFS_UNTRACKED_ROOT_ENTRY',
|
|
90
92
|
...REFUSAL_CODES,
|
|
@@ -265,6 +267,7 @@ function scopeFindings({ repoRoot, root, repo, resolver, files, all = files, sco
|
|
|
265
267
|
findings.push(
|
|
266
268
|
...depFindings({ repoRoot, files: all }),
|
|
267
269
|
...peerIntegrationFindings({ repoRoot, files: all }),
|
|
270
|
+
...integrationSpecFindings({ repoRoot, files: all }),
|
|
268
271
|
...pipelineFindings({ repoRoot, files, pins }),
|
|
269
272
|
...testTopologyFindings({ repoRoot, files }),
|
|
270
273
|
...proofCommandFindings({ repoRoot, files: all, resolver, sides: Object.keys(repo.sides ?? {}) }),
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// integration-specs.mjs - BE_INTEGRATION_SPEC_MISSING (R112): every integration of a back end has an integration spec that proves
|
|
2
|
+
// its client through the test world. An integration is the slot be.integrations: `src/modules/integrations/<provider>/` holding
|
|
3
|
+
// `<provider>.config.ts` (its required config boundary). Its spec is the slot be.tests.integration of the same folder name:
|
|
4
|
+
// `src/tests/integration/<provider>/*.integration-spec.ts`. At least one of those specs, read as a TypeScript syntax tree (never
|
|
5
|
+
// matched by name), must
|
|
6
|
+
// 1. call `useTestWorld({ modules })` where `modules` is an array (inline, a local const, or an exported const of another file,
|
|
7
|
+
// spreads followed) holding a factory whose body calls `<X>.register(...)` with `<X>` imported from that integration;
|
|
8
|
+
// 2. reference an enum the integration exports (its ErrorCode enum): an identifier imported from the integration whose export
|
|
9
|
+
// resolves to an `enum` declaration, used outside the import;
|
|
10
|
+
// 3. drive an outage through the world handle the call answers: `<world>.infra.<service>.cut|latency|during|connection(...)`, `<world>.fake.<name>.failNext(...)`,
|
|
11
|
+
// `<world>.apps.<name>.during(...)` or `<world>.interruptDatabase(...)`.
|
|
12
|
+
// The finding sits on the integration folder. Imports resolve through the side's tsconfig (paths included) with the app's own
|
|
13
|
+
// TypeScript, the one every back end installs; the runtime's TypeScript is the fallback.
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { findPackage, requirePackage } from '../package-at.mjs';
|
|
18
|
+
import { found, readText } from './read.mjs';
|
|
19
|
+
|
|
20
|
+
export const INTEGRATION_SPEC_MISSING = 'BE_INTEGRATION_SPEC_MISSING';
|
|
21
|
+
|
|
22
|
+
const CONFIG_FILE = /^((?:[^/]+\/)*?)src\/modules\/integrations\/([^/]+)\/\2\.config\.ts$/;
|
|
23
|
+
const SPEC_SUFFIX = '.integration-spec.ts';
|
|
24
|
+
const OUTAGE_CALLS = Object.freeze({ fake: 'failNext', apps: 'during' });
|
|
25
|
+
/** The outage verbs of `world.infra.<service>` (`connection` leads to one connection's cut/restore/during). */
|
|
26
|
+
const INFRA_OUTAGES = new Set(['cut', 'latency', 'during', 'connection']);
|
|
27
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
|
|
29
|
+
/** The TypeScript compiler of the app (else of the runtime), or null. */
|
|
30
|
+
function typescriptFor(repoRoot) {
|
|
31
|
+
const located = findPackage([repoRoot, HERE], ['typescript']);
|
|
32
|
+
return located ? requirePackage(located) : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The compiler options of the side (its tsconfig.json, `extends` and `paths` resolved by TypeScript itself). */
|
|
36
|
+
function optionsOf(ts, sideRoot) {
|
|
37
|
+
const file = path.join(sideRoot, 'tsconfig.json');
|
|
38
|
+
if (!fs.existsSync(file)) return {};
|
|
39
|
+
const read = ts.readConfigFile(file, ts.sys.readFile);
|
|
40
|
+
if (read.error) return {};
|
|
41
|
+
return ts.parseJsonConfigFileContent(read.config, ts.sys, sideRoot).options;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One parsed file: its syntax tree and its imports (local name -> { source, imported }). */
|
|
45
|
+
function parse(ts, repoRoot, rel) {
|
|
46
|
+
const text = readText(repoRoot, rel);
|
|
47
|
+
if (text === null) return null;
|
|
48
|
+
const sourceFile = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
49
|
+
const imports = new Map();
|
|
50
|
+
for (const statement of sourceFile.statements) {
|
|
51
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
|
52
|
+
const named = statement.importClause?.namedBindings;
|
|
53
|
+
if (!named || !ts.isNamedImports(named)) continue;
|
|
54
|
+
for (const element of named.elements) imports.set(element.name.text, { source: statement.moduleSpecifier.text, imported: (element.propertyName ?? element.name).text, node: element });
|
|
55
|
+
}
|
|
56
|
+
return { rel, sourceFile, imports };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Walks every node under `node`. */
|
|
60
|
+
function walk(ts, node, visit) {
|
|
61
|
+
visit(node);
|
|
62
|
+
ts.forEachChild(node, (child) => walk(ts, child, visit));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The names of a property chain `a.b.c` from its root identifier, or null when the chain does not start at an identifier. */
|
|
66
|
+
function chainOf(ts, expression) {
|
|
67
|
+
const names = [];
|
|
68
|
+
let current = expression;
|
|
69
|
+
while (ts.isPropertyAccessExpression(current)) {
|
|
70
|
+
names.unshift(current.name.text);
|
|
71
|
+
current = current.expression;
|
|
72
|
+
}
|
|
73
|
+
return ts.isIdentifier(current) ? [current.text, ...names] : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The judge of one app: resolves an import of a parsed file to a repository-relative path, finds the top-level const of a file,
|
|
78
|
+
* and reads files once.
|
|
79
|
+
*/
|
|
80
|
+
function judgeOf(ts, repoRoot, side) {
|
|
81
|
+
const options = optionsOf(ts, path.join(repoRoot, side));
|
|
82
|
+
const cache = new Map();
|
|
83
|
+
const parsed = (rel) => {
|
|
84
|
+
if (!cache.has(rel)) cache.set(rel, parse(ts, repoRoot, rel));
|
|
85
|
+
return cache.get(rel);
|
|
86
|
+
};
|
|
87
|
+
const resolve = (fromRel, source) => {
|
|
88
|
+
const resolved = ts.resolveModuleName(source, path.join(repoRoot, fromRel), options, ts.sys).resolvedModule?.resolvedFileName;
|
|
89
|
+
if (!resolved) return null;
|
|
90
|
+
const rel = path.relative(repoRoot, resolved).split(path.sep).join('/');
|
|
91
|
+
return rel.startsWith('..') ? null : rel;
|
|
92
|
+
};
|
|
93
|
+
/** The initializer of `const <name> = ...` at the top level of `file`. */
|
|
94
|
+
const constOf = (file, name) => {
|
|
95
|
+
for (const statement of file.sourceFile.statements) {
|
|
96
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
97
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
98
|
+
if (ts.isIdentifier(declaration.name) && declaration.name.text === name && declaration.initializer) return declaration.initializer;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
};
|
|
103
|
+
return { parsed, resolve, constOf };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether `rel` lies in the integration folder `folder` (its index or any file of it). */
|
|
107
|
+
const insideFolder = (rel, folder) => rel !== null && (rel === `${folder}/index.ts` || rel.startsWith(`${folder}/`));
|
|
108
|
+
|
|
109
|
+
/** The array elements `expression` evaluates to in `file`, each with the file it is written in (identifiers, spreads and imports followed). */
|
|
110
|
+
function elementsOf(ts, judge, file, expression, depth = 0) {
|
|
111
|
+
if (!expression || depth > 6) return [];
|
|
112
|
+
let node = expression;
|
|
113
|
+
while (ts.isAsExpression(node) || ts.isSatisfiesExpression?.(node) || ts.isParenthesizedExpression(node)) node = node.expression;
|
|
114
|
+
if (ts.isArrayLiteralExpression(node)) {
|
|
115
|
+
return node.elements.flatMap((element) => (ts.isSpreadElement(element) ? elementsOf(ts, judge, file, element.expression, depth + 1) : [{ file, node: element }]));
|
|
116
|
+
}
|
|
117
|
+
if (!ts.isIdentifier(node)) return [];
|
|
118
|
+
const local = judge.constOf(file, node.text);
|
|
119
|
+
if (local) return elementsOf(ts, judge, file, local, depth + 1);
|
|
120
|
+
const imported = file.imports.get(node.text);
|
|
121
|
+
if (!imported) return [];
|
|
122
|
+
const target = judge.resolve(file.rel, imported.source);
|
|
123
|
+
const other = target ? judge.parsed(target) : null;
|
|
124
|
+
return other ? elementsOf(ts, judge, other, judge.constOf(other, imported.imported), depth + 1) : [];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Whether a factory element calls `<X>.register(...)` with `<X>` imported (in the element's file) from the integration folder. */
|
|
128
|
+
function registersIntegration(ts, judge, element, folder) {
|
|
129
|
+
let registers = false;
|
|
130
|
+
walk(ts, element.node, (node) => {
|
|
131
|
+
if (registers || !ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return;
|
|
132
|
+
if (node.expression.name.text !== 'register' || !ts.isIdentifier(node.expression.expression)) return;
|
|
133
|
+
const binding = element.file.imports.get(node.expression.expression.text);
|
|
134
|
+
if (binding && insideFolder(judge.resolve(element.file.rel, binding.source), folder)) registers = true;
|
|
135
|
+
});
|
|
136
|
+
return registers;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Whether `name`, exported by the module `rel`, is declared as an enum there or in the file it is re-exported from. */
|
|
140
|
+
function exportsEnum(ts, judge, rel, name, depth = 0) {
|
|
141
|
+
const file = rel ? judge.parsed(rel) : null;
|
|
142
|
+
if (!file || depth > 4) return false;
|
|
143
|
+
for (const statement of file.sourceFile.statements) {
|
|
144
|
+
if (ts.isEnumDeclaration(statement) && statement.name.text === name) return true;
|
|
145
|
+
if (!ts.isExportDeclaration(statement) || !statement.exportClause || !ts.isNamedExports(statement.exportClause)) continue;
|
|
146
|
+
for (const element of statement.exportClause.elements) {
|
|
147
|
+
if (element.name.text !== name) continue;
|
|
148
|
+
const original = (element.propertyName ?? element.name).text;
|
|
149
|
+
if (statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) return exportsEnum(ts, judge, judge.resolve(rel, statement.moduleSpecifier.text), original, depth + 1);
|
|
150
|
+
return exportsEnum(ts, judge, rel, original, depth + 1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** What one spec proves about the integration in `folder`: { modules, errorCode, outage }. */
|
|
157
|
+
function judgeSpec(ts, judge, rel, folder) {
|
|
158
|
+
const spec = judge.parsed(rel);
|
|
159
|
+
const result = { modules: false, errorCode: false, outage: false };
|
|
160
|
+
if (!spec) return result;
|
|
161
|
+
const worlds = new Set();
|
|
162
|
+
walk(ts, spec.sourceFile, (node) => {
|
|
163
|
+
if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression) || node.expression.text !== 'useTestWorld' || !spec.imports.has('useTestWorld')) return;
|
|
164
|
+
const [argument] = node.arguments;
|
|
165
|
+
if (!argument || !ts.isObjectLiteralExpression(argument)) return;
|
|
166
|
+
const modules = argument.properties.find((property) => ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) && property.name.text === 'modules');
|
|
167
|
+
if (!modules) return;
|
|
168
|
+
if (ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name)) worlds.add(node.parent.name.text);
|
|
169
|
+
if (elementsOf(ts, judge, spec, modules.initializer).some((element) => registersIntegration(ts, judge, element, folder))) result.modules = true;
|
|
170
|
+
});
|
|
171
|
+
const enumBindings = new Set(
|
|
172
|
+
[...spec.imports].filter(([, binding]) => {
|
|
173
|
+
const target = judge.resolve(rel, binding.source);
|
|
174
|
+
return insideFolder(target, folder) && exportsEnum(ts, judge, target, binding.imported);
|
|
175
|
+
}).map(([local]) => local),
|
|
176
|
+
);
|
|
177
|
+
walk(ts, spec.sourceFile, (node) => {
|
|
178
|
+
if (ts.isIdentifier(node) && enumBindings.has(node.text) && !ts.isImportSpecifier(node.parent)) result.errorCode = true;
|
|
179
|
+
if (!ts.isPropertyAccessExpression(node)) return;
|
|
180
|
+
const chain = chainOf(ts, node);
|
|
181
|
+
if (!chain || !worlds.has(chain[0])) return;
|
|
182
|
+
const [, area, name, call] = chain;
|
|
183
|
+
const called = ts.isCallExpression(node.parent) && node.parent.expression === node;
|
|
184
|
+
if (area === 'infra' && name !== undefined && INFRA_OUTAGES.has(call) && called) result.outage = true;
|
|
185
|
+
else if (area === 'interruptDatabase' && called) result.outage = true;
|
|
186
|
+
else if (OUTAGE_CALLS[area] !== undefined && name !== undefined && call === OUTAGE_CALLS[area] && called) result.outage = true;
|
|
187
|
+
});
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const MISSING_PARTS = Object.freeze({
|
|
192
|
+
modules: 'call useTestWorld({ modules }) with a factory that registers the integration module (<Provider>Module.register) imported from the integration',
|
|
193
|
+
errorCode: "reference the integration's ErrorCode enum (its refusal mapping)",
|
|
194
|
+
outage: 'drive an outage through the world: world.infra.<service>, world.fake.<name>.failNext(...), world.apps.<peer>.during(...) or world.interruptDatabase(...)',
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
/** The findings of R112 over the tracked paths `files` of the app at `repoRoot`. */
|
|
198
|
+
export function integrationSpecFindings({ repoRoot, files }) {
|
|
199
|
+
const integrations = files.map((file) => CONFIG_FILE.exec(file)).filter(Boolean).map(([, side, provider]) => ({ side, provider, folder: `${side}src/modules/integrations/${provider}` }));
|
|
200
|
+
if (integrations.length === 0) return [];
|
|
201
|
+
const ts = typescriptFor(repoRoot);
|
|
202
|
+
const findings = [];
|
|
203
|
+
for (const { side, provider, folder } of integrations) {
|
|
204
|
+
const specDir = `${side}src/tests/integration/${provider}/`;
|
|
205
|
+
const specs = files.filter((file) => file.startsWith(specDir) && file.endsWith(SPEC_SUFFIX) && !file.slice(specDir.length).includes('/')).sort();
|
|
206
|
+
if (specs.length === 0) {
|
|
207
|
+
findings.push(found(INTEGRATION_SPEC_MISSING, `${folder}/`, `${folder}/ is an integration with no integration spec: add ${specDir}<name>${SPEC_SUFFIX} that ${MISSING_PARTS.modules}, ${MISSING_PARTS.errorCode}, and ${MISSING_PARTS.outage}.`, { provider, missing: ['spec'] }));
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (ts === null) {
|
|
211
|
+
findings.push(found(INTEGRATION_SPEC_MISSING, `${folder}/`, `${folder}/ cannot be judged: no TypeScript resolves from the app; install the app's dependencies.`, { provider, missing: ['typescript'] }));
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const judge = judgeOf(ts, repoRoot, side);
|
|
215
|
+
const verdicts = specs.map((spec) => ({ spec, ...judgeSpec(ts, judge, spec, folder) }));
|
|
216
|
+
if (verdicts.some((verdict) => verdict.modules && verdict.errorCode && verdict.outage)) continue;
|
|
217
|
+
const best = [...verdicts].sort((a, b) => Number(b.modules) + Number(b.errorCode) + Number(b.outage) - (Number(a.modules) + Number(a.errorCode) + Number(a.outage)))[0];
|
|
218
|
+
const missing = Object.keys(MISSING_PARTS).filter((part) => !best[part]);
|
|
219
|
+
findings.push(found(INTEGRATION_SPEC_MISSING, `${folder}/`, `${folder}/ has integration specs (${specs.join(', ')}), but none proves the integration: ${best.spec} must also ${missing.map((part) => MISSING_PARTS[part]).join('; and ')}.`, { provider, missing }));
|
|
220
|
+
}
|
|
221
|
+
return findings;
|
|
222
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Read-only: it walks the file system and asks git, nothing else.
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import { gitOutput } from '
|
|
7
|
+
import { gitOutput } from '../api/git/lib.mjs';
|
|
8
8
|
import { posixPath } from './path-key.mjs';
|
|
9
9
|
|
|
10
10
|
/** Directory names no tree check enters: git's own store and installed packages. */
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// scripts/lib/package-at.mjs — a package the runtime does not ship (playwright, esbuild, tailwindcss) comes from the
|
|
2
|
+
// project that owns it: node resolution from that project's directory, walking up its node_modules.
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
|
|
6
|
+
/** The package.json of `name` as resolved from `dir`, or null. */
|
|
7
|
+
export const packageAt = (dir, name) => {
|
|
8
|
+
try { return createRequire(path.join(dir, 'package.json')).resolve(`${name}/package.json`); } catch { return null; }
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** The first of `names` resolvable from the first of `dirs` that has one: {name, root, packageFile, version} or null. */
|
|
12
|
+
export function findPackage(dirs, names) {
|
|
13
|
+
for (const dir of dirs.filter(Boolean)) {
|
|
14
|
+
for (const name of names) {
|
|
15
|
+
const packageFile = packageAt(dir, name);
|
|
16
|
+
if (!packageFile) continue;
|
|
17
|
+
const require = createRequire(packageFile);
|
|
18
|
+
return { name, root: path.dirname(packageFile), packageFile, version: require(packageFile).version };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The CommonJS entry of a package found by findPackage. */
|
|
25
|
+
export const requirePackage = (found) => createRequire(found.packageFile)(found.name);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// they carry the same README title, the same stack-declaration project name and the same sibling repositories.
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { gitOutput } from '
|
|
6
|
+
import { gitOutput } from '../api/git/lib.mjs';
|
|
7
7
|
|
|
8
8
|
const git = (root, args) => gitOutput(args, { cwd: root }).trim();
|
|
9
9
|
const real = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
|
|
@@ -18,11 +18,11 @@
|
|
|
18
18
|
import fs from 'node:fs';
|
|
19
19
|
import os from 'node:os';
|
|
20
20
|
import path from 'node:path';
|
|
21
|
-
import { spawnSync } from 'node:child_process';
|
|
22
21
|
import { fileURLToPath } from 'node:url';
|
|
23
22
|
import { sleepSync } from './sleep-sync.mjs';
|
|
24
23
|
import { samePath } from './path-key.mjs';
|
|
25
|
-
import { gitSpawn } from '
|
|
24
|
+
import { gitSpawn } from '../api/git/lib.mjs';
|
|
25
|
+
import { rmdirLink } from '../api/fs/rmdir-link.mjs';
|
|
26
26
|
import { artifactHoldReason } from './artifact-hold.mjs';
|
|
27
27
|
import { realpathOr } from './fs-kind.mjs';
|
|
28
28
|
|
|
@@ -186,7 +186,7 @@ export function removeLink(p) {
|
|
|
186
186
|
if (WIN) {
|
|
187
187
|
let st = null;
|
|
188
188
|
try { st = fs.lstatSync(p); } catch { return true; }
|
|
189
|
-
if (st.isDirectory() || st.isSymbolicLink())
|
|
189
|
+
if (st.isDirectory() || st.isSymbolicLink()) rmdirLink(p);
|
|
190
190
|
}
|
|
191
191
|
return unlinkOnly(p);
|
|
192
192
|
}
|
|
@@ -212,6 +212,21 @@ export function mainCheckoutDamage(before, after) {
|
|
|
212
212
|
return out;
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
/**
|
|
216
|
+
* The link step of every worktree removal (git's here, Orca's in scripts/api/orca/worktree-remove.mjs removeOrcaWorktree): every link
|
|
217
|
+
* under `target` found WITHOUT following one (linksUnder), each removed as a link (removeLink: `cmd /c rmdir <link>`, never
|
|
218
|
+
* /s), outermost first, then a re-scan that must find ZERO. {ok, links, errors: [{path, code, message}]}; ok false: a link
|
|
219
|
+
* is stuck and the caller removes nothing.
|
|
220
|
+
*/
|
|
221
|
+
export function removeLinksUnder(target) {
|
|
222
|
+
const out = { ok: false, links: 0, errors: [] };
|
|
223
|
+
if (!fs.existsSync(target)) { out.ok = true; return out; }
|
|
224
|
+
for (const link of linksUnder(target)) { if (removeLink(link)) out.links += 1; else out.errors.push({ path: link, code: 'LINK_STUCK', message: 'a link could not be removed' }); }
|
|
225
|
+
for (const l of linksUnder(target)) if (!out.errors.some((e) => e.path === l)) out.errors.push({ path: l, code: 'LINK_STUCK', message: 'a link is still there after removal' });
|
|
226
|
+
out.ok = out.errors.length === 0;
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
215
230
|
/**
|
|
216
231
|
* Remove a git worktree (the one algorithm; the 490-file .claude incident and nivo-fe inc-c8fbf76aa499):
|
|
217
232
|
* 1. enumerate every link in it WITHOUT following one (linksUnder);
|
|
@@ -236,13 +251,9 @@ export function safeRemoveWorktree(worktree, { repo, git = null, retries = 5 } =
|
|
|
236
251
|
if (refused) { out.errors.push({ path: target, code: 'REFUSED', message: `refusing to remove ${refused}` }); return out; }
|
|
237
252
|
const before = mainRoot ? mainCheckoutGuard(mainRoot, { git: run }) : null;
|
|
238
253
|
if (fs.existsSync(target)) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (
|
|
242
|
-
for (const l of left) if (!out.errors.some((e) => e.path === l)) out.errors.push({ path: l, code: 'LINK_STUCK', message: 'a link is still there after removal' });
|
|
243
|
-
out.reason = 'link-stuck';
|
|
244
|
-
return out;
|
|
245
|
-
}
|
|
254
|
+
const unlinked = removeLinksUnder(target);
|
|
255
|
+
out.links = unlinked.links;
|
|
256
|
+
if (!unlinked.ok) { out.errors.push(...unlinked.errors); out.reason = 'link-stuck'; return out; }
|
|
246
257
|
out.removed.links = out.links;
|
|
247
258
|
const registered = trees.some((t) => same(t, target));
|
|
248
259
|
if (registered && repo) run(['worktree', 'remove', '--force', target], { cwd: repo });
|
package/sync/index.mjs
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// side (be, fe) for a side slot, whose files land under that side's folder. Two targets are not whole files of a managedBy slot
|
|
9
9
|
// and are listed in this module: the marked block of the root .gitignore, and .starciwork/.gitignore, which lives inside the
|
|
10
10
|
// .starciwork directory slot. Every file is rendered with the app's hfs.json (both sides and their apps) and, for the Sonar
|
|
11
|
-
// exclusions
|
|
11
|
+
// exclusions and the one coverage scope (sonar.coverage.inclusions and codecov.yml alike), the jest preset the app installs for
|
|
12
|
+
// its be side. `--check` compares the sha256 of the rendered content with the
|
|
12
13
|
// file on disk and fails on any drift; `--write` rewrites the drifted files. `.gitignore` is the one shared file: only the marked
|
|
13
14
|
// block is managed and the app's own lines around it are left alone. The root package.json is managed by its `scripts` block
|
|
14
15
|
// only (mode scripts, compared as parsed JSON): the rest of the file (dependencies, npm workspaces of fe/packages/*) is the app's.
|
|
@@ -98,7 +99,10 @@ export function render(text, vars, readTemplate = readBundled) {
|
|
|
98
99
|
});
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* What the app's sync reads from the jest preset it installs for its be side: the Sonar exclusions and the coverage sources
|
|
104
|
+
* (`COVERAGE_SOURCES`, the globs jest collects coverage from): { sonarExclusions, coverageSources }.
|
|
105
|
+
*/
|
|
102
106
|
export async function loadPresets(root) {
|
|
103
107
|
const name = '@starci/jest-preset';
|
|
104
108
|
const require = createRequire(path.join(root, 'package.json'));
|
|
@@ -109,7 +113,21 @@ export async function loadPresets(root) {
|
|
|
109
113
|
throw new SyncError('HFS_SYNC_PRESET_MISSING', `${name} is not installed under ${root}; set it to the exact version in knowledge/hfs/canon-pins.yaml and reinstall`);
|
|
110
114
|
}
|
|
111
115
|
const preset = require(resolved);
|
|
112
|
-
return { sonarExclusions: preset.sonarExclusions() };
|
|
116
|
+
return { sonarExclusions: preset.sonarExclusions(), coverageSources: [...preset.COVERAGE_SOURCES] };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Where the be unit run writes the lcov report (jest `coverageDirectory` coverage under be/, reporter lcov), from the app root. */
|
|
120
|
+
export const LCOV_REPORT = 'be/coverage/lcov.info';
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* THE coverage scope of an app, from the app root: the preset's coverage sources on the be side (`be/src/**` + `/*.service.ts`).
|
|
124
|
+
* It is the one source of sonar.coverage.inclusions and of the codecov.yml status paths, so the two can never drift; fe/ is
|
|
125
|
+
* outside it (a front end has no tests).
|
|
126
|
+
*/
|
|
127
|
+
export function coverageScope(presets) {
|
|
128
|
+
const sources = presets?.coverageSources;
|
|
129
|
+
if (!Array.isArray(sources) || !sources.length) throw new SyncError('HFS_SYNC_PRESET_MISSING', '@starci/jest-preset gives no COVERAGE_SOURCES: the coverage scope cannot be rendered');
|
|
130
|
+
return sources.map(glob => `be/${glob}`);
|
|
113
131
|
}
|
|
114
132
|
|
|
115
133
|
/**
|
|
@@ -156,6 +174,9 @@ export function variables(app, scope, presets, sonarKey) {
|
|
|
156
174
|
sonarKey: sonarKey ?? app.project,
|
|
157
175
|
sonarExclusions: [presets?.sonarExclusions, '**/.next/**', '**/node_modules/**', '**/src/messages/**'].filter(Boolean).join(','),
|
|
158
176
|
sonarSources: ['be/apps', 'be/src', 'fe/apps', ...(packages ? ['fe/packages'] : [])].join(','),
|
|
177
|
+
lcovReport: LCOV_REPORT,
|
|
178
|
+
coverageInclusions: scope === APP_SCOPE ? coverageScope(presets).join(',') : '',
|
|
179
|
+
codecovPaths: scope === APP_SCOPE ? coverageScope(presets).map(glob => ` - ${JSON.stringify(glob)}`).join('\n') : '',
|
|
159
180
|
tsconfigPaths: ['be/tsconfig.json', ...feTsconfigs].join(','),
|
|
160
181
|
styleGlob: STYLE_GLOB,
|
|
161
182
|
};
|
package/sync/managed.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// `prettier`, `lint-staged`, `jest`). Forbidden tool-config files (.eslintrc*, .eslintignore, a second
|
|
21
21
|
// eslint.config.*) are refused by hfs-check through the slot manifest under the same code.
|
|
22
22
|
// HFS_SONAR_CONFIG (R11) sonar-project.properties differs from its render (the render names no host URL, sources and tests that
|
|
23
|
-
// do not overlap, the `sonar.exclusions` of the jest preset (a back end),
|
|
23
|
+
// do not overlap, the `sonar.exclusions` of the jest preset (a back end), the be lcov with the services as the coverage scope, the ESLint report and the
|
|
24
24
|
// HFS import files Sonar reads), or the stack declaration names a quality gate other than the one
|
|
25
25
|
// gate of knowledge/sonar-gate.yaml (bundled in the runtime copy). One edit is one finding.
|
|
26
26
|
// HFS_TS_STRICT (R22) the root tsconfig.json drift (either profile), named by flag (ts-strict.mjs) instead of by hash.
|
|
@@ -14,6 +14,7 @@ jobs:
|
|
|
14
14
|
runs-on: ubuntu-latest
|
|
15
15
|
env:
|
|
16
16
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
|
17
|
+
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
|
17
18
|
steps:
|
|
18
19
|
- uses: actions/checkout@v4
|
|
19
20
|
- uses: actions/setup-node@v4
|
|
@@ -29,6 +30,14 @@ jobs:
|
|
|
29
30
|
run: npm run typecheck
|
|
30
31
|
- name: unit
|
|
31
32
|
run: npm test -- --ci
|
|
33
|
+
- name: coverage upload
|
|
34
|
+
uses: codecov/codecov-action@v5
|
|
35
|
+
if: ${{ !cancelled() && env.CODECOV_TOKEN != '' }}
|
|
36
|
+
with:
|
|
37
|
+
token: ${{ env.CODECOV_TOKEN }}
|
|
38
|
+
files: {{lcovReport}}
|
|
39
|
+
disable_search: true
|
|
40
|
+
fail_ci_if_error: true
|
|
32
41
|
- name: build be
|
|
33
42
|
run: npm run build:be
|
|
34
43
|
- name: build fe
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{{header}}
|
|
2
|
+
# The be unit run's lcov ({{lcovReport}}), uploaded by the managed CI workflow. Coverage is the services' alone: the status
|
|
3
|
+
# paths are the one coverage scope hfs sync renders into sonar.coverage.inclusions too, held at 100 on the project and on
|
|
4
|
+
# the patch. fe/ is outside coverage (a front end has no tests).
|
|
5
|
+
codecov:
|
|
6
|
+
require_ci_to_pass: true
|
|
7
|
+
coverage:
|
|
8
|
+
status:
|
|
9
|
+
project:
|
|
10
|
+
default:
|
|
11
|
+
target: 100%
|
|
12
|
+
threshold: 0%
|
|
13
|
+
paths:
|
|
14
|
+
{{codecovPaths}}
|
|
15
|
+
patch:
|
|
16
|
+
default:
|
|
17
|
+
target: 100%
|
|
18
|
+
threshold: 0%
|
|
19
|
+
paths:
|
|
20
|
+
{{codecovPaths}}
|
|
21
|
+
ignore:
|
|
22
|
+
- "fe/**"
|
|
@@ -7,4 +7,6 @@ sonar.exclusions={{sonarExclusions}}
|
|
|
7
7
|
sonar.test.inclusions=**/*.spec.ts
|
|
8
8
|
sonar.typescript.tsconfigPaths={{tsconfigPaths}}
|
|
9
9
|
sonar.externalIssuesReportPaths=reports/lint.sonar.json
|
|
10
|
+
sonar.javascript.lcov.reportPaths={{lcovReport}}
|
|
11
|
+
sonar.coverage.inclusions={{coverageInclusions}}
|
|
10
12
|
sonar.nodejs.maxspace=8192
|