@starci/hfs 4.12.2 → 4.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +8 -9
- package/cache/app-families.d.mts +30 -0
- package/cache/app-families.mjs +94 -0
- package/cache/content-index.d.mts +13 -0
- package/cache/content-index.mjs +37 -0
- package/cache/db-family.d.mts +13 -0
- package/cache/db-family.mjs +37 -0
- package/cache/fake-session.d.mts +42 -0
- package/cache/fake-session.mjs +57 -0
- package/cache/fingerprint.d.mts +28 -0
- package/cache/fingerprint.mjs +120 -0
- package/cache/format-family.d.mts +13 -0
- package/cache/format-family.mjs +49 -0
- package/cache/git.d.mts +11 -0
- package/cache/git.mjs +13 -0
- package/cache/hash.d.mts +15 -0
- package/cache/hash.mjs +58 -0
- package/cache/hfs-family.d.mts +35 -0
- package/cache/hfs-family.mjs +164 -0
- package/cache/import-graph.d.mts +75 -0
- package/cache/import-graph.mjs +204 -0
- package/cache/jsonl.d.mts +31 -0
- package/cache/jsonl.mjs +115 -0
- package/cache/lint-family.d.mts +162 -0
- package/cache/lint-family.mjs +433 -0
- package/cache/order.d.mts +1 -0
- package/cache/order.mjs +2 -0
- package/cache/progress.d.mts +19 -0
- package/cache/progress.mjs +68 -0
- package/cache/quality-baselines.d.mts +109 -0
- package/cache/quality-baselines.mjs +235 -0
- package/cache/records.d.mts +33 -0
- package/cache/records.mjs +54 -0
- package/cache/report.d.mts +19 -0
- package/cache/report.mjs +32 -0
- package/cache/run-log.d.mts +17 -0
- package/cache/run-log.mjs +37 -0
- package/cache/run.d.mts +33 -0
- package/cache/run.mjs +65 -0
- package/cache/session.d.mts +29 -0
- package/cache/session.mjs +197 -0
- package/cache/side-key.d.mts +10 -0
- package/cache/side-key.mjs +47 -0
- package/cache/test-family.d.mts +130 -0
- package/cache/test-family.mjs +258 -0
- package/cache/trust.d.mts +53 -0
- package/cache/trust.mjs +157 -0
- package/cache/types-family.d.mts +74 -0
- package/cache/types-family.mjs +154 -0
- package/cache/types.d.mts +99 -0
- package/cache/types.mjs +8 -0
- package/cache/wiring-family.d.mts +14 -0
- package/cache/wiring-family.mjs +43 -0
- package/coverage/cli.d.mts +4 -2
- package/coverage/cli.mjs +44 -5
- package/db/migrate.d.mts +16 -0
- package/db/migrate.mjs +21 -4
- package/db/postgres.d.mts +23 -0
- package/db/postgres.mjs +32 -1
- package/lint/run.d.mts +47 -1
- package/lint/run.mjs +157 -5
- package/package.json +2 -1
- package/release/gate.d.mts +2 -0
- package/release/gate.mjs +4 -2
- package/release/records.d.mts +15 -0
- package/release/records.mjs +25 -0
- package/release/urgent.mjs +23 -4
- package/runtime/knowledge/grammars/core/playbook.yaml +12 -18
- package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
- package/runtime/knowledge/hfs/ci-standard.yaml +29 -6
- package/runtime/knowledge/hfs/rules.yaml +74 -7
- package/runtime/knowledge/hfs/slots.yaml +19 -7
- package/runtime/modules/kernel/failure-codes.yaml +46 -4
- package/runtime/modules/schemas/application-stacks.schema.yaml +26 -0
- package/runtime/scripts/hfs/architecture/hfs.d.mts +6 -6
- package/runtime/scripts/hfs/check.mjs +1 -1
- package/runtime/scripts/hfs/gate-threshold.mjs +2 -1
- package/runtime/scripts/hfs/rules/boot-proof.d.mts +5 -0
- package/runtime/scripts/hfs/rules/boot-proof.mjs +15 -1
- package/runtime/scripts/hfs/rules/ci-custody.mjs +3 -1
- package/runtime/scripts/hfs/rules/ci-standard.mjs +32 -3
- package/runtime/scripts/hfs/rules/database-migrations.d.mts +14 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +55 -0
- package/runtime/scripts/hfs/rules/database.mjs +2 -1
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +53 -6
- package/runtime/scripts/hfs/rules/release-provenance.mjs +32 -4
- package/runtime/scripts/hfs/rules/stacks.mjs +5 -1
- package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -1
- package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +98 -7
- package/runtime/scripts/hfs/rules/unused-symbol.d.mts +9 -2
- package/runtime/scripts/hfs/rules/unused-symbol.mjs +15 -2
- package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +21 -5
- package/runtime/scripts/hfs/stack-deploy-config.d.mts +6 -0
- package/runtime/scripts/hfs/stack-deploy-config.mjs +32 -1
- package/runtime/scripts/hfs/stacks-gate.mjs +2 -0
- package/runtime/scripts/hfs/tree-findings.mjs +3 -1
- package/scaffold/add-inbox-lite.mjs +2 -4
- package/scaffold/add-queue-lite.mjs +2 -3
- package/scaffold/add-table.mjs +2 -4
- package/scaffold/migration-stamp.d.mts +11 -0
- package/scaffold/migration-stamp.mjs +32 -0
- package/src/helpers/hfs-arch-fixture.d.mts +1 -1
- package/src/main.d.mts +4 -1
- package/src/main.mjs +189 -74
- package/src/stack-records-double.d.mts +31 -0
- package/stack/cli.d.mts +3 -1
- package/stack/cli.mjs +17 -11
- package/stack/config.d.mts +6 -0
- package/stack/converge.d.mts +2 -0
- package/stack/converge.mjs +1 -1
- package/stack/db-phases.d.mts +185 -0
- package/stack/db-phases.mjs +346 -0
- package/stack/deploy.d.mts +18 -4
- package/stack/deploy.mjs +235 -70
- package/stack/journal.d.mts +127 -0
- package/stack/journal.mjs +182 -0
- package/stack/manager-backup.d.mts +54 -0
- package/stack/manager-backup.mjs +136 -0
- package/stack/release-store.d.mts +44 -0
- package/stack/release-store.mjs +89 -6
- package/stack/remote.d.mts +2 -0
- package/stack/remote.mjs +31 -11
- package/stack/server-key.d.mts +2 -0
- package/stack/server-key.mjs +1 -1
- package/stack/status.d.mts +49 -0
- package/stack/status.mjs +114 -0
- package/stack/urgent-floor.d.mts +10 -61
- package/stack/urgent-floor.mjs +28 -123
- package/stack/verify.mjs +26 -0
- package/sync/ci-commands.mjs +10 -5
- package/sync/format-worker.d.mts +1 -0
- package/sync/format-worker.mjs +17 -0
- package/sync/format.d.mts +21 -3
- package/sync/format.mjs +99 -7
- package/sync/index.d.mts +3 -6
- package/sync/index.mjs +12 -12
- package/sync/stack-images.d.mts +25 -10
- package/sync/stack-images.mjs +215 -54
- package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
- package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
- package/templates/app/gitignore +1 -0
- package/templates/app/gitignore-lite +1 -0
- package/templates/app/package-scripts/db.json +1 -1
- package/templates/app/skeleton/.gitattributes +2 -0
- package/templates/app/skeleton-lite/.gitattributes +2 -0
- package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +0 -63
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.13.0 - 2026-10-31
|
|
4
|
+
|
|
5
|
+
The delivery pipeline of an app (contexts/project/cicd.md, ruling app-cicd-auto-deploy; owner approval 2026-10-31: every green release tag deploys by itself, STARCI_DEPLOY_PAUSED is the brake, agents deploy, the CI deploy key is sealed, pull requests get CI, contract migrations run after the accept, Sonar judges every full run, images are cached in GitHub Actions and built for amd64 unless declared).
|
|
6
|
+
|
|
7
|
+
- Added: `starci app stack status --env <env>` (read-only) compares the live services of the stack with the accepted release and reports drift per service (`rolled back by swarm`, `updated outside a release`, `missing`, `not in the release`); `stack verify` fails on the same drift when the stack file pins its images by digest.
|
|
8
|
+
- Added: every executing `stack deploy`, `rollback` and `accept` is one recorded attempt: it takes `/srv/<stack>/deploy.lock` on the manager (shared with the urgent deploy, a bounded wait, then `STACK_DEPLOY_LOCKED`), writes `deploys/<attempt>.json` at every phase boundary plus the append-only `deploys.jsonl`, and snapshots the live services after converge, after a rollback and on accept (`release.json` gains `observed`).
|
|
9
|
+
- Added: `stack deploy --ci-run <id>` marks the attempt as a CI one and `--redeploy` deploys although the accepted release already runs the commit; without it a deploy of the commit the accepted release already runs, with matching services, ends green as `already-current`.
|
|
10
|
+
- Changed: preflight pulls the images of the rollback target too and refuses (`STACK_DEPLOY_ROLLBACK_PULL`) before the services are touched when one no longer pulls.
|
|
11
|
+
|
|
12
|
+
- Added: every deploy of an app that declares the `database:` block of application-stacks.yaml runs the database phases of `stack/db-phases.mts`. Before the stack deploy: `db-preview` (migrate preview of every pending migration; red refuses `STACK_DEPLOY_MIGRATE_PREVIEW_RED`), `backup` only when something is pending (a fresh dump made on the manager from the database container, age-encrypted to the `.sops.yaml` recipients and the server recipient, kept in `<deploy.backup.dir | /var/backups/<stack>>/db`, receipt written beside it and judged against a second reading of the archive, test decrypt on the manager with its own identity; codes `STACK_DEPLOY_BACKUP_FAILED`, `_UNVERIFIED`, `_DECRYPT_FAILED`; nothing pending means no backup and `data.migrations.backup` is null) and `migrate` (the expand migrations: every migration but `*_contract_*`; `STACK_DEPLOY_MIGRATE_FAILED`). Any refusal leaves the services untouched. After accept (in `stack deploy` and in `stack accept`): the `contract` pass applies the `*_contract_*` migrations; a red contract leaves the release accepted and the job red (`data.contractFailed`). A release that only carries a contract migration still gets its backup first. With no accepted release the deploy chooses the first start by itself (hold deploy, proof the database is empty, every migration inside the container, then the real deploy; `STACK_FIRST_START_NOT_EMPTY` refuses a database that holds data). `data.migrations` records pending files, backup receipt, applied versions and contract versions.
|
|
13
|
+
- Added: `database.target` (`stack`, default, or `external`) and `database.service` (default `db`) in the `database:` block. A `stack` database is reached inside its own container with the credentials that container holds (`ownContainerTarget`: role and database from the container, password read inside it from `POSTGRES_PASSWORD_FILE`), so a deploy holds no `DATABASE_URL` and no password; `stack deploy --db-url-env` is refused for it (`STACK_DEPLOY_DB_URL_UNEXPECTED`). An `external` database keeps `--db-url-env` (also on `stack accept`) and dumps where the deploy runs (`--backup-out`).
|
|
14
|
+
- Added: `db migrate` passes (`phase: all | expand | contract`) and `pushFiles` in its result data; `isContractMigration`.
|
|
15
|
+
- Added: `DB_MIGRATION_DESTRUCTIVE` (R259) also judges a contract migration against the previous release: a contract migration that is new since the previous release tag (the newest `v*` tag HEAD contains, not on HEAD itself) and destroys an object that the be/src sources of that tag still reference is a hard blocker, because the deploy applies it right after the accept while that release is the rollback target. The code change goes in one release and its contract migration in the next. The check is git-based: no release tag, or an unreadable history, means no previous release and no finding.
|
|
16
|
+
- Changed: the urgent floor shares these steps and their codes. `STACK_URGENT_BACKUP_FAILED`, `_BACKUP_UNVERIFIED`, `_DECRYPT_UNAVAILABLE`, `_DECRYPT_FAILED`, `_MIGRATE_PREVIEW_RED` and `_MIGRATE_FAILED` are now `STACK_DEPLOY_BACKUP_FAILED`, `STACK_DEPLOY_BACKUP_UNVERIFIED`, `STACK_DEPLOY_BACKUP_DECRYPT_UNAVAILABLE`, `STACK_DEPLOY_BACKUP_DECRYPT_FAILED`, `STACK_DEPLOY_MIGRATE_PREVIEW_RED` and `STACK_DEPLOY_MIGRATE_FAILED`. The urgent path keeps its operator-held `--db-url-env` and its always-on backup. `stack deploy` accepts `--db-url-env` and `--backup-out` without `--urgent` (for `database.target: external`). An app without a `database:` block deploys exactly as before.
|
|
17
|
+
- Added: the urgent tag annotation carries the trailers `Starci-Urgent: <role>` and `Starci-Urgent-Reason: <text>` (`urgentTrailerBlock`, `parseUrgentTrailers` in release/records.mts; `release push --urgent` writes them), and the urgent record gains `trailers`. `stack deploy --ci-run <id>` is judged by the CLI (the jobs the CI standard requires, read from GitHub for the exact `--sha`); hfs takes the id only as the journal label of the attempt, after the CLI has accepted the run.
|
|
18
|
+
- Added: the managed workflows render the pipeline. `ci.yml` runs on `pull_request` to main as well (read-only, no environment, no secret), and holds `plan` (full, defer or reuse from the event, the ref and a green candidate run of the same commit), the checks, `images` (the reusable images.yml, built once beside the checks), `verdict` (needs every job, runs always, the single required check) and one `deploy-<env>` call of the reusable stack-deploy.yml per environment with `deploy.auto`, in declared order, after the verdict. Sonar and the Codecov upload run on every full run (a release tag or a candidate), not on tags only.
|
|
19
|
+
- Added: `images.yml` is a reusable workflow (`workflow_call`: `push`, `release`; output `refs`). It builds each declared app with buildx, the GitHub Actions cache (or `ci.imageCache: registry`) and an SBOM plus provenance (`mode=max`), pushes the deployed ones to GHCR under the immutable `sha-<sha>`, adds `v<version>` on a release run by retagging that digest (no rebuild), and collects the pushed digests as `{service: repository@sha256:...}`. An image whose public build values live in a sealed member is built by a second job bound to the release environment on a release tag run only; every other image builds with no environment and no secret. Platforms default to `linux/amd64` (`deploy.platforms`; `linux/arm64` adds QEMU).
|
|
20
|
+
- Added: `stack-deploy.yml` is reusable and still dispatchable (`deploy`, `redeploy`, `rollback`). The automatic deploy reads the variable `STARCI_DEPLOY_PAUSED` of its GitHub Environment first and stops without a code change when it is `true` (a manual run ignores it), resolves each image to a digest (the calling run's, else the registry's `sha-<sha>` tag), finds the green CI run of the commit and calls `stack deploy --sha <sha> --ci-run <run>`. Its only secret is the environment's `SOPS_AGE_KEY`.
|
|
21
|
+
- Added: the CI transport is the sealed member `vps-ci` of the environment (`VPS_SSH_KEY`, `VPS_KNOWN_HOSTS`, `VPS_HOST`, `VPS_USER`), opened with the server identity: `stack connect` prefers it over the job's `VPS_*` variables (which still work when the member is absent), pins the host keys with `StrictHostKeyChecking yes` and masks the host, the account and `deploy.remote.releasesBase` in the log of a public repository first.
|
|
22
|
+
- Added: stacks schema keys `environments.<env>.deploy.auto` (default false), `deploy.approval` (`none` | `reviewers`) and `deploy.platforms` (default `[linux/amd64]`), and `ci.imageCache` (`gha` | `registry`).
|
|
23
|
+
- Changed: R221 admits `workflow_call` and, in ci.yml only, `pull_request` to main (`branches: ['main']` and nothing else); `pull_request_target`, `workflow_run` and schedules stay refused. R251 judges a deployment caller (a job of ci.yml that calls the reusable stack-deploy.yml: permissions, concurrency and `with.environment`, no environment of its own), a workflow that runs on `pull_request` (read-only permissions, no job bound to an environment), recognises `starci app stack deploy|rollback|accept` as deployment commands, and judges the secrets allowlist and the dotenv ban on the deployment jobs rather than on the check jobs beside them (the Sonar token of ci.yml). R271 accepts the immutable `sha-${{ github.sha }}` tag from any ref and a release retag (`imagetools create`) only after a step under `if: ${{ inputs.release }}` that rejects a ref other than `refs/tags/v*`. R269 counts a job that the plan (`needs.plan.outputs.<x> == 'true'`) or a boolean input decides as enabled. R296 to R298 treat `vars.STARCI_DEPLOY_PAUSED` as a managed variable no app declares. The CI standard (R301) lists the jobs `plan`, `images` and `verdict` as required, the `deploy-<env>` calls after the verdict, and four new items (`pull-request-ci`, `images-once`, `deploy-brake`, the candidate trigger with pull requests).
|
|
24
|
+
- Changed: R259 (`DB_MIGRATION_DESTRUCTIVE`) states the previous-release rule of the contract migration; `HFS_DB_DEPLOY_UNGUARDED` (R271) still judges a full app's own `db-deploy.yml` and a lite app that has not synced yet, with the pointer to the stack deploy.
|
|
25
|
+
- Removed: the lite `db-deploy.yml` workflow. A lite app migrates in the migrate phase of its stack deploy (a verified backup, the expand migrations before the services, the contract migrations after the accept); `starci app sync --execute` deletes the file and the slot `app.ci-db` is forbidden in lite. The old tarball `images.yml` (never pushed) and the build-and-push step list of `stack-deploy.yml` are gone: the deployed digest is now the attested one the checks ran beside.
|
|
26
|
+
|
|
27
|
+
- Added: the committed app check cache (ruling app-check-cache, contexts/project/check-cache.md). `starci app check` runs every family diff-only against `.starcistacks/cache/` and writes what it judged back; `--full` runs the selected families in full; the selectors `--hfs --format --db --secrets --wiring --types --lint --test --sonar` run one family each and combine. Each family prints one diagnosis line (full or diff and why, checked, reused, findings new, known, fixed); progress goes to stderr with a percentage (whole lines off a terminal, `--quiet` silences it); timings go to the untracked tool cache. `STARCI_APP_CHECK_CACHE=verify` runs diff then full and fails with exit 2 on any difference; `=full` never reads the cache (the release gate sets it on every gate row). New `cache/` modules: types, hashing (CRLF to LF), JSON Lines, fingerprint, content index, trust (host: the working tree; CI: the cache at the merge-base or first parent), session, progress, run log, the hfs, secrets, db, wiring and format families, an in-memory fake session.
|
|
28
|
+
- Added: slot `app.starcistacks.cache` (slots 3.15.0, both editions); rule R343 with HFS_CHECK_CACHE_FORGED (hard blocker at any count), HFS_CHECK_CACHE_STALE and HFS_CHECK_CACHE_MISSING (warnings); the managed `.gitignore` re-includes the folder, `.gitattributes` marks it `merge=union linguist-generated`, the managed CI runs `app check --format` and sets the cache env to full on release candidates and tags, `db:lint` runs `app check --db`.
|
|
29
|
+
- Changed: Prettier (HFS_FORMAT) runs in batches of 64 files spread over worker threads that load the repository's prettier once, with configuration resolved once per directory (it was one lookup per file).
|
|
30
|
+
- Removed: `app check --fast`, `--base` and `--force`; `--wiring` is now the wiring family.
|
|
31
|
+
|
|
32
|
+
- Changed: the bundled canon pins name the 0.16.3 cohort (@starci/grammar, eslint-grammar-core and eslint-grammar-offset-pop 0.16.3, eslint-canon-fe 9.6.3, eslint-canon-be 3.9.3, stylelint-canon 2.3.0, jest-preset 2.6.0, hfs 4.13.0); ChatWorkspace and PaneLayout are removed with no alias; PaneGrid and Pane are the one generic pane grid: grammar names are generic. R341 FE_FREESTYLE_REASON_MISSING (eslint-grammar-core `freestyle-has-reason`) joins the bundled rule catalog.
|
|
33
|
+
- Fixed: two migrations written within one second (an app scaffold followed at once by `add table`, `add webhook`, `add queue`) took the same UTC timestamp and the second was refused with HFS_ADD_MIGRATION_ORDER, which failed the CI package job on a fast runner; the stamp now moves to the second after the latest migration when the latest is at most 5 s ahead of the clock (scaffold/migration-stamp), and a clock that is really behind is still refused. Specs: src/migration-stamp.spec.mts.
|
|
34
|
+
- Added: R342 `BE_ENTITY_EXTENDS_BASE` (eslint-canon-be `entity-extends-base`) joins the bundled rule catalog and the failure codes.
|
|
35
|
+
- Fixed: HFS_UNUSED_SYMBOL (R280) no longer reports a catalog key read through a translator helper. A hook that exposes its bound translators as members of a declared object type (`useMemo<OfficeWords>(() => ({ t: (key, values) => t(key, values) }))`) binds the app's own type alias of that member (`OfficeText`), or the member itself, to the translator's namespaces; a value of that type called with a key (`text("composerLabel")`, `props.text(...)`, `view.text(...)`, handed on through props, spreads and render callbacks) reads it in those namespaces. The binding is additive: a literal handed to a callback of another type, or a bare literal, still reads nothing.
|
|
36
|
+
- Fixed: the be skeleton's canon copy `platform/config/env-source.config.ts` is Prettier-formatted with @starci/prettier-config (the `optionalDomain` guard is wrapped), so an app that restores the canon bytes passes its own pre-commit format; HFS_CANON_COPY_DRIFT and HFS_FORMAT no longer contradict each other. A spec keeps every canon-copy template Prettier-stable.
|
|
37
|
+
|
|
3
38
|
## 4.12.2 - 2026-10-31
|
|
4
39
|
|
|
5
40
|
- Changed: the bundled canon pins name @starci/grammar 0.16.2 (the horizontal Stepper connector line; SurfaceListCard takes its depth from the surface context). R340 FE_RAISED_SURFACE_IN_SURFACE joins the bundled rule catalog.
|
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ starci app add spec <file>.service.ts [--cwd <dir>]
|
|
|
18
18
|
starci app add image [--cwd <dir>]
|
|
19
19
|
starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--fix] [--json] [--out <sonar file>]
|
|
20
20
|
starci app sync [--execute] [--cwd <dir>]
|
|
21
|
-
starci app check [--cwd <dir>] [--json] [--
|
|
21
|
+
starci app check [--cwd <dir>] [--json] [--full] [--hfs] [--format] [--db] [--secrets] [--wiring] [--types] [--lint] [--test] [--sonar] [--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`,
|
|
196
|
+
| `HFS_FORMAT` | error | a tracked file the repository's own prettier would change (R19; `sync/format.mjs`, run in batches by `--format`) |
|
|
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
|
|
@@ -208,14 +208,13 @@ the source-shape, contract-form and front-end rules, and the repository-tree rul
|
|
|
208
208
|
finding under the machine's own rule id (`BE_TIER_DIRECTION`, `HFS_UNUSED_EXPORT`, `ARCH_OWNER_EXPORT_BYPASS`, ...). A
|
|
209
209
|
repository without `typescript` installed fails with `ARCH_TYPESCRIPT_MISSING`; the check never passes because it could not run.
|
|
210
210
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
211
|
+
With no selector `starci app check` runs every family diff-only against the committed cache `.starcistacks/cache/` (contexts/project/check-cache.md) and
|
|
212
|
+
writes what it judged back; the result equals a full run's. `--full` runs the selected families in full. A selector (`--hfs`, `--format`, `--db`, `--secrets`,
|
|
213
|
+
`--wiring`, `--types`, `--lint`, `--test`, `--sonar`) runs one family; selectors combine. Each family prints `<family>: <full|diff> (<reason>), checked n, reused n;
|
|
214
|
+
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
|
+
`STARCI_APP_CHECK_CACHE=full` never reads the cache (the release gate).
|
|
216
216
|
|
|
217
|
-
Exit codes: 0 clean, 1 an error finding, 2 a tool could not run or a refusal (not a Git work tree, bad flag
|
|
218
|
-
(`starci app lint --fix` lets ESLint and stylelint fix in place).
|
|
217
|
+
Exit codes: 0 clean, 1 an error finding, 2 a tool could not run or a refusal (not a Git work tree, bad flag). `starci app check` and `starci app lint` write only `.starcistacks/cache/` (and `starci app lint --fix` lets ESLint and stylelint fix in place).
|
|
219
218
|
|
|
220
219
|
## Secrets
|
|
221
220
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type CheckFinding } from '../runtime/scripts/hfs/check.mjs';
|
|
2
|
+
import { type TestFamilyResult } from './test-family.mjs';
|
|
3
|
+
import { type TypesFamilyResult, type TypesProgram } from './types-family.mjs';
|
|
4
|
+
import { type QualityFamilyResult } from './quality-baselines.mjs';
|
|
5
|
+
import type { CheckSession } from './types.mjs';
|
|
6
|
+
/** The tsc programs of the app: the back end and each front-end workspace, from the tracked tsconfigs. */
|
|
7
|
+
export declare function programsOf(tracked: readonly string[]): TypesProgram[];
|
|
8
|
+
/** `--types`: `tsc --noEmit` per workspace program through the app's TypeScript, reused by program key. */
|
|
9
|
+
export declare function runTypesForApp(session: CheckSession, { repoRoot, tracked, env }: {
|
|
10
|
+
repoRoot: string;
|
|
11
|
+
tracked: readonly string[];
|
|
12
|
+
env: NodeJS.ProcessEnv;
|
|
13
|
+
}): {
|
|
14
|
+
result: TypesFamilyResult;
|
|
15
|
+
findings: CheckFinding[];
|
|
16
|
+
};
|
|
17
|
+
/** `--test`: the unit specs of the back end through the app's own Jest, reused by closure key; the merged total is judged by `@starci/hfs` (the coverage proof). */
|
|
18
|
+
export declare function runTestForApp(session: CheckSession, { repoRoot, tracked }: {
|
|
19
|
+
repoRoot: string;
|
|
20
|
+
tracked: readonly string[];
|
|
21
|
+
}): Promise<{
|
|
22
|
+
result: TestFamilyResult | null;
|
|
23
|
+
findings: CheckFinding[];
|
|
24
|
+
}>;
|
|
25
|
+
/** `--test --full --shard i/n`: one shard of the full unit run in the shape the shard merge reads; nothing is recorded. */
|
|
26
|
+
export declare function runTestShard(repoRoot: string, index: number, total: number): number;
|
|
27
|
+
/** `--sonar`: the Sonar and Codecov baselines and the predictions from the merged coverage records; they never pass or fail a run. */
|
|
28
|
+
export declare function runSonarForApp(session: CheckSession, { repoRoot }: {
|
|
29
|
+
repoRoot: string;
|
|
30
|
+
}): QualityFamilyResult | null;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// app-families.mts - the adapters that run the heavy families (`--types`, `--test`, `--sonar`) on a real app: its tsconfigs for the programs, its own Jest and
|
|
2
|
+
// jest-preset for the specs, its owners' files for the baselines. The families themselves (types-family, test-family, quality-baselines) take their tools as seams;
|
|
3
|
+
// this module is where the real ones are chosen. The integration, e2e and contract specs are not run here: they need a world (`npm run test:integration`, ...) and
|
|
4
|
+
// always run in full wherever they run, so no cache key could hold them.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { skillRoot } from '../runtime/engine/runtime-root.mjs';
|
|
10
|
+
import { coverageFrom } from '../runtime/scripts/hfs/coverage-from.mjs';
|
|
11
|
+
import { readWhy } from '../runtime/scripts/hfs/check.mjs';
|
|
12
|
+
import { withWhy } from '../runtime/scripts/hfs/check-results.mjs';
|
|
13
|
+
import { git as runGitText } from './git.mjs';
|
|
14
|
+
import { toolCacheDir } from './run-log.mjs';
|
|
15
|
+
import { untrackedProgramInputs } from './side-key.mjs';
|
|
16
|
+
import { createJestEnvironment, installedSpecMerge, runTestFamily, shardArgs } from './test-family.mjs';
|
|
17
|
+
import { createTscRunner, runTypesFamily } from './types-family.mjs';
|
|
18
|
+
import { changedLinesFromDiff, gitRunner, lineCoverageOf, recordsAtCommit, runQualityFamily } from './quality-baselines.mjs';
|
|
19
|
+
const asFinding = (found, code, level, engine) => ({ code, level, engine, path: found.path, line: found.line || undefined, column: found.column || undefined, message: `${found.path}${found.line ? `:${found.line}` : ''}: ${found.message}` });
|
|
20
|
+
/** The tsc programs of the app: the back end and each front-end workspace, from the tracked tsconfigs. */
|
|
21
|
+
export function programsOf(tracked) {
|
|
22
|
+
const programs = [];
|
|
23
|
+
for (const file of tracked) {
|
|
24
|
+
if (file === 'be/tsconfig.json')
|
|
25
|
+
programs.push({ name: 'be', tsconfig: file, reads: ['be'] });
|
|
26
|
+
const match = /^(fe\/(?:apps|packages)\/[^/]+)\/tsconfig\.json$/.exec(file);
|
|
27
|
+
if (match?.[1] !== undefined)
|
|
28
|
+
programs.push({ name: match[1], tsconfig: file, reads: [match[1]] });
|
|
29
|
+
}
|
|
30
|
+
return programs.sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
31
|
+
}
|
|
32
|
+
/** `--types`: `tsc --noEmit` per workspace program through the app's TypeScript, reused by program key. */
|
|
33
|
+
export function runTypesForApp(session, { repoRoot, tracked, env }) {
|
|
34
|
+
const programs = programsOf(tracked);
|
|
35
|
+
const trackedSet = new Set(tracked);
|
|
36
|
+
const result = runTypesFamily({
|
|
37
|
+
session, programs, run: createTscRunner({ appRoot: repoRoot }), toolCacheDir: toolCacheDir(repoRoot, env),
|
|
38
|
+
inputsOf: (program) => {
|
|
39
|
+
const side = program.name.startsWith('be') ? 'be' : 'fe';
|
|
40
|
+
const prefix = `${program.reads?.[0] ?? side}/`;
|
|
41
|
+
const staged = runGitText(repoRoot, ['ls-files', '-s', '-z', '--', prefix]).stdout.split('\0').filter(Boolean).map((line) => line.replace(/^\d+ ([0-9a-f]+) (\d)\t(.*)$/u, '$3:$1:$2'));
|
|
42
|
+
return { staged, untracked: untrackedProgramInputs(repoRoot, side, trackedSet).filter((file) => file.startsWith(prefix)) };
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
return { result, findings: result.findings.map((found) => asFinding(found, 'TS_DIAGNOSTIC', 'error', 'tsc')) };
|
|
46
|
+
}
|
|
47
|
+
/** `--test`: the unit specs of the back end through the app's own Jest, reused by closure key; the merged total is judged by `@starci/hfs` (the coverage proof). */
|
|
48
|
+
export async function runTestForApp(session, { repoRoot, tracked }) {
|
|
49
|
+
if (!tracked.includes('be/package.json') && !tracked.some((file) => file.startsWith('be/')))
|
|
50
|
+
return { result: null, findings: [] };
|
|
51
|
+
const jest = await createJestEnvironment({ appRoot: repoRoot, side: 'be' });
|
|
52
|
+
let judged = [];
|
|
53
|
+
const result = runTestFamily({
|
|
54
|
+
session, jest, side: 'be', merge: installedSpecMerge(repoRoot),
|
|
55
|
+
judge: (lcovPath) => {
|
|
56
|
+
const report = coverageFrom({ repoRoot, lcov: fs.readFileSync(lcovPath, 'utf8'), files: [...tracked] });
|
|
57
|
+
judged = report.findings.length === 0 ? [] : withWhy(report.findings, readWhy(skillRoot, [...new Set(report.findings.map((found) => found.code))]));
|
|
58
|
+
return { ok: report.ok, findings: report.findings.map((found) => ({ code: found.code, message: String(found.message ?? '') })) };
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
const findings = [...judged];
|
|
62
|
+
for (const spec of result.failed)
|
|
63
|
+
findings.push({ code: 'JEST_SPEC_FAILED', level: 'error', engine: 'jest', path: spec, message: `${spec} failed or reported no coverage; run npm test for its output` });
|
|
64
|
+
return { result, findings };
|
|
65
|
+
}
|
|
66
|
+
/** `--test --full --shard i/n`: one shard of the full unit run in the shape the shard merge reads; nothing is recorded. */
|
|
67
|
+
export function runTestShard(repoRoot, index, total) {
|
|
68
|
+
const shard = shardArgs(index, total);
|
|
69
|
+
const jestBin = createRequire(path.join(repoRoot, 'be', 'package.json')).resolve('jest/bin/jest');
|
|
70
|
+
const run = spawnSync(process.execPath, [jestBin, ...shard.args], { cwd: path.join(repoRoot, 'be'), stdio: 'inherit', env: { ...process.env, ...shard.env } });
|
|
71
|
+
return run.status ?? 1;
|
|
72
|
+
}
|
|
73
|
+
/** `--sonar`: the Sonar and Codecov baselines and the predictions from the merged coverage records; they never pass or fail a run. */
|
|
74
|
+
export function runSonarForApp(session, { repoRoot }) {
|
|
75
|
+
const read = (file) => { try {
|
|
76
|
+
return fs.readFileSync(path.join(repoRoot, file), 'utf8');
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
} };
|
|
81
|
+
const properties = read('sonar-project.properties');
|
|
82
|
+
const codecov = read('codecov.yml');
|
|
83
|
+
if (properties === undefined || codecov === undefined)
|
|
84
|
+
return null;
|
|
85
|
+
const gateYaml = fs.readFileSync(path.join(skillRoot, 'knowledge', 'sonar-gate.yaml'), 'utf8');
|
|
86
|
+
const git = gitRunner(repoRoot);
|
|
87
|
+
const base = ['origin/main', 'main'].map((ref) => git(['merge-base', 'HEAD', ref])?.trim()).find((sha) => sha !== undefined && sha !== '') ?? git(['rev-parse', 'HEAD'])?.trim() ?? 'none';
|
|
88
|
+
const diff = git(['diff', '-U0', '--no-color', base]) ?? '';
|
|
89
|
+
const baseRecords = recordsAtCommit(base, git);
|
|
90
|
+
return runQualityFamily({
|
|
91
|
+
session, sources: { baseCommit: base, sonarGateYaml: gateYaml, sonarProperties: properties, codecovYaml: codecov },
|
|
92
|
+
changed: changedLinesFromDiff(diff), clones: [], baseCoverage: baseRecords.length > 0 ? lineCoverageOf(baseRecords) : undefined,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ContentIndex } from './types.mjs';
|
|
2
|
+
/** Whether `file` is inside the committed cache folder. */
|
|
3
|
+
export declare const inCacheDir: (file: string) => boolean;
|
|
4
|
+
/** The index of `tracked` (app-relative posix paths) read from the work tree at `root`. A path the work tree no longer holds has no hash. */
|
|
5
|
+
export declare function createContentIndex(root: string, tracked: readonly string[]): ContentIndex;
|
|
6
|
+
/** What differs between the work tree and a trusted record set: `modified` (hash differs), `added` (no record), `deleted` (a record, no file). */
|
|
7
|
+
export interface IndexDiff {
|
|
8
|
+
readonly modified: readonly string[];
|
|
9
|
+
readonly added: readonly string[];
|
|
10
|
+
readonly deleted: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
/** The diff of the index against `recorded` (path to the hash a trusted record holds). A rename is a deletion and an addition. */
|
|
13
|
+
export declare function diffIndex(index: ContentIndex, recorded: ReadonlyMap<string, string>): IndexDiff;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// content-index.mts - the tracked files of the app and their content hashes, hashed again on every run. The cache folder itself is outside
|
|
2
|
+
// the index: it never keys a step and is never a step's file set.
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { hashFile } from './hash.mjs';
|
|
5
|
+
import { CACHE_DIR } from './types.mjs';
|
|
6
|
+
/** Whether `file` is inside the committed cache folder. */
|
|
7
|
+
export const inCacheDir = (file) => file === CACHE_DIR || file.startsWith(`${CACHE_DIR}/`);
|
|
8
|
+
/** The index of `tracked` (app-relative posix paths) read from the work tree at `root`. A path the work tree no longer holds has no hash. */
|
|
9
|
+
export function createContentIndex(root, tracked) {
|
|
10
|
+
const hashes = new Map();
|
|
11
|
+
const paths = [];
|
|
12
|
+
for (const file of tracked) {
|
|
13
|
+
if (inCacheDir(file))
|
|
14
|
+
continue;
|
|
15
|
+
paths.push(file);
|
|
16
|
+
const hash = hashFile(path.join(root, file));
|
|
17
|
+
if (hash !== undefined)
|
|
18
|
+
hashes.set(file, hash);
|
|
19
|
+
}
|
|
20
|
+
paths.sort();
|
|
21
|
+
return { paths, hashOf: (file) => hashes.get(file) };
|
|
22
|
+
}
|
|
23
|
+
/** The diff of the index against `recorded` (path to the hash a trusted record holds). A rename is a deletion and an addition. */
|
|
24
|
+
export function diffIndex(index, recorded) {
|
|
25
|
+
const modified = [];
|
|
26
|
+
const added = [];
|
|
27
|
+
for (const file of index.paths) {
|
|
28
|
+
const had = recorded.get(file);
|
|
29
|
+
if (had === undefined)
|
|
30
|
+
added.push(file);
|
|
31
|
+
else if (had !== index.hashOf(file))
|
|
32
|
+
modified.push(file);
|
|
33
|
+
}
|
|
34
|
+
const live = new Set(index.paths);
|
|
35
|
+
const deleted = [...recorded.keys()].filter((file) => !live.has(file)).sort();
|
|
36
|
+
return { modified, added, deleted };
|
|
37
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CheckFinding } from '../runtime/scripts/hfs/check.mjs';
|
|
2
|
+
import type { CheckSession, FamilyReport } from './types.mjs';
|
|
3
|
+
export declare const DB_FOLDER = "check/db";
|
|
4
|
+
/** The merge-base of HEAD with origin/main, else main, as the baseline rules find it; `none` when neither resolves. */
|
|
5
|
+
export declare function dbMergeBase(repoRoot: string): string;
|
|
6
|
+
export declare function runDbFamily(session: CheckSession, { repoRoot, tracked, base }: {
|
|
7
|
+
repoRoot: string;
|
|
8
|
+
tracked: readonly string[];
|
|
9
|
+
base?: string | undefined;
|
|
10
|
+
}): Promise<{
|
|
11
|
+
findings: CheckFinding[];
|
|
12
|
+
report: FamilyReport;
|
|
13
|
+
}>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// db-family.mts - `app check --db`: the database rules of `db/` (DB_*). Scoped: reused when every `db/` hash and the merge-base the baseline
|
|
2
|
+
// rules compare with are unchanged; any `db/` change or a new merge-base judges the rules again.
|
|
3
|
+
import { checkDatabase } from '../runtime/scripts/hfs/rules/database.mjs';
|
|
4
|
+
import { mergeBase } from '../runtime/scripts/api/git/merge-base.mjs';
|
|
5
|
+
import { RecordSet, keyOfPaths, scopedStep } from './records.mjs';
|
|
6
|
+
import { makeReport } from './report.mjs';
|
|
7
|
+
export const DB_FOLDER = 'check/db';
|
|
8
|
+
/** The merge-base of HEAD with origin/main, else main, as the baseline rules find it; `none` when neither resolves. */
|
|
9
|
+
export function dbMergeBase(repoRoot) {
|
|
10
|
+
for (const ref of ['origin/main', 'main']) {
|
|
11
|
+
const sha = mergeBase(repoRoot, 'HEAD', ref);
|
|
12
|
+
if (sha)
|
|
13
|
+
return sha;
|
|
14
|
+
}
|
|
15
|
+
return 'none';
|
|
16
|
+
}
|
|
17
|
+
const compact = (finding) => ({ path: finding.path ?? 'db', line: finding.line ?? 0, column: finding.column ?? 0, rule: finding.code, message: String(finding.message ?? '') });
|
|
18
|
+
export async function runDbFamily(session, { repoRoot, tracked, base }) {
|
|
19
|
+
const decision = session.decide('db');
|
|
20
|
+
const records = new RecordSet(session.records(DB_FOLDER));
|
|
21
|
+
const previous = records.findings('db-rules');
|
|
22
|
+
const inputs = session.index.paths.filter((file) => file.startsWith('db/') || file.startsWith('supabase/') || file.endsWith('.sql'));
|
|
23
|
+
const key = keyOfPaths(session.index, inputs, { 'merge-base': base ?? dbMergeBase(repoRoot) });
|
|
24
|
+
const step = session.progress.step('db', 'rules', inputs.length);
|
|
25
|
+
const { value, reused } = await scopedStep(session, records, {
|
|
26
|
+
path: 'db', step: 'db-rules', key, reuse: decision.mode === 'diff', inputs,
|
|
27
|
+
compute: async () => JSON.parse(JSON.stringify(await checkDatabase({ repoRoot, files: [...tracked], base }))),
|
|
28
|
+
compact: (found) => found.map(compact),
|
|
29
|
+
});
|
|
30
|
+
step.tick(inputs.length, reused ? inputs.length : 0, reused ? 0 : inputs.length);
|
|
31
|
+
step.end();
|
|
32
|
+
session.replace(DB_FOLDER, records.toArray());
|
|
33
|
+
const findings = value;
|
|
34
|
+
const report = makeReport({ family: 'db', mode: decision.mode, reason: decision.reason, checked: reused ? 0 : inputs.length, reused: reused ? inputs.length : 0, previous, current: findings.map(compact) });
|
|
35
|
+
session.report(report);
|
|
36
|
+
return { findings, report };
|
|
37
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { CacheRecord, CheckSession, ContentIndex, FamilyId, FamilyReport, RunMode } from './types.mjs';
|
|
2
|
+
/** One progress event the fake saw. */
|
|
3
|
+
export type ProgressEvent = {
|
|
4
|
+
readonly kind: 'start';
|
|
5
|
+
readonly family: FamilyId;
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly total: number;
|
|
8
|
+
} | {
|
|
9
|
+
readonly kind: 'tick';
|
|
10
|
+
readonly family: FamilyId;
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly done: number;
|
|
13
|
+
readonly reused: number;
|
|
14
|
+
readonly checked: number;
|
|
15
|
+
} | {
|
|
16
|
+
readonly kind: 'end';
|
|
17
|
+
readonly family: FamilyId;
|
|
18
|
+
readonly name: string;
|
|
19
|
+
};
|
|
20
|
+
export interface FakeSessionOptions {
|
|
21
|
+
/** The work tree: app-relative posix path to text. */
|
|
22
|
+
readonly files?: Readonly<Record<string, string>>;
|
|
23
|
+
/** The trusted records, by folder. */
|
|
24
|
+
readonly records?: Readonly<Record<string, readonly CacheRecord[]>>;
|
|
25
|
+
/** The run mode every family is told; `full` carries `fullReason`. */
|
|
26
|
+
readonly mode?: RunMode;
|
|
27
|
+
readonly fullReason?: string;
|
|
28
|
+
}
|
|
29
|
+
/** The fake, with the logs a spec asserts on. */
|
|
30
|
+
export interface FakeSession extends CheckSession {
|
|
31
|
+
readonly events: ProgressEvent[];
|
|
32
|
+
readonly reports: FamilyReport[];
|
|
33
|
+
readonly replaced: Map<string, readonly CacheRecord[]>;
|
|
34
|
+
/** Edits the fake tree between a run's read and its write (the "edit during the run" case). */
|
|
35
|
+
setFile(path: string, text: string | undefined): void;
|
|
36
|
+
flushed: number;
|
|
37
|
+
}
|
|
38
|
+
/** A content index over `files`, hashing as the real one does. */
|
|
39
|
+
export declare function createFakeIndex(files: Readonly<Record<string, string>>): ContentIndex & {
|
|
40
|
+
set(path: string, text: string | undefined): void;
|
|
41
|
+
};
|
|
42
|
+
export declare function createFakeSession(options?: FakeSessionOptions): FakeSession;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// fake-session.mts - the in-memory CheckSession specs (and the work packages that build on the cache) run families against: a
|
|
2
|
+
// tree given as text, trusted records given as a map, and a log of everything the family did. It touches no disk and no clock.
|
|
3
|
+
import { hashText } from './hash.mjs';
|
|
4
|
+
/** A content index over `files`, hashing as the real one does. */
|
|
5
|
+
export function createFakeIndex(files) {
|
|
6
|
+
const hashes = new Map(Object.entries(files).map(([path, text]) => [path, hashText(text)]));
|
|
7
|
+
return {
|
|
8
|
+
get paths() {
|
|
9
|
+
return [...hashes.keys()].sort();
|
|
10
|
+
},
|
|
11
|
+
hashOf: (path) => hashes.get(path),
|
|
12
|
+
set(path, text) {
|
|
13
|
+
if (text === undefined)
|
|
14
|
+
hashes.delete(path);
|
|
15
|
+
else
|
|
16
|
+
hashes.set(path, hashText(text));
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function createFakeSession(options = {}) {
|
|
21
|
+
const index = createFakeIndex(options.files ?? {});
|
|
22
|
+
const initial = new Map(index.paths.map((file) => [file, index.hashOf(file)]));
|
|
23
|
+
const stored = new Map(Object.entries(options.records ?? {}));
|
|
24
|
+
const events = [];
|
|
25
|
+
const reports = [];
|
|
26
|
+
const replaced = new Map();
|
|
27
|
+
const decision = { mode: options.mode ?? 'diff', reason: options.fullReason ?? (options.mode === 'full' ? 'full run' : 'diff against the cache') };
|
|
28
|
+
const progress = {
|
|
29
|
+
step(family, name, total) {
|
|
30
|
+
events.push({ kind: 'start', family, name, total });
|
|
31
|
+
return {
|
|
32
|
+
tick: (done, reused, checked) => void events.push({ kind: 'tick', family, name, done, reused, checked }),
|
|
33
|
+
end: () => void events.push({ kind: 'end', family, name }),
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
const session = {
|
|
38
|
+
root: '/fake/app',
|
|
39
|
+
index,
|
|
40
|
+
events,
|
|
41
|
+
reports,
|
|
42
|
+
replaced,
|
|
43
|
+
flushed: 0,
|
|
44
|
+
decide: () => decision,
|
|
45
|
+
records: (folder) => replaced.get(folder) ?? stored.get(folder) ?? [],
|
|
46
|
+
replace: (folder, records) => void replaced.set(folder, records),
|
|
47
|
+
unchanged: (paths) => paths.every((file) => index.hashOf(file) === initial.get(file)),
|
|
48
|
+
report: (report) => void reports.push(report),
|
|
49
|
+
progress,
|
|
50
|
+
setFile: (path, text) => index.set(path, text),
|
|
51
|
+
flush() {
|
|
52
|
+
session.flushed += 1;
|
|
53
|
+
return { written: [...replaced.keys()].sort(), removed: [], warnings: [] };
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
return session;
|
|
57
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type FingerprintPart } from './types.mjs';
|
|
2
|
+
/** The fingerprint of a run: its parts, and whether it is portable (and if not, why). */
|
|
3
|
+
export interface Fingerprint {
|
|
4
|
+
readonly parts: readonly FingerprintPart[];
|
|
5
|
+
readonly portable: boolean;
|
|
6
|
+
readonly localReasons: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
export interface FingerprintInput {
|
|
9
|
+
readonly root: string;
|
|
10
|
+
readonly tracked: readonly string[];
|
|
11
|
+
/** The version of the CLI that runs this package in-process, when it gave one. */
|
|
12
|
+
readonly cliVersion?: string | undefined;
|
|
13
|
+
/** Where @starci/hfs runs from; defaults to this package. */
|
|
14
|
+
readonly hfsDir?: string | undefined;
|
|
15
|
+
}
|
|
16
|
+
/** The fingerprint of the app at `root`. */
|
|
17
|
+
export declare function computeFingerprint({ root, tracked, cliVersion, hfsDir }: FingerprintInput): Fingerprint;
|
|
18
|
+
/** `fingerprint.jsonl`: one line per part. */
|
|
19
|
+
export declare function serializeFingerprint(parts: readonly FingerprintPart[]): string;
|
|
20
|
+
/** The parts of a `fingerprint.jsonl`; a line that is not a part is dropped. */
|
|
21
|
+
export declare function parseFingerprint(text: string): FingerprintPart[];
|
|
22
|
+
/**
|
|
23
|
+
* The part names that differ between the cache's fingerprint and this run's: changed, added, removed, and a name held twice with
|
|
24
|
+
* two digests (a union merge of two branches), which never matches.
|
|
25
|
+
*/
|
|
26
|
+
export declare function fingerprintDifferences(cached: readonly FingerprintPart[], current: readonly FingerprintPart[]): string[];
|
|
27
|
+
/** One digest of all parts, for the run log. */
|
|
28
|
+
export declare function fingerprintDigest(parts: readonly FingerprintPart[]): string;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// fingerprint.mts - the one fingerprint check, lint and coverage share. A part is anything a step reads that is not an app source; any part
|
|
2
|
+
// that differs makes the whole run full. Parts are portable (equal on every host for one commit): the cache format, the CLI and
|
|
3
|
+
// @starci/hfs that run, every installed @starci package by version and recorded integrity, the lockfile and the configuration files by
|
|
4
|
+
// normalised content. A run whose CLI or any @starci package is linked or copied from a local folder has no portable fingerprint.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { combineHashes, hashFile, hashText } from './hash.mjs';
|
|
9
|
+
import { CACHE_FORMAT_VERSION } from './types.mjs';
|
|
10
|
+
/** The tracked files (by base name or exact path) that are parts by content. */
|
|
11
|
+
const CONFIG_NAME = /^(hfs\.json|package\.json|tsconfig[^/]*\.json|eslint\.config\.mjs|stylelint\.config\.mjs|jest\.config\.[cm]?[jt]s|jest\.config\.json|\.prettierrc[^/]*|prettier\.config\.[cm]?[jt]s|\.prettierignore|\.editorconfig|\.gitignore|\.gitattributes|turbo\.json|sonar-project\.properties|codecov\.yml|package-lock\.json)$/;
|
|
12
|
+
const sorted = (parts) => [...parts].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
13
|
+
function readJson(file) {
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
16
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : undefined;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function lockEntries(root) {
|
|
23
|
+
const packages = readJson(path.join(root, 'package-lock.json'))?.['packages'];
|
|
24
|
+
return packages !== null && typeof packages === 'object' ? packages : {};
|
|
25
|
+
}
|
|
26
|
+
function installedStarci(root) {
|
|
27
|
+
try {
|
|
28
|
+
return fs.readdirSync(path.join(root, 'node_modules', '@starci'));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Whether the directory sits in an install (a `node_modules` segment of its real path) rather than a local checkout. */
|
|
35
|
+
function installedUnderNodeModules(dir) {
|
|
36
|
+
let real = dir;
|
|
37
|
+
try {
|
|
38
|
+
real = fs.realpathSync(dir);
|
|
39
|
+
}
|
|
40
|
+
catch { /* an unreadable directory is judged by its given path */ }
|
|
41
|
+
return real.split(/[\\/]/).includes('node_modules');
|
|
42
|
+
}
|
|
43
|
+
/** The fingerprint of the app at `root`. */
|
|
44
|
+
export function computeFingerprint({ root, tracked, cliVersion, hfsDir = fileURLToPath(new URL('..', import.meta.url)) }) {
|
|
45
|
+
const parts = [];
|
|
46
|
+
const localReasons = [];
|
|
47
|
+
const add = (name, value) => void parts.push({ name, sha256: hashText(value) });
|
|
48
|
+
add('cache-format', String(CACHE_FORMAT_VERSION));
|
|
49
|
+
add('cli', cliVersion ?? 'none');
|
|
50
|
+
const hfsVersion = String(readJson(path.join(hfsDir, 'package.json'))?.['version'] ?? 'unknown');
|
|
51
|
+
add('hfs', hfsVersion);
|
|
52
|
+
if (!installedUnderNodeModules(hfsDir))
|
|
53
|
+
localReasons.push(`@starci/hfs runs from a local folder (${path.basename(path.resolve(hfsDir))})`);
|
|
54
|
+
const lock = lockEntries(root);
|
|
55
|
+
for (const name of installedStarci(root).sort()) {
|
|
56
|
+
const dir = path.join(root, 'node_modules', '@starci', name);
|
|
57
|
+
const version = String(readJson(path.join(dir, 'package.json'))?.['version'] ?? 'unknown');
|
|
58
|
+
const entry = lock[`node_modules/@starci/${name}`];
|
|
59
|
+
let linked = entry?.link === true;
|
|
60
|
+
try {
|
|
61
|
+
linked = linked || fs.lstatSync(dir).isSymbolicLink();
|
|
62
|
+
}
|
|
63
|
+
catch { /* absent: not installed */ }
|
|
64
|
+
if (linked || entry?.integrity === undefined)
|
|
65
|
+
localReasons.push(`@starci/${name} is linked or copied from a local folder (no recorded integrity)`);
|
|
66
|
+
add(`pkg:@starci/${name}`, `${version}\0${entry?.integrity ?? 'local'}`);
|
|
67
|
+
}
|
|
68
|
+
for (const file of tracked) {
|
|
69
|
+
if (!CONFIG_NAME.test(path.posix.basename(file)) || file.startsWith('node_modules/'))
|
|
70
|
+
continue;
|
|
71
|
+
const sha = hashFile(path.join(root, file));
|
|
72
|
+
parts.push({ name: `file:${file}`, sha256: sha ?? 'absent' });
|
|
73
|
+
}
|
|
74
|
+
return { parts: sorted(parts), portable: localReasons.length === 0, localReasons };
|
|
75
|
+
}
|
|
76
|
+
/** `fingerprint.jsonl`: one line per part. */
|
|
77
|
+
export function serializeFingerprint(parts) {
|
|
78
|
+
const lines = sorted([...parts]).map((part) => JSON.stringify({ name: part.name, sha256: part.sha256 }));
|
|
79
|
+
return lines.length === 0 ? '' : `${lines.join('\n')}\n`;
|
|
80
|
+
}
|
|
81
|
+
/** The parts of a `fingerprint.jsonl`; a line that is not a part is dropped. */
|
|
82
|
+
export function parseFingerprint(text) {
|
|
83
|
+
const parts = [];
|
|
84
|
+
for (const raw of text.split('\n')) {
|
|
85
|
+
const line = raw.trim();
|
|
86
|
+
if (line === '')
|
|
87
|
+
continue;
|
|
88
|
+
try {
|
|
89
|
+
const value = JSON.parse(line);
|
|
90
|
+
if (typeof value.name === 'string' && typeof value.sha256 === 'string')
|
|
91
|
+
parts.push({ name: value.name, sha256: value.sha256 });
|
|
92
|
+
}
|
|
93
|
+
catch { /* a damaged line is a part that cannot match */ }
|
|
94
|
+
}
|
|
95
|
+
return sorted(parts);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The part names that differ between the cache's fingerprint and this run's: changed, added, removed, and a name held twice with
|
|
99
|
+
* two digests (a union merge of two branches), which never matches.
|
|
100
|
+
*/
|
|
101
|
+
export function fingerprintDifferences(cached, current) {
|
|
102
|
+
const differing = new Set();
|
|
103
|
+
const byName = new Map();
|
|
104
|
+
for (const part of cached)
|
|
105
|
+
byName.set(part.name, (byName.get(part.name) ?? new Set()).add(part.sha256));
|
|
106
|
+
for (const part of current) {
|
|
107
|
+
const held = byName.get(part.name);
|
|
108
|
+
if (held === undefined || held.size !== 1 || !held.has(part.sha256))
|
|
109
|
+
differing.add(part.name);
|
|
110
|
+
}
|
|
111
|
+
const names = new Set(current.map((part) => part.name));
|
|
112
|
+
for (const name of byName.keys())
|
|
113
|
+
if (!names.has(name))
|
|
114
|
+
differing.add(name);
|
|
115
|
+
return [...differing].sort();
|
|
116
|
+
}
|
|
117
|
+
/** One digest of all parts, for the run log. */
|
|
118
|
+
export function fingerprintDigest(parts) {
|
|
119
|
+
return combineHashes(Object.fromEntries(parts.map((part) => [part.name, part.sha256])));
|
|
120
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { FORMAT, type FormatFinding, type Prettier } from '../sync/format.mjs';
|
|
2
|
+
import type { CheckSession, FamilyReport } from './types.mjs';
|
|
3
|
+
export declare const FORMAT_FOLDER = "check/format";
|
|
4
|
+
export interface FormatFamilyResult {
|
|
5
|
+
readonly findings: FormatFinding[];
|
|
6
|
+
readonly report: FamilyReport;
|
|
7
|
+
}
|
|
8
|
+
/** Runs the format family against `session`: decides, formats what must be formatted, replaces the records, reports. */
|
|
9
|
+
export declare function runFormatFamily(session: CheckSession, { files, prettier }: {
|
|
10
|
+
files: readonly string[];
|
|
11
|
+
prettier?: Prettier | undefined;
|
|
12
|
+
}): Promise<FormatFamilyResult>;
|
|
13
|
+
export { FORMAT };
|