@starci/hfs 4.7.12 → 4.7.14
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 +11 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/release/gate.mjs +388 -378
- package/release/lint-report.mjs +52 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/knowledge/hfs/ci-standard.yaml +1 -1
- package/runtime/knowledge/hfs/rules.yaml +7 -6
- package/runtime/knowledge/patterns/be/cli.yaml +1 -0
- package/runtime/knowledge/patterns/be/realtime.yaml +1 -0
- package/runtime/knowledge/patterns/be/webhooks.yaml +1 -0
- package/runtime/modules/kernel/failure-codes.yaml +24 -2
- package/runtime/packages/cli/src/catalog.generated.mjs +12 -6
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
- package/runtime/scripts/hfs/architecture/hfs.mjs +19 -4
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +6 -4
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +1 -1
- package/runtime/scripts/hfs/ci-declaration.mjs +5 -2
- package/runtime/scripts/hfs/db-baseline-verb.mjs +5 -2
- package/runtime/scripts/hfs/doc-commands.mjs +2 -1
- package/runtime/scripts/hfs/rule-catalog.mjs +5 -2
- package/runtime/scripts/hfs/rules/coverage-below.mjs +5 -3
- package/runtime/scripts/hfs/rules/database-access.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-migrations.mjs +13 -3
- package/runtime/scripts/hfs/rules/database-plpgsql.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-queues.mjs +2 -2
- package/runtime/scripts/hfs/rules/deploy-scan.mjs +50 -6
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +11 -1
- package/runtime/scripts/hfs/rules/doc-fresh.mjs +18 -3
- package/runtime/scripts/hfs/rules/docker-health.mjs +4 -1
- package/runtime/scripts/hfs/rules/env-parity.mjs +8 -7
- package/runtime/scripts/hfs/rules/env-value-shape.mjs +2 -4
- package/runtime/scripts/hfs/rules/fe-build-args.mjs +1 -1
- package/runtime/scripts/hfs/rules/release-provenance.mjs +8 -5
- package/runtime/scripts/hfs/rules/script-hygiene.mjs +23 -7
- package/runtime/scripts/hfs/rules/services.mjs +19 -5
- package/runtime/scripts/hfs/rules/stack-tooling.mjs +5 -2
- package/runtime/scripts/hfs/rules/stacks-swarm.mjs +5 -5
- package/runtime/scripts/hfs/rules/staged-specs.mjs +26 -0
- package/runtime/scripts/hfs/rules/supply-chain.mjs +3 -3
- package/runtime/scripts/hfs/rules/unused-symbol-properties.mjs +1 -1
- package/runtime/scripts/hfs/rules/unused-symbol.mjs +8 -4
- package/runtime/scripts/hfs/sealed-members.mjs +1 -1
- package/runtime/scripts/hfs/secret-bootstrap-crypto.mjs +1 -1
- package/runtime/scripts/hfs/secret-files.mjs +1 -1
- package/runtime/scripts/hfs/secret-sops.mjs +1 -1
- package/runtime/scripts/hfs/stack-deploy-config.mjs +19 -2
- package/runtime/scripts/lib/env.mjs +2 -1
- package/runtime/scripts/lib/git-hooks-dir.mjs +30 -0
- package/runtime/scripts/lib/path-key.mjs +1 -1
- package/runtime/scripts/lib/roots.mjs +3 -3
- package/runtime/scripts/lib/secret-patterns.mjs +1 -1
- package/runtime/scripts/lib/swarm-stack.mjs +4 -2
- package/src/main.mjs +2 -2
- package/sync/ci-commands.mjs +275 -275
- package/sync/hygiene.mjs +19 -4
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// lint-report.mjs - the external issues report the release gate's sonar row produces before it runs the scanner.
|
|
2
|
+
// The app declares `sonar.externalIssuesReportPaths=reports/lint.sonar.json`; the managed CI makes it with `lint --side <side> --sonar reports/lint.<side>.sonar.json`
|
|
3
|
+
// per lint matrix side and a "merge Sonar lint reports" step. This module does the same locally, through the same process seam as every gate
|
|
4
|
+
// (an argument array through ctx.spawn, never a shell). The merge below is the one in packages/hfs/sync/ci-commands.mjs (sonarMergeSteps): keep them identical.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { firstLine, tool } from '../stack/context.mjs';
|
|
8
|
+
|
|
9
|
+
const LINT_SIDES = ['be', 'fe'];
|
|
10
|
+
const MERGED_REPORT = 'reports/lint.sonar.json';
|
|
11
|
+
const sideReport = (side) => `reports/lint.${side}.sonar.json`;
|
|
12
|
+
|
|
13
|
+
/** CI merge: rules unique by id (last one wins, first position kept), issues concatenated in side order. */
|
|
14
|
+
function mergeSonarReports(reports) {
|
|
15
|
+
const rules = [...new Map(reports.flatMap((report) => report.rules).map((rule) => [rule.id, rule])).values()];
|
|
16
|
+
const issues = reports.flatMap((report) => report.issues);
|
|
17
|
+
return { rules, issues };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The sides the app has: a be/ or fe/ folder beside the app root. */
|
|
21
|
+
const sidesOf = (root) => LINT_SIDES.filter((side) => fs.existsSync(path.join(root, side)));
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Runs `starci app lint --side <side> --sonar <file>` for every side the app has, then writes reports/lint.sonar.json.
|
|
25
|
+
* `launch` turns an argv into [command, args] for the platform. Returns { pass, text }: the honest log of every lint run and the verdict.
|
|
26
|
+
* A lint that exits non-zero (findings or error) or writes no report fails the row; nothing is skipped.
|
|
27
|
+
*/
|
|
28
|
+
export function produceLintReports(ctx, { launch, platform, env }) {
|
|
29
|
+
const sides = sidesOf(ctx.cwd);
|
|
30
|
+
const out = [];
|
|
31
|
+
if (sides.length === 0) return { pass: false, text: `no be/ or fe/ side in ${ctx.cwd}: cannot produce ${MERGED_REPORT}\n` };
|
|
32
|
+
fs.mkdirSync(path.join(ctx.cwd, 'reports'), { recursive: true });
|
|
33
|
+
for (const side of sides) {
|
|
34
|
+
const file = path.join(ctx.cwd, sideReport(side));
|
|
35
|
+
fs.rmSync(file, { force: true });
|
|
36
|
+
const argv = ['npx', 'starci', 'app', 'lint', '--side', side, '--sonar', sideReport(side)];
|
|
37
|
+
const [command, args] = launch(argv, platform);
|
|
38
|
+
out.push(`$ ${argv.join(' ')}\n`);
|
|
39
|
+
const run = tool(ctx, command, args, { cwd: ctx.cwd, env });
|
|
40
|
+
out.push(`${run.stdout ?? ''}${run.stderr ?? ''}`);
|
|
41
|
+
if (run.error) return { pass: false, text: `${out.join('')}\nlint ${side} cannot run (${run.error.message})\n` };
|
|
42
|
+
if (run.status !== 0) return { pass: false, text: `${out.join('')}\nlint ${side} failed: exit ${run.status}; the scanner was not run\n` };
|
|
43
|
+
if (!fs.existsSync(file)) return { pass: false, text: `${out.join('')}\nlint ${side} wrote no ${sideReport(side)}; the scanner was not run\n` };
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const reports = sides.map((side) => JSON.parse(fs.readFileSync(path.join(ctx.cwd, sideReport(side)), 'utf8')));
|
|
47
|
+
fs.writeFileSync(path.join(ctx.cwd, MERGED_REPORT), `${JSON.stringify(mergeSonarReports(reports), null, 2)}\n`);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return { pass: false, text: `${out.join('')}\ncannot merge the side reports: ${firstLine(error.message)}\n` };
|
|
50
|
+
}
|
|
51
|
+
return { pass: true, text: `${out.join('')}\nwrote ${MERGED_REPORT} from ${sides.map(sideReport).join(', ')}\n\n` };
|
|
52
|
+
}
|
|
@@ -29,14 +29,14 @@ pins:
|
|
|
29
29
|
source: packages/grammar/package.json
|
|
30
30
|
why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
|
|
31
31
|
'@starci/eslint-canon-be':
|
|
32
|
-
version: 3.4.
|
|
32
|
+
version: 3.4.22
|
|
33
33
|
group: starci
|
|
34
34
|
install: registry
|
|
35
35
|
side: be
|
|
36
36
|
source: packages/eslint/be/package.json
|
|
37
37
|
why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
|
|
38
38
|
'@starci/eslint-canon-fe':
|
|
39
|
-
version: 8.4.
|
|
39
|
+
version: 8.4.21
|
|
40
40
|
group: starci
|
|
41
41
|
install: registry
|
|
42
42
|
side: fe
|
|
@@ -76,14 +76,14 @@ pins:
|
|
|
76
76
|
source: packages/test-world/package.json
|
|
77
77
|
why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
|
|
78
78
|
'@starci/cli':
|
|
79
|
-
version: 1.7.
|
|
79
|
+
version: 1.7.14
|
|
80
80
|
group: starci
|
|
81
81
|
install: registry
|
|
82
82
|
side: both
|
|
83
83
|
source: packages/cli/package.json
|
|
84
84
|
why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
|
|
85
85
|
'@starci/hfs':
|
|
86
|
-
version: 4.7.
|
|
86
|
+
version: 4.7.14
|
|
87
87
|
group: starci
|
|
88
88
|
install: registry
|
|
89
89
|
side: both
|
|
@@ -27,7 +27,7 @@ defaults:
|
|
|
27
27
|
# The steps that need a raised Node heap on the GitHub runner, matched on their `run` text, and the least heap they must declare in NODE_OPTIONS.
|
|
28
28
|
heap:
|
|
29
29
|
minMiB: 6144
|
|
30
|
-
steps: ['npm run typecheck', 'npm run lint', 'app lint', 'app check --coverage-from']
|
|
30
|
+
steps: ['npm run typecheck', 'npm run lint', 'app lint', 'app check --coverage-from', 'npm run db:lint']
|
|
31
31
|
|
|
32
32
|
items:
|
|
33
33
|
- id: candidate-trigger
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
schema: starci/hfs-rules@3
|
|
20
20
|
version: 3.1.0
|
|
21
21
|
gates:
|
|
22
|
-
pre-commit: the managed .husky/pre-commit at the app root - starci app hygiene (staged .starciwork and .starcistacks paths, secrets guard), typecheck, ESLint per side and stylelint on the staged files, prettier --check, the unit specs the staged files touch
|
|
22
|
+
pre-commit: the managed .husky/pre-commit at the app root - starci app hygiene (staged .starciwork and .starcistacks paths, secrets guard, a new back-end logic file staged without its unit spec), typecheck, ESLint per side and stylelint on the staged files, prettier --check, the unit specs the staged files touch
|
|
23
23
|
pre-push: typecheck, lint (starci app lint), format:check and the unit specs affected since origin/main; runs every pre-commit rule again
|
|
24
24
|
settle: the op gate `starci gate run` over the op's changed files (merge guard, starci app lint --changed, codegen, tsc, the slice's specs; only findings new against the workflow's previous checkpoint block), re-read at starci kernel settle, which refuses a red done; a green settle commits the op's checkpoint on wf-<workflowId>
|
|
25
25
|
land: the workflow's finish, the only time main is touched - gate.mjs over the whole workflow branch against its merge-base with main, the merge guard, review.verify of the exact head that lands, the branch rebased onto main, then main fast-forwarded and pushed
|
|
@@ -205,10 +205,10 @@ rules:
|
|
|
205
205
|
- {path: packages/hfs/templates/app/ci-workflows, edition: full}
|
|
206
206
|
witness: {status: unreachable, reason: "owner:no-runtime-specs (2026-10-17): the mutation witness was a deleted runtime spec or fixture", date: "2026-10-17"}
|
|
207
207
|
code: "HFS_CI_MISSING_CANON"
|
|
208
|
-
law: "CI runs the pinned `starci app lint` (`npm run lint`),
|
|
208
|
+
law: "CI runs the pinned `starci app lint` (`npm run lint`), a clone never redirects `core.hooksPath` away from husky, and the folder a set `core.hooksPath` names holds the installed hooks in every checkout (husky's dispatcher in `.husky/_`), so no commit lands without the pre-commit gate."
|
|
209
209
|
kinds: [check, codemod]
|
|
210
210
|
gates: [land, ci]
|
|
211
|
-
failureCodes: ["HFS_CI_MISSING_CANON", "HFS_HOOKS_PATH_REDIRECTED"]
|
|
211
|
+
failureCodes: ["HFS_CI_MISSING_CANON", "HFS_HOOKS_PATH_REDIRECTED", "HFS_HOOKS_NOT_INSTALLED"]
|
|
212
212
|
enforcers:
|
|
213
213
|
- {kind: machine, id: hooks-path-not-redirected, at: scripts/hfs/architecture/hfs.mjs}
|
|
214
214
|
- {kind: hfs, id: ci-missing-canon, at: scripts/hfs/rules/pipeline.mjs}
|
|
@@ -846,12 +846,13 @@ rules:
|
|
|
846
846
|
exemplifiedBy:
|
|
847
847
|
- {path: packages/hfs/templates/be/inbox/service.spec.ts.tpl, editions: [lite, full]}
|
|
848
848
|
witness: {status: unreachable, reason: "owner:no-runtime-specs (2026-10-17): the mutation witness was a deleted runtime spec or fixture", date: "2026-10-17"}
|
|
849
|
-
law: "One jest config, projects `unit`, `integration`, `e2e` and `contract`, live by folder, `diagnostics: false`; unit specs are `<name>.<role>.spec.ts` beside the file they test: required for the roles of `ruleParams.be.unitRoles` (`<name>.service.spec.ts` beside a `<name>.service.ts`, `<name>.cli.spec.ts` beside a cli command `<name>.cli.ts` of `src/features/cli`), optional for any other logic role (`ruleParams.be.logicRoles`) inside a slot whose `coverage` is required, and nowhere else but beside a webhook or realtime door that has no service of its own (`<provider>.webhook.spec.ts`, `<channel>.gateway.spec.ts`, `<x>.subscription.spec.ts`) (every unit-tested subject has exactly one; any other `*.spec.ts` outside `be/src/tests/{world,integration,e2e,contract}` is a finding, including specs in apps), and the unit project collects coverage from the logic roles of the measured roots only (the scope derived from the slot manifest, R204) with a per-file threshold of 100 on lines, branches, functions and statements (`test` runs `--coverage`; `importHelpers` and `tslib`); `be/src/tests/world/` is the ONLY test infrastructure: `global-setup.ts` starts the app's own stack (`.starcistacks/<env>`, named by `stack` in `test-world.config.ts`, the declaration of the test-world library in its one named form `export const { useTestWorld, useSandbox } = defineTestWorld({...})` (a default export is not read): every service it declares runs REAL, behind toxiproxy, images and versions read from the stack at run time, never spelled in test source) or attaches to a warm one that answers, and runs the migrations once over the cli app's connections (the runner of the cli migrate command); `global-teardown.ts` stops only what its setup started; `use-test-world.ts` exports `useTestWorld({ apps } | { modules })` (`world.apps.<name>.api`, `world.db.<connection>`, `world.infra.<service>` with `latency(ms)`, `cut()` and `restore()` on the real service, `world.fake.<provider>`, `world.waitFor`) and `fakes/<provider>/` holds the network-edge fakes (`failNext`, `replayWebhook`, `delay`, payload fixtures) of external SaaS the team does not operate ONLY: a fake of a service the stack declares is refused, except stateless compute that needs special hardware or an external model (GPU inference, a self-hosted embedding model), whose `stacks` entry in `test-world.config.ts` declares `{ fakedBy: <fake>, reason }` with a non-empty `reason` (a stateful service, or one with a persistent volume, is never accepted); it alone owns containers, DataSource, migrations and `process.env`; nothing under `be/src/tests` overrides a provider or DI token; `be/src/tests/integration/<capability>/*.integration-spec.ts` (`{ modules }`, with the real peer apps its client calls beside them as `apps`; real infrastructure, no HTTP door of ours), `be/src/tests/e2e/<area>/*.e2e-spec.ts` (`{ apps }`) and `be/src/tests/contract/<provider>/*.contract-spec.ts` (provider sandboxes, skipped without sandbox config, run only by `test:contract`, never by `test` or `test:e2e`) agree folder with suffix; an integration or e2e spec passes `useTestWorld` one object literal with plain keys (no variable, spread or computed key) and calls it; the contract layer skips only through the test-world library's `useSandbox(...)` when the sandbox config is absent (`sandbox.describe(...)`), and every `fakes/<provider>/` that serves payload fixtures (`payloads/*.json`) has a `contract/<provider>/*.contract-spec.ts` that references those fixtures and asserts `shapeOf(real)` equals `shapeOf(fixture)` (the helper `shapeOf` is named by `ruleParams.be.contractShape` of the slot manifest; fakes the test-world library provides are guarded by the library); no `.test.ts`, `int-spec` or `harness-spec`, no `live/` and no `be/src/tests/e2e/world`."
|
|
849
|
+
law: "One jest config, projects `unit`, `integration`, `e2e` and `contract`, live by folder, `diagnostics: false`; unit specs are `<name>.<role>.spec.ts` beside the file they test: required for the roles of `ruleParams.be.unitRoles` (`<name>.service.spec.ts` beside a `<name>.service.ts`, `<name>.cli.spec.ts` beside a cli command `<name>.cli.ts` of `src/features/cli`), optional for any other logic role (`ruleParams.be.logicRoles`) inside a slot whose `coverage` is required, except that the commit adding a measured logic file stages its `<name>.<role>.spec.ts` beside it (owner 2026-10-20, judged by `starci app hygiene` in the pre-commit hook), and nowhere else but beside a webhook or realtime door that has no service of its own (`<provider>.webhook.spec.ts`, `<channel>.gateway.spec.ts`, `<x>.subscription.spec.ts`) (every unit-tested subject has exactly one; any other `*.spec.ts` outside `be/src/tests/{world,integration,e2e,contract}` is a finding, including specs in apps), and the unit project collects coverage from the logic roles of the measured roots only (the scope derived from the slot manifest, R204) with a per-file threshold of 100 on lines, branches, functions and statements (`test` runs `--coverage`; `importHelpers` and `tslib`); `be/src/tests/world/` is the ONLY test infrastructure: `global-setup.ts` starts the app's own stack (`.starcistacks/<env>`, named by `stack` in `test-world.config.ts`, the declaration of the test-world library in its one named form `export const { useTestWorld, useSandbox } = defineTestWorld({...})` (a default export is not read): every service it declares runs REAL, behind toxiproxy, images and versions read from the stack at run time, never spelled in test source) or attaches to a warm one that answers, and runs the migrations once over the cli app's connections (the runner of the cli migrate command); `global-teardown.ts` stops only what its setup started; `use-test-world.ts` exports `useTestWorld({ apps } | { modules })` (`world.apps.<name>.api`, `world.db.<connection>`, `world.infra.<service>` with `latency(ms)`, `cut()` and `restore()` on the real service, `world.fake.<provider>`, `world.waitFor`) and `fakes/<provider>/` holds the network-edge fakes (`failNext`, `replayWebhook`, `delay`, payload fixtures) of external SaaS the team does not operate ONLY: a fake of a service the stack declares is refused, except stateless compute that needs special hardware or an external model (GPU inference, a self-hosted embedding model), whose `stacks` entry in `test-world.config.ts` declares `{ fakedBy: <fake>, reason }` with a non-empty `reason` (a stateful service, or one with a persistent volume, is never accepted); it alone owns containers, DataSource, migrations and `process.env`; nothing under `be/src/tests` overrides a provider or DI token; `be/src/tests/integration/<capability>/*.integration-spec.ts` (`{ modules }`, with the real peer apps its client calls beside them as `apps`; real infrastructure, no HTTP door of ours), `be/src/tests/e2e/<area>/*.e2e-spec.ts` (`{ apps }`) and `be/src/tests/contract/<provider>/*.contract-spec.ts` (provider sandboxes, skipped without sandbox config, run only by `test:contract`, never by `test` or `test:e2e`) agree folder with suffix; an integration or e2e spec passes `useTestWorld` one object literal with plain keys (no variable, spread or computed key) and calls it; the contract layer skips only through the test-world library's `useSandbox(...)` when the sandbox config is absent (`sandbox.describe(...)`), and every `fakes/<provider>/` that serves payload fixtures (`payloads/*.json`) has a `contract/<provider>/*.contract-spec.ts` that references those fixtures and asserts `shapeOf(real)` equals `shapeOf(fixture)` (the helper `shapeOf` is named by `ruleParams.be.contractShape` of the slot manifest; fakes the test-world library provides are guarded by the library); no `.test.ts`, `int-spec` or `harness-spec`, no `live/` and no `be/src/tests/e2e/world`."
|
|
850
850
|
kinds: [check, codemod, lint]
|
|
851
851
|
gates: [land, ci]
|
|
852
|
-
failureCodes: ["BE_TEST_TOPOLOGY", "BE_CONTRACT_UNGUARDED"]
|
|
852
|
+
failureCodes: ["BE_TEST_TOPOLOGY", "BE_CONTRACT_UNGUARDED", "BE_UNIT_SPEC_NOT_STAGED"]
|
|
853
853
|
enforcers:
|
|
854
854
|
- {kind: hfs, id: test-topology, at: scripts/hfs/rules/test-topology.mjs}
|
|
855
|
+
- {kind: hfs, id: staged-spec-presence, at: scripts/hfs/rules/staged-specs.mjs}
|
|
855
856
|
- {kind: machine, id: test-kind-unsupported, at: scripts/hfs/architecture/hfs.mjs}
|
|
856
857
|
- {kind: eslint-be, id: test-world-files}
|
|
857
858
|
- {kind: machine, id: contract-fixture-guard, at: scripts/hfs/architecture/contract-fixture-guard.mjs}
|
|
@@ -2710,7 +2711,7 @@ rules:
|
|
|
2710
2711
|
- {path: packages/hfs/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml, edition: lite}
|
|
2711
2712
|
- {path: packages/hfs/templates/app/skeleton/.starcistacks/application-stacks.yaml, edition: full}
|
|
2712
2713
|
witness: {status: unreachable, reason: "owner:no-runtime-specs (2026-10-17): the mutation witness was a deleted runtime spec or fixture", date: "2026-10-17"}
|
|
2713
|
-
law: "Every api or worker app `be/apps/<service>` of a product with more than one service is a
|
|
2714
|
+
law: "Every api or worker app `be/apps/<service>` of a product with more than one service is a `role: service` component in `.starcistacks/application-stacks.yaml`: the component of the same name, or one whose `environments.<env>.deploy.images.<component>.app` names the app."
|
|
2714
2715
|
kinds: [check]
|
|
2715
2716
|
gates: [land, ci]
|
|
2716
2717
|
failureCodes: ["HFS_SERVICE_STACK_DECLARATION"]
|
|
@@ -82,6 +82,7 @@ rules:
|
|
|
82
82
|
- HFS_README_BADGE_NOT_LIVE
|
|
83
83
|
- BE_TEST_TOPOLOGY
|
|
84
84
|
- BE_CONTRACT_UNGUARDED
|
|
85
|
+
- BE_UNIT_SPEC_NOT_STAGED
|
|
85
86
|
manual:
|
|
86
87
|
- Confirm the provider endpoint is one door and that a second endpoint of the same provider is a second door only when it has a different proof.
|
|
87
88
|
relatedExamples: [webhooks]
|
|
@@ -1605,6 +1605,17 @@ BE_TYPE_ESCAPE:
|
|
|
1605
1605
|
owner: op-retry
|
|
1606
1606
|
kind: check-finding
|
|
1607
1607
|
|
|
1608
|
+
BE_UNIT_SPEC_NOT_STAGED:
|
|
1609
|
+
title: "A new back-end logic file is committed without its unit spec"
|
|
1610
|
+
title_vi: "Tệp logic back-end mới được commit mà không có unit spec"
|
|
1611
|
+
meaning_vi: "Commit thêm `<file>`, một tệp `<name>.<role>.ts` có vai trò logic mà unit coverage đo ở mức 100 mỗi tệp, nhưng index không có `<name>.<role>.spec.ts` bên cạnh nó: mã được viết trước, spec để sau."
|
|
1612
|
+
causes_vi:
|
|
1613
|
+
- "Agent viết mã trước rồi định sửa lint và spec sau, thay vì chạy vòng op `starci gate read` → mã + spec → `starci gate run`"
|
|
1614
|
+
- "Spec đã viết nhưng chưa được stage cùng tệp"
|
|
1615
|
+
nextStep_vi: "Viết `<name>.<role>.spec.ts` bên cạnh tệp trong cùng thay đổi, chạy `starci gate run --root <app> --changed <files> --tests <pattern>`, rồi stage cả hai tệp và commit lại qua hook. 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; không thêm ngoại lệ."
|
|
1616
|
+
owner: op-retry
|
|
1617
|
+
kind: check-finding
|
|
1618
|
+
|
|
1608
1619
|
BE_UNNAMED_DATA_ACCESS:
|
|
1609
1620
|
title: "Database reached without the named shared EntityManager"
|
|
1610
1621
|
title_vi: "Truy cập cơ sở dữ liệu không qua EntityManager dùng chung có tên"
|
|
@@ -3475,6 +3486,17 @@ HFS_GITIGNORE_BLOCK_DRIFT:
|
|
|
3475
3486
|
owner: op-retry
|
|
3476
3487
|
kind: check-finding
|
|
3477
3488
|
|
|
3489
|
+
HFS_HOOKS_NOT_INSTALLED:
|
|
3490
|
+
title: "The hooks folder core.hooksPath names is missing or empty in this checkout"
|
|
3491
|
+
title_vi: "Hook Git chưa được cài trong bản checkout này"
|
|
3492
|
+
meaning_vi: "`core.hooksPath` trỏ tới `.husky/_` (hoặc `.husky`) nhưng thư mục đó không tồn tại hoặc không có hook đã cài (không có tệp điều phối `h` của husky) trong bản checkout này, nên Git không chạy pre-commit và pre-push: mọi commit bỏ qua lint và spec. Một worktree dùng chung cấu hình Git của clone chính nhưng `.husky/_` chỉ được husky tạo ở nơi đã cài phụ thuộc có chạy lifecycle script."
|
|
3493
|
+
causes_vi:
|
|
3494
|
+
- "Cài phụ thuộc với `--ignore-scripts` nên script `prepare` (husky) không chạy"
|
|
3495
|
+
- "Worktree mới tạo chưa từng chạy husky; runtime chỉ ghi hook riêng của nó vào `.husky/_`"
|
|
3496
|
+
nextStep_vi: "Chạy `starci npm ci` ở gốc app (lệnh này chạy trình cài hook husky `prepare` của app kể cả khi lifecycle script bị bỏ qua), rồi commit lại qua hook; không dùng `--no-verify`. 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."
|
|
3497
|
+
owner: op-retry
|
|
3498
|
+
kind: check-finding
|
|
3499
|
+
|
|
3478
3500
|
HFS_HOOKS_PATH_REDIRECTED:
|
|
3479
3501
|
title: "A clone redirects core.hooksPath away from husky"
|
|
3480
3502
|
title_vi: "core.hooksPath bị đổi hướng khỏi husky"
|
|
@@ -3962,10 +3984,10 @@ HFS_SERVICE_ROLE_SCOPE:
|
|
|
3962
3984
|
HFS_SERVICE_STACK_DECLARATION:
|
|
3963
3985
|
title: "Each service app is a service component of the stack"
|
|
3964
3986
|
title_vi: "Ứng dụng dịch vụ chưa được khai báo trong stack"
|
|
3965
|
-
meaning_vi: "Một ứng dụng api hoặc worker dưới `be/apps/<service>/` của sản phẩm nhiều dịch vụ không có thành phần `role: service`
|
|
3987
|
+
meaning_vi: "Một ứng dụng api hoặc worker dưới `be/apps/<service>/` của sản phẩm nhiều dịch vụ không có thành phần `role: service` trong `.starcistacks/application-stacks.yaml`: không có thành phần cùng tên, và không có thành phần nào được `environments.<env>.deploy.images.<component>.app` gắn với ứng dụng đó."
|
|
3966
3988
|
causes_vi:
|
|
3967
3989
|
- "Vi phạm luật R165: mọi dịch vụ của sản phẩm nhiều dịch vụ là một thành phần `role: service` của stack (image riêng `<repo>/<service>:<x.y.z>`, build từ Dockerfile của app)."
|
|
3968
|
-
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 thành phần cùng tên app (role service, image đã ghim, file compose và cổng của môi trường)."
|
|
3990
|
+
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 thành phần cùng tên app (role service, image đã ghim, file compose và cổng của môi trường), hoặc gắn thành phần dịch vụ đang chạy với app qua `environments.<env>.deploy.images.<component>.app` thay vì đổi tên dịch vụ."
|
|
3969
3991
|
owner: op-retry
|
|
3970
3992
|
kind: check-finding
|
|
3971
3993
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// GENERATED by scripts/cli/gen-catalog.mjs — do not edit
|
|
2
2
|
export const CATALOG = {
|
|
3
3
|
"schema": "starci/cli-catalog@1",
|
|
4
|
-
"hash": "
|
|
4
|
+
"hash": "1ac457c26ee05922e910a56fab64d9bdc9b0bc251466dd4a2f341bf698ff3e73",
|
|
5
5
|
"global": [
|
|
6
6
|
{
|
|
7
7
|
"name": "json",
|
|
@@ -1039,7 +1039,7 @@ export const CATALOG = {
|
|
|
1039
1039
|
"runtimeSide": "host"
|
|
1040
1040
|
},
|
|
1041
1041
|
"hygiene": {
|
|
1042
|
-
"summary": "judge staged worktree metadata, stack files and
|
|
1042
|
+
"summary": "judge staged worktree metadata, stack files, plaintext secrets and new logic files without a spec",
|
|
1043
1043
|
"owner": "@starci/hfs",
|
|
1044
1044
|
"flags": [],
|
|
1045
1045
|
"exit": {
|
|
@@ -7872,7 +7872,7 @@ export const CATALOG = {
|
|
|
7872
7872
|
],
|
|
7873
7873
|
"exit": {
|
|
7874
7874
|
"0": "the clean lockfile was installed",
|
|
7875
|
-
"1": "npm
|
|
7875
|
+
"1": "npm, the host lock or the git hooks install failed",
|
|
7876
7876
|
"2": "the worktree or install state was refused"
|
|
7877
7877
|
},
|
|
7878
7878
|
"json": "starci/npm-ci@1",
|
|
@@ -7898,7 +7898,10 @@ export const CATALOG = {
|
|
|
7898
7898
|
"conventions": [
|
|
7899
7899
|
"npm ci installs the lockfile into this worktree's real node_modules and never uses a junction or symbolic link",
|
|
7900
7900
|
"package.json and package-lock.json must be clean before the run",
|
|
7901
|
-
"dependency installs run one at a time under the host lock and never use npm install"
|
|
7901
|
+
"dependency installs run one at a time under the host lock and never use npm install",
|
|
7902
|
+
"after an install, a core.hooksPath naming a missing or empty folder runs the root husky prepare (npm run prepare), lifecycle scripts ignored or not",
|
|
7903
|
+
"the receipt's hooks field reports the git hooks as installed, repaired, not-set or not-declared",
|
|
7904
|
+
"a hooks path still naming a missing or empty folder after the installer ran fails the verb"
|
|
7902
7905
|
],
|
|
7903
7906
|
"runtimeSide": "host"
|
|
7904
7907
|
},
|
|
@@ -7926,7 +7929,7 @@ export const CATALOG = {
|
|
|
7926
7929
|
],
|
|
7927
7930
|
"exit": {
|
|
7928
7931
|
"0": "exact dependencies and any requested canon pins were installed",
|
|
7929
|
-
"1": "npm, Git, target canon loading
|
|
7932
|
+
"1": "npm, Git, target canon loading, the host lock or the git hooks install failed",
|
|
7930
7933
|
"2": "the package, target pin or worktree was refused"
|
|
7931
7934
|
},
|
|
7932
7935
|
"json": "starci/npm-install@1",
|
|
@@ -7958,7 +7961,10 @@ export const CATALOG = {
|
|
|
7958
7961
|
"target pins never fall back to the running runtime when the target pin file is missing or inconsistent",
|
|
7959
7962
|
"each target StarCi pin is reported as moved (with its workspace), skipped when no package.json declares it, or refused with a reason",
|
|
7960
7963
|
"a failed canon phase exits nonzero even when the first CLI install succeeded",
|
|
7961
|
-
"installs always use --save-exact and report the package.json and package-lock.json diff stat"
|
|
7964
|
+
"installs always use --save-exact and report the package.json and package-lock.json diff stat",
|
|
7965
|
+
"after an install, a core.hooksPath naming a missing or empty folder runs the root husky prepare (npm run prepare), lifecycle scripts ignored or not",
|
|
7966
|
+
"the receipt's hooks field reports the git hooks as installed, repaired, not-set or not-declared",
|
|
7967
|
+
"a hooks path still naming a missing or empty folder after the installer ran fails the verb"
|
|
7962
7968
|
],
|
|
7963
7969
|
"runtimeSide": "host"
|
|
7964
7970
|
}
|
|
@@ -106,7 +106,7 @@ function queueMethods(env, declaration, constructor, excluded) {
|
|
|
106
106
|
&& decoratorsHave(env, parameter, 'InjectQueueOutbox', '@modules/platform/queue'));
|
|
107
107
|
if (!outbox || !env.ts.isIdentifier(outbox.name)) return;
|
|
108
108
|
for (const method of declaration.members) {
|
|
109
|
-
if (!env.ts.isMethodDeclaration(method) ||
|
|
109
|
+
if (!env.ts.isMethodDeclaration(method) || method.body?.statements.length !== 1) continue;
|
|
110
110
|
const call = statementCall(env.ts, method.body.statements[0]);
|
|
111
111
|
if (call && env.ts.isCallExpression(call) && receiverMember(env.ts, call) === outbox.name.text && call.expression.name.text === 'write'
|
|
112
112
|
&& call.arguments.length === 3 && call.arguments.every(argument => mapping(env, argument))) excluded.add(method);
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { lsFiles } from '../../api/git/ls-files.mjs'; import { revParseQuery } from '../../api/git/rev-parse-query.mjs'; import { configGet } from '../../api/git/config-get.mjs'; import { gitOutputOf } from '../../lib/git.mjs';
|
|
4
4
|
import { braceVariants } from '../../lib/glob.mjs';
|
|
5
|
+
import { hooksDirState } from '../../lib/git-hooks-dir.mjs';
|
|
5
6
|
import { createSlotResolver, loadSlotManifest, openHfs } from '../slots.mjs';
|
|
6
7
|
import { isFeTestPath } from '../rules/fe-no-tests.mjs';
|
|
7
8
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
@@ -21,6 +22,7 @@ export const HFS_RULE_IDS = [
|
|
|
21
22
|
'HFS_APPS_REQUIRED',
|
|
22
23
|
'HFS_APP_LAYOUT_INVALID',
|
|
23
24
|
'HFS_E2E_IN_AUTOMATIC_GATE',
|
|
25
|
+
'HFS_HOOKS_NOT_INSTALLED',
|
|
24
26
|
'HFS_HOOKS_PATH_REDIRECTED',
|
|
25
27
|
'HFS_MODULE_TIER_INVALID',
|
|
26
28
|
'HFS_PACKAGE_MANAGER_MIXED',
|
|
@@ -114,15 +116,28 @@ function ownsGitTopLevel(root) {
|
|
|
114
116
|
}
|
|
115
117
|
}
|
|
116
118
|
|
|
117
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* The hooks of the checked root: core.hooksPath stays husky's (HFS_HOOKS_PATH_REDIRECTED), and the folder it names holds
|
|
121
|
+
* installed hooks in this checkout (HFS_HOOKS_NOT_INSTALLED, scripts/lib/git-hooks-dir.mjs). An unset key judges nothing: a CI
|
|
122
|
+
* checkout installs without hooks. A directory that is not the top level of its own work tree has no hooks of its own: the
|
|
123
|
+
* check is not applicable there.
|
|
124
|
+
*/
|
|
118
125
|
function hooksPathNotRedirected({ root, finding }) {
|
|
119
126
|
if (!ownsGitTopLevel(root)) return { status: 'not-applicable', reason: 'the checked root is not the top level of its own Git work tree, so it has no hooks of its own' };
|
|
120
127
|
const set = configGet(root, 'core.hooksPath', { local: true });
|
|
121
128
|
if (!set.ok) return { status: 'checked' }; // key not set: nothing is redirected
|
|
122
|
-
const value = set.stdout;
|
|
123
|
-
if (value
|
|
129
|
+
const value = String(set.stdout ?? '').trim();
|
|
130
|
+
if (!value) return { status: 'checked' };
|
|
131
|
+
if (!HUSKY_HOOKS_PATH.test(value.replaceAll('\\', '/'))) {
|
|
124
132
|
finding('HFS_HOOKS_PATH_REDIRECTED', '.git/config', `core.hooksPath is set to ${value} in this clone. Unset it (git config --local --unset core.hooksPath) and let husky own the hooks; a redirected path skips the pre-commit and pre-push gates.`);
|
|
125
|
-
|
|
133
|
+
return { status: 'checked' };
|
|
134
|
+
}
|
|
135
|
+
const hooks = hooksDirState(root, value);
|
|
136
|
+
if (hooks.state !== 'installed') {
|
|
137
|
+
const what = { missing: 'does not exist', empty: 'holds no installed hook (husky never ran here)', unreadable: `cannot be read (${hooks.error})` }[hooks.state];
|
|
138
|
+
finding('HFS_HOOKS_NOT_INSTALLED', value, `core.hooksPath is ${value}, but that folder ${what} in this checkout, so git runs no pre-commit or pre-push gate and a commit skips lint and specs. Install the hooks before committing: run \`starci npm ci\` at the app root (it runs the app's hook installer \`npm run prepare\` even when lifecycle scripts are ignored), or \`npm run prepare\`; an install with --ignore-scripts alone never installs them.`);
|
|
139
|
+
}
|
|
140
|
+
return { status: 'checked', hooks: hooks.state };
|
|
126
141
|
}
|
|
127
142
|
|
|
128
143
|
/** The tracked paths under the given root, relative to it: the pathspec keeps a nested directory from listing the paths of its enclosing clone. */
|
|
@@ -62,11 +62,13 @@ function catalogsPerApp(config, graph) {
|
|
|
62
62
|
|
|
63
63
|
/** Each interpolation can fill part or all of a segment; the template's static fragments keep their literal spelling. */
|
|
64
64
|
function templatePatternOf(node) {
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
const pattern = [];
|
|
66
|
+
for (const segment of [node.head.text, ...node.templateSpans.map(span => span.literal.text)].join('\u0000').split('.')) {
|
|
67
|
+
if (!segment.includes('\u0000')) { pattern.push(segment); continue; }
|
|
67
68
|
const parts = segment.split('\u0000');
|
|
68
|
-
|
|
69
|
-
}
|
|
69
|
+
pattern.push(parts.every(part => part === '') ? '*' : new RegExp(`^${parts.map((part) => escapeRegExp(part)).join('.*')}$`, 'su'));
|
|
70
|
+
}
|
|
71
|
+
return pattern;
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
function patternsOf(ts, files) {
|
|
@@ -94,7 +94,7 @@ function moduleSpecifierOf(ts, node) {
|
|
|
94
94
|
function isApolloClientConstruction(ts, kit, checker, node) {
|
|
95
95
|
if (!ts.isNewExpression(node)) return false;
|
|
96
96
|
const binding = kit.importBinding(checker, node.expression);
|
|
97
|
-
return Boolean(binding
|
|
97
|
+
return Boolean(binding?.name === 'ApolloClient' && binding.module.startsWith('@apollo/client'));
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
function inspectLibraryImport({ ts, file, node, transport, report }) {
|
|
@@ -60,7 +60,10 @@ export function qualityServices(doc, { example = false } = {}) {
|
|
|
60
60
|
/** Enumerate a missing directory as empty; all filesystem access uses the caller's injected synchronous seam. */
|
|
61
61
|
function entries(dir, fs) {
|
|
62
62
|
try { return fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); }
|
|
63
|
-
catch (error) {
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error.code === 'ENOENT') return [];
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
/** Read only encrypted envelope key names, never decrypting or returning any sealed value. */
|
|
@@ -70,7 +73,7 @@ export function sealedKeyNames(root, { fs }) {
|
|
|
70
73
|
for (const member of entries(path.join(base, env.name, 'secrets'), fs).filter((entry) => entry.isFile() && entry.name.endsWith('.enc'))) {
|
|
71
74
|
const file = `.starcistacks/${env.name}/secrets/${member.name}`;
|
|
72
75
|
const source = fs.readFileSync(path.join(root, file), 'utf8');
|
|
73
|
-
const dotenv = [...source.matchAll(/^([A-Za-z_]
|
|
76
|
+
const dotenv = [...source.matchAll(/^([A-Za-z_]\w*)=ENC\[/gm)].map((match) => match[1]).filter((key) => !key.startsWith('sops_'));
|
|
74
77
|
let keys = dotenv;
|
|
75
78
|
if (!dotenv.length) {
|
|
76
79
|
const doc = parseYaml(source);
|
|
@@ -87,7 +87,7 @@ function baselinePlan(root, opts, now) {
|
|
|
87
87
|
if (!active.length) throw new DbVerbError('baseline requires an existing active migration chain');
|
|
88
88
|
const archived = dbSqlFiles(root, MIGRATIONS_ARCHIVE_DIR);
|
|
89
89
|
const oldStamps = [...active, ...archived].map((entry) => entry.stamp);
|
|
90
|
-
const last = oldStamps.
|
|
90
|
+
const last = oldStamps.toSorted((left, right) => left.localeCompare(right)).at(-1);
|
|
91
91
|
const stamp = opts.stamp ?? now().toISOString().replace(/\D/g, '').slice(0, 14);
|
|
92
92
|
if (!/^\d{14}$/.test(stamp) || migrationStamp(stamp) === null || stamp <= last) throw new DbVerbError('--stamp must be a valid UTC version after the archive boundary');
|
|
93
93
|
const file = `${MIGRATIONS_DIR}/${stamp}_baseline.sql`;
|
|
@@ -225,7 +225,10 @@ function extensionDeclaration(stmt, sql, extensions) {
|
|
|
225
225
|
|
|
226
226
|
function baselineStatement(state, stmt, sql) {
|
|
227
227
|
if (roleDeclaration(stmt, sql, state.roles) || extensionDeclaration(stmt, sql, state.extensions)) return;
|
|
228
|
-
if (stmt.DropRoleStmt) {
|
|
228
|
+
if (stmt.DropRoleStmt) {
|
|
229
|
+
for (const role of stmt.DropRoleStmt.roles ?? []) state.roles.delete(role.RoleSpec?.rolename);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
229
232
|
if (stmt.GrantRoleStmt || stmt.AlterExtensionStmt || stmt.AlterExtensionContentsStmt || /^OBJECT_ROLE$/.test(stmt.RenameStmt?.renameType ?? '')) throw new DbVerbError('baseline cannot reconstruct role membership, role renames or extension alterations');
|
|
230
233
|
if (dataMutation(stmt) || sourceExecution(stmt)) throw new DbVerbError('baseline cannot reconstruct migration-owned data changes or custom execution from a schema-only dump');
|
|
231
234
|
const node = Object.values(stmt)[0];
|
|
@@ -29,7 +29,8 @@ const inner = (t) => t.replace(/^<|>$/g, '');
|
|
|
29
29
|
|
|
30
30
|
// ---- extraction -----------------------------------------------------------------------------------------------
|
|
31
31
|
|
|
32
|
-
const
|
|
32
|
+
const COMMAND_FORM = /npx\s+starci|npm\s+run\s+starci(?:\s+--silent)?(?:\s+--)?|starci/;
|
|
33
|
+
const COMMAND_AT = new RegExp(String.raw`(?<![\w@./:-])(?:${COMMAND_FORM.source})(?![\w@./-])(?=\s|$)`, 'g');
|
|
33
34
|
const FENCE = /^\s*(?:[-*>|]\s*)?```/;
|
|
34
35
|
const SCRIPT_COMMENT = /^\s*(?:\/\/|\*|\/\*)/;
|
|
35
36
|
const CONTINUED = /\\\s*$/;
|
|
@@ -21,7 +21,10 @@ const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'fail
|
|
|
21
21
|
const CASE_KEYS = new Set(['id', 'date', 'source', 'ruling', 'quote', 'supersedes', 'status', 'staleWhen', 'amendedBy']);
|
|
22
22
|
const CASE_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
23
23
|
const ENFORCER_KEYS = new Set(['kind', 'id', 'status', 'at', 'editions', 'editionWhy']);
|
|
24
|
-
const
|
|
24
|
+
const CASE_ID = /case-[1-9]\d*/;
|
|
25
|
+
const RULING_REFERENCE = new RegExp(String.raw`(?:ruling:)?R\d{2,3}(?:/${CASE_ID.source})?`);
|
|
26
|
+
const OWNER_REFERENCE = /owner:[a-z][a-z0-9]*(?:-[a-z0-9]+)*/;
|
|
27
|
+
const CASE_REFERENCE = new RegExp(`^(?:${CASE_ID.source}|${RULING_REFERENCE.source}|${OWNER_REFERENCE.source})$`);
|
|
25
28
|
const EVIDENCE_KEYS = new Set(['path', 'edition', 'editions']);
|
|
26
29
|
const PROOF_KEYS = new Set([...EVIDENCE_KEYS, 'codes', 'fixtures']);
|
|
27
30
|
const WITNESS_KEYS = new Set(['edition', 'base', 'mutate', 'expect']);
|
|
@@ -191,7 +194,7 @@ function checkRuleCases(rule, label, bad) {
|
|
|
191
194
|
const at = `${label}.cases[${index}]`;
|
|
192
195
|
if (!isPlainObject(item)) { bad.push(`${at} is not a map`); return; }
|
|
193
196
|
for (const key of Object.keys(item)) if (!CASE_KEYS.has(key)) bad.push(`${at} has unknown key ${key}`);
|
|
194
|
-
if (typeof item.id !== 'string' || !/^case-[1-9]
|
|
197
|
+
if (typeof item.id !== 'string' || !/^case-[1-9]\d*$/.test(item.id) || ids.has(item.id)) bad.push(`${at}.id must be a unique case-N`);
|
|
195
198
|
ids.add(item.id);
|
|
196
199
|
if (typeof item.date !== 'string' || !CASE_DATE.test(item.date)) bad.push(`${at}.date must be YYYY-MM-DD`);
|
|
197
200
|
if (item.source !== 'owner') bad.push(`${at}.source must be owner`);
|
|
@@ -12,7 +12,6 @@ import { found } from './read.mjs';
|
|
|
12
12
|
export const HFS_COVERAGE_BELOW = 'HFS_COVERAGE_BELOW';
|
|
13
13
|
const METRICS = ['lines', 'functions', 'branches'];
|
|
14
14
|
const SUMMARY = { LF: ['lines', 'found'], LH: ['lines', 'hit'], FNF: ['functions', 'found'], FNH: ['functions', 'hit'], BRF: ['branches', 'found'], BRH: ['branches', 'hit'] };
|
|
15
|
-
const SOURCE = /\.ts$/;
|
|
16
15
|
const SPEC = /(?:\.(?:spec|test)|-spec)\.ts$/;
|
|
17
16
|
const countOf = (value) => /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : null;
|
|
18
17
|
const slash = (value) => path.posix.normalize(String(value).replaceAll('\\', '/'));
|
|
@@ -66,7 +65,10 @@ function parseLcov(text) {
|
|
|
66
65
|
const records = [];
|
|
67
66
|
const problems = [];
|
|
68
67
|
let current = null;
|
|
69
|
-
const flush = () => {
|
|
68
|
+
const flush = () => {
|
|
69
|
+
if (current) records.push(current);
|
|
70
|
+
current = null;
|
|
71
|
+
};
|
|
70
72
|
for (const [index, raw] of String(text).replace(/^\uFEFF/, '').split(/\r?\n/).entries()) {
|
|
71
73
|
const line = raw.trim();
|
|
72
74
|
if (!line) continue;
|
|
@@ -102,7 +104,7 @@ function inventory({ files, all, repo, manifest }) {
|
|
|
102
104
|
const scope = coverageScope(effective, repo.providers ?? repo.sides?.be?.providers ?? []);
|
|
103
105
|
const roots = scope.roots.map((dir) => globExpression(`${dir}**`));
|
|
104
106
|
const excludes = scope.excludes.map((dir) => globExpression(`${dir}**`));
|
|
105
|
-
const candidates = [...new Set((all ?? files ?? []).map(slash))].filter((file) => file.startsWith(prefix) &&
|
|
107
|
+
const candidates = [...new Set((all ?? files ?? []).map(slash))].filter((file) => file.startsWith(prefix) && file.endsWith('.ts') && !SPEC.test(file)).sort(byCodeUnit);
|
|
106
108
|
const measured = [];
|
|
107
109
|
const unmeasured = [];
|
|
108
110
|
for (const file of candidates) {
|
|
@@ -74,7 +74,7 @@ async function principalHelpers(analyses) {
|
|
|
74
74
|
try { bodies.push({ name: fn.name, ast: await parseSql(body) }); } catch { /* PL/pgSQL safety is checked separately. */ }
|
|
75
75
|
});
|
|
76
76
|
const helpers = new Set();
|
|
77
|
-
for (let
|
|
77
|
+
for (let remaining = bodies.length; remaining > 0; remaining -= 1) {
|
|
78
78
|
for (const body of bodies) if (hasPrincipal(body.ast, helpers)) helpers.add(body.name);
|
|
79
79
|
}
|
|
80
80
|
return helpers;
|
|
@@ -13,7 +13,10 @@ import { fileURLToPath } from 'node:url';
|
|
|
13
13
|
import { loadTypescript } from '../../lib/package-at.mjs';
|
|
14
14
|
|
|
15
15
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
-
const
|
|
16
|
+
const MIGRATION_PLAIN = /[a-z0-9]+(?:-[a-z0-9]+)*/;
|
|
17
|
+
const MIGRATION_CONTRACT_TAIL = /contract_[a-z0-9]+(?:[-_][a-z0-9]+)*/;
|
|
18
|
+
const MIGRATION_SLUG = [MIGRATION_PLAIN.source, MIGRATION_CONTRACT_TAIL.source, `${MIGRATION_PLAIN.source}_${MIGRATION_CONTRACT_TAIL.source}`].join('|');
|
|
19
|
+
const MIGRATION_NAME = new RegExp(String.raw`^(\d{14})_(${MIGRATION_SLUG})\.sql$`);
|
|
17
20
|
export const isMigrationName = (name) => MIGRATION_NAME.test(name);
|
|
18
21
|
const RESTAMP_HEADER = /^-- starci:restamp (\d{14}) (\d{14})(?:\r?\n|$)/;
|
|
19
22
|
const withoutRestampHeader = (text) => text?.replace(RESTAMP_HEADER, '') ?? null;
|
|
@@ -227,7 +230,11 @@ export function databaseSqlTokens(text) {
|
|
|
227
230
|
let index = 0;
|
|
228
231
|
let line = 1;
|
|
229
232
|
while (index < text.length) {
|
|
230
|
-
if (/\s/.test(text[index])) {
|
|
233
|
+
if (/\s/.test(text[index])) {
|
|
234
|
+
if (text[index] === '\n') line += 1;
|
|
235
|
+
index += 1;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
231
238
|
const end = commentEnd(text, index);
|
|
232
239
|
if (end !== null) { line += text.slice(index, end).split('\n').length - 1; index = end; continue; }
|
|
233
240
|
const token = sqlTokenAt(text, index);
|
|
@@ -243,7 +250,10 @@ export function databaseSqlStatements(tokens) {
|
|
|
243
250
|
const statements = [];
|
|
244
251
|
let current = [];
|
|
245
252
|
for (const token of tokens) {
|
|
246
|
-
if (token.t === 'punct' && token.v === ';') {
|
|
253
|
+
if (token.t === 'punct' && token.v === ';') {
|
|
254
|
+
if (current.length) statements.push(current);
|
|
255
|
+
current = [];
|
|
256
|
+
}
|
|
247
257
|
else current.push(token);
|
|
248
258
|
}
|
|
249
259
|
if (current.length) statements.push(current);
|
|
@@ -290,7 +290,7 @@ async function functionFindings(file, fn, facts, exposed) {
|
|
|
290
290
|
|
|
291
291
|
function alteredDefinerFindings(file, alter, facts, exposed) {
|
|
292
292
|
if (!alter.securityDefiner) return [];
|
|
293
|
-
const created = facts.functions.
|
|
293
|
+
const created = facts.functions.findLast((fn) => fn.index < alter.index && matchesAlter(fn, alter));
|
|
294
294
|
const node = created?.node ?? { func: alter.node.func };
|
|
295
295
|
const fn = { ...alter, node, altered: true, originIndex: created?.index ?? -1 };
|
|
296
296
|
return definerDeclarationFindings(file, fn, facts, exposed, true);
|
|
@@ -104,7 +104,7 @@ function inspectDo(state, node, file) {
|
|
|
104
104
|
const option = (key) => (node.args ?? []).find((item) => item.DefElem?.defname === key)?.DefElem?.arg?.String?.sval;
|
|
105
105
|
const tree = plpgsqlTree(option('as') ?? '');
|
|
106
106
|
walk(tree, (item) => {
|
|
107
|
-
if (item.PLpgSQL_expr
|
|
107
|
+
if (item.PLpgSQL_expr?.parseMode === 0) inspectCalls(state, sqlTree(item.PLpgSQL_expr.query), file, true);
|
|
108
108
|
if (item.PLpgSQL_stmt_dynexecute) {
|
|
109
109
|
const expression = sqlTree(`select ${item.PLpgSQL_stmt_dynexecute.query?.PLpgSQL_expr?.query ?? ''}`);
|
|
110
110
|
dynamicOwnership(state, expression, file, true);
|
|
@@ -128,7 +128,7 @@ function grantCheck(state, node, file) {
|
|
|
128
128
|
function recordGrant(state, node, file, executed) {
|
|
129
129
|
state.grants.push({ node, file, created: new Map(state.created), executed });
|
|
130
130
|
const grant = node.GrantStmt;
|
|
131
|
-
if (!executed ||
|
|
131
|
+
if (!executed || grant?.objtype !== 'OBJECT_TABLE' || grant.targtype !== 'ACL_TARGET_OBJECT') return;
|
|
132
132
|
if (!(grant.grantees ?? []).some((item) => item.RoleSpec?.rolename === state.appRole)) return;
|
|
133
133
|
const privileges = grant.privileges?.map((item) => item.AccessPriv?.priv_name).filter(Boolean) ?? TABLE_PRIVILEGES;
|
|
134
134
|
for (const object of grant.objects ?? []) {
|