@starci/hfs 5.0.5 → 5.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/README.md +22 -2
- package/cache/session.mjs +1 -1
- package/cache/test-family.d.mts +1 -1
- package/cache/test-family.mjs +15 -7
- package/ci/cli.d.mts +7 -1
- package/ci/cli.mjs +37 -6
- package/ci/github.d.mts +1 -0
- package/ci/secret.d.mts +10 -1
- package/ci/secret.mjs +26 -6
- package/ci/verify.d.mts +9 -1
- package/ci/verify.mjs +5 -3
- package/package.json +1 -1
- package/release/candidate-ci.mjs +1 -1
- package/release/gate.d.mts +1 -0
- package/release/gate.mjs +76 -18
- package/release/index.d.mts +1 -1
- package/release/index.mjs +7 -3
- package/runtime/knowledge/hfs/canon-pins.yaml +6 -6
- package/runtime/knowledge/hfs/rules.yaml +2 -2
- package/runtime/modules/kernel/failure-codes.yaml +4 -4
- package/runtime/modules/schemas/application-stacks.schema.yaml +10 -7
- package/runtime/scripts/gates/starcistacks-ci.mjs +3 -1
- package/runtime/scripts/gates/starcistacks-form.d.mts +1 -0
- package/runtime/scripts/gates/starcistacks.d.mts +1 -0
- package/runtime/scripts/gates/starcistacks.mjs +22 -10
- package/runtime/scripts/hfs/architecture/contract-bounds.d.mts +3 -0
- package/runtime/scripts/hfs/architecture/contract-bounds.mjs +72 -0
- package/runtime/scripts/hfs/architecture/index.mjs +1 -1
- package/runtime/scripts/hfs/architecture/tiers.d.mts +2 -1
- package/runtime/scripts/hfs/architecture/tiers.mjs +45 -4
- package/runtime/scripts/hfs/ci-declaration.d.mts +47 -1
- package/runtime/scripts/hfs/ci-declaration.mjs +115 -3
- package/runtime/scripts/hfs/repo-identity.d.mts +5 -1
- package/runtime/scripts/hfs/repo-identity.mjs +15 -10
- package/runtime/scripts/hfs/rules/ci-custody.mjs +21 -32
- package/runtime/scripts/hfs/rules/env-parity.mjs +15 -6
- package/runtime/scripts/hfs/secret-standard.d.mts +1 -0
- package/runtime/scripts/hfs/secret-standard.mjs +25 -4
- package/runtime/scripts/hfs/secret-store.d.mts +5 -0
- package/runtime/scripts/hfs/secret-store.mjs +31 -0
- package/src/main.mjs +1 -1
- package/sync/ci-commands.mjs +2 -1
- package/sync/quality-services.mjs +3 -2
- package/sync/readme-badges.mjs +1 -1
- package/sync/sonar-key.d.mts +2 -3
- package/sync/sonar-key.mjs +7 -14
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +7 -3
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +7 -3
|
@@ -31,35 +31,35 @@ pins:
|
|
|
31
31
|
source: packages/event-stream/package.json
|
|
32
32
|
why: 'the one server-sent-events client of every front end (fe-stack event-stream, owner:event-stream-package): the transport client layer (modules/api/sse/clients) creates one instance for the app with the bearer token source and the stream operations follow on it; it carries Last-Event-ID resume, the capped doubling backoff, the heartbeat timeout, the single 401 renewal and the useSWRSubscription adapter, so no app keeps a hand-written reader.'
|
|
33
33
|
'@starci/grammar':
|
|
34
|
-
version: 0.21.
|
|
34
|
+
version: 0.21.10
|
|
35
35
|
group: starci
|
|
36
36
|
install: registry
|
|
37
37
|
side: fe
|
|
38
38
|
source: packages/grammar/package.json
|
|
39
39
|
why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
|
|
40
40
|
'@starci/eslint-canon-be':
|
|
41
|
-
version: 3.11.
|
|
41
|
+
version: 3.11.17
|
|
42
42
|
group: starci
|
|
43
43
|
install: registry
|
|
44
44
|
side: be
|
|
45
45
|
source: packages/eslint-canon-be/package.json
|
|
46
46
|
why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
|
|
47
47
|
'@starci/eslint-canon-fe':
|
|
48
|
-
version: 9.9.
|
|
48
|
+
version: 9.9.10
|
|
49
49
|
group: starci
|
|
50
50
|
install: registry
|
|
51
51
|
side: fe
|
|
52
52
|
source: packages/eslint-canon-fe/package.json
|
|
53
53
|
why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
|
|
54
54
|
'@starci/eslint-grammar-core':
|
|
55
|
-
version: 0.21.
|
|
55
|
+
version: 0.21.10
|
|
56
56
|
group: starci
|
|
57
57
|
install: registry
|
|
58
58
|
side: fe
|
|
59
59
|
source: packages/eslint-grammar-core/package.json
|
|
60
60
|
why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
|
|
61
61
|
'@starci/eslint-grammar-offset-pop':
|
|
62
|
-
version: 0.21.
|
|
62
|
+
version: 0.21.10
|
|
63
63
|
group: starci
|
|
64
64
|
install: registry
|
|
65
65
|
side: fe
|
|
@@ -106,7 +106,7 @@ pins:
|
|
|
106
106
|
source: packages/ai-router/package.json
|
|
107
107
|
why: 'the AI pool router of a back end: a NestJS module that routes OpenAI-compatible chat, stream and embeddings calls across keys, models and providers, with @starci/ai-router/testing for its specs; @starci/jest-preset compiles it as an ESM-only dependency.'
|
|
108
108
|
'@starci/hfs':
|
|
109
|
-
version: 5.0.
|
|
109
|
+
version: 5.0.6
|
|
110
110
|
group: starci
|
|
111
111
|
install: registry
|
|
112
112
|
side: both
|
|
@@ -5107,7 +5107,7 @@ rules:
|
|
|
5107
5107
|
- {path: packages/eslint/be/module-shape.spec.mjs, editions: [lite, full], codes: [BE_MODULE_DEFINITION_MISSING]}
|
|
5108
5108
|
- id: R296
|
|
5109
5109
|
code: "CI_SECRET_SEALED"
|
|
5110
|
-
law: "A
|
|
5110
|
+
law: "A sealed CI credential copy names an explicit service credential bound to the selected product project, environment, member and key; required product Sonar analysis uses sonar.enc.yaml and an app runtime key never becomes a CI credential. Checks inspect names without decrypting or printing values."
|
|
5111
5111
|
kinds: [check]
|
|
5112
5112
|
gates: [settle, land, ci]
|
|
5113
5113
|
failureCodes: ["CI_SECRET_SEALED"]
|
|
@@ -5129,7 +5129,7 @@ rules:
|
|
|
5129
5129
|
- {path: packages/hfs/src/ci-custody.spec.mjs, editions: [lite, full], codes: [CI_SECRET_SEALED]}
|
|
5130
5130
|
- id: R297
|
|
5131
5131
|
code: "APP_SECRET_IN_CI"
|
|
5132
|
-
law: "A sealed member key
|
|
5132
|
+
law: "A sealed member key selected by an app runtime secret declaration or deploy grant never appears in ci.secrets or ci.vars. The consumer environment, member and selected key determine app custody; online GitHub custody is verified by starci app ci verify."
|
|
5133
5133
|
kinds: [check]
|
|
5134
5134
|
gates: [settle, land, ci]
|
|
5135
5135
|
failureCodes: ["APP_SECRET_IN_CI"]
|
|
@@ -1742,12 +1742,12 @@ CI_DECLARATION_DRIFT:
|
|
|
1742
1742
|
kind: check-finding
|
|
1743
1743
|
|
|
1744
1744
|
CI_SECRET_SEALED:
|
|
1745
|
-
title: "CI
|
|
1745
|
+
title: "CI credential lacks exact project service custody"
|
|
1746
1746
|
title_vi: "Tên CI trùng khóa bí mật đã niêm phong"
|
|
1747
|
-
meaning_vi: "
|
|
1747
|
+
meaning_vi: "Bản sao khóa CI không trỏ đúng dịch vụ, dự án, môi trường, thành viên và khóa đã niêm phong; khóa chạy ứng dụng không thuộc CI."
|
|
1748
1748
|
causes_vi:
|
|
1749
|
-
- "
|
|
1750
|
-
nextStep_vi: "
|
|
1749
|
+
- "Thiếu liên kết credential của dịch vụ, sai dự án hoặc dùng khóa được cấp cho container ứng dụng."
|
|
1750
|
+
nextStep_vi: "Giữ khóa runtime ngoài CI; khai báo đúng credential.project và liên kết services.<id>.ci.secrets cho bản sao khóa công cụ."
|
|
1751
1751
|
owner: op-retry
|
|
1752
1752
|
kind: check-finding
|
|
1753
1753
|
|
|
@@ -196,7 +196,7 @@ $defs:
|
|
|
196
196
|
local: {type: string, pattern: '^https?://[^\s]+$'}
|
|
197
197
|
public: {type: string, pattern: '^https://[^\s]+$'}
|
|
198
198
|
fromCredential:
|
|
199
|
-
description: A
|
|
199
|
+
description: A private connection URL carried inside a sealed credential - its credential id, never the value. Public local or hosted URLs use local or public.
|
|
200
200
|
type: string
|
|
201
201
|
pattern: '^[a-z][a-z0-9-]*$'
|
|
202
202
|
stack: {$ref: '#/$defs/serviceStack'}
|
|
@@ -432,17 +432,19 @@ $defs:
|
|
|
432
432
|
publishedBy: {type: string, minLength: 1}
|
|
433
433
|
serviceCredential:
|
|
434
434
|
description: >-
|
|
435
|
-
A
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
435
|
+
A name-only connection or credential reference: its repository, actual sealed member path and key.
|
|
436
|
+
env names the consuming tool's variable. project references projects[].key for project-scoped tooling;
|
|
437
|
+
ids identify references independently of provider project-key syntax. Username, password and private
|
|
438
|
+
endpoint keys use separate references; operator/admin login lives in a separate authority member and
|
|
439
|
+
is never an implicit scanner or CI credential. Product Sonar analysis names exactly
|
|
440
|
+
.starcistacks/<declared-env>/secrets/sonar.enc.yaml; ci-declaration.mjs enforces that service-specific member.
|
|
441
|
+
Legacy plaintext paths with .enc twins require migration.
|
|
441
442
|
type: object
|
|
442
443
|
additionalProperties: false
|
|
443
444
|
required: [id, custody]
|
|
444
445
|
properties:
|
|
445
446
|
id: {type: string, pattern: '^[a-z][a-z0-9-]*$'}
|
|
447
|
+
project: {type: string, minLength: 1, description: "The exact projects[].key this credential serves; execution selects the entry by the actual consumer repository."}
|
|
446
448
|
purpose: {type: string, minLength: 1}
|
|
447
449
|
env: {type: string, pattern: '^[A-Z][A-Z0-9_]*$'}
|
|
448
450
|
key: {type: string, minLength: 1}
|
|
@@ -469,6 +471,7 @@ $defs:
|
|
|
469
471
|
workflow: {type: string, minLength: 1}
|
|
470
472
|
permissions: {type: array, items: {type: string, minLength: 1}}
|
|
471
473
|
secrets:
|
|
474
|
+
description: Explicit credential-to-CI execution-copy links; names are declared in the top-level ci block. ci.token selects the analysis copy.
|
|
472
475
|
type: array
|
|
473
476
|
items:
|
|
474
477
|
type: object
|
|
@@ -22,7 +22,7 @@ export const catalogOf = (id) => {
|
|
|
22
22
|
export const checkServiceForm = (service, id, entry, at, kind, add, ambiguous) => {
|
|
23
23
|
if (service.provider && !catalogOf(id).providers.includes(service.provider))
|
|
24
24
|
add('refuse', 'STACKS_PROVIDER_UNKNOWN', `${at}.provider`, `${service.provider} is not a ${id} provider (${catalogOf(id).providers.join(', ')})`);
|
|
25
|
-
if (Object.hasOwn(entry?.ci ?? {}, '
|
|
25
|
+
if (Object.hasOwn(entry?.ci ?? {}, 'vars') || service.ci.secrets.some((item) => !item.credential))
|
|
26
26
|
add('suspect', 'STACKS_CI_LEGACY_FORM', `${at}.ci`, 'move the names to the top-level ci block and name them by services.<id>.ci.token / hostVar');
|
|
27
27
|
if (id === 'sonar' && service.provider === 'sonarcloud' && !service.organization
|
|
28
28
|
&& !(kind === 'example' && service.runtimeSecrets.length && !service.credentials.length && service.ci.organizationVar))
|
|
@@ -56,6 +56,8 @@ export function checkQualityService(service, id, at, context, checkServiceCi) {
|
|
|
56
56
|
ambiguous('codecov auth must be oidc or token');
|
|
57
57
|
if (id === 'codecov' && service.auth === 'token' && !service.ci.token)
|
|
58
58
|
ambiguous('codecov auth token requires ci.token');
|
|
59
|
+
if (id === 'codecov' && service.auth === 'oidc' && (service.ci.token || service.credentials.length || service.ci.secrets.length))
|
|
60
|
+
ambiguous('codecov OIDC has no token, credential or sealed member');
|
|
59
61
|
checkServiceCi(service, id, at, context);
|
|
60
62
|
return true;
|
|
61
63
|
}
|
|
@@ -42,6 +42,8 @@ import { readProperties } from '../lib/properties.mjs';
|
|
|
42
42
|
import { escapeRegExp } from '../lib/regex.mjs';
|
|
43
43
|
import { isInfrastructureValueFile } from './starcistacks-value-file.mjs';
|
|
44
44
|
import { STACK_DECLARATION_TEMPLATE } from '../lib/example-refs.mjs';
|
|
45
|
+
import { isSealedName } from '../lib/sealed-member.mjs';
|
|
46
|
+
import { serviceBindings } from '../hfs/ci-declaration.mjs';
|
|
45
47
|
import { checkSecretForm, kindOf } from './starcistacks-form.mjs';
|
|
46
48
|
import { SERVICE_CATALOG, catalogOf, checkServiceForm, checkCiDeclaration, checkQualityService } from './starcistacks-ci.mjs';
|
|
47
49
|
import { applicationStackFindings } from './starcistacks-runtime.mjs';
|
|
@@ -165,9 +167,10 @@ const normalizeCredential = (credential, declaringRepo) => {
|
|
|
165
167
|
const repo = resolveDeclaredRepository(custody.repository, { fromRepo: declaringRepo });
|
|
166
168
|
const rel = text(custody.path);
|
|
167
169
|
const abs = resolveCustodyFile(repo, rel);
|
|
168
|
-
|
|
170
|
+
const sealed = rel && isSealedName(rel) ? abs : abs ? `${abs}.enc` : null;
|
|
171
|
+
return { id: text(credential.id), env: text(credential.env), key: text(credential.key), purpose: text(credential.purpose), project: text(credential.project),
|
|
169
172
|
custody: { repository: text(custody.repository), path: rel, resolved: Boolean(repo), file: abs,
|
|
170
|
-
encPresent:
|
|
173
|
+
encPresent: sealed ? isFile(sealed) : null, plainPresent: abs && !isSealedName(abs) ? isFile(abs) : false } };
|
|
171
174
|
};
|
|
172
175
|
const ownerActionOf = (s) => {
|
|
173
176
|
const noAction = s.ownerAction === 'none' ? 'none' : null;
|
|
@@ -239,12 +242,9 @@ export function resolveStackService(repoRoot, serviceId) {
|
|
|
239
242
|
const listed = service.projects.some((project) => project.repository === name);
|
|
240
243
|
if (declaration.sourceHost && !listed)
|
|
241
244
|
continue;
|
|
242
|
-
//
|
|
243
|
-
// token (a credential whose id is a project key); other projects' tokens and the host's CI wiring are not its.
|
|
244
|
-
const ownKey = service.projects.find((project) => project.repository === name)?.key;
|
|
245
|
-
const projectKeys = new Set(service.projects.map((project) => project.key));
|
|
245
|
+
// A source host lends public server settings only; analysis credentials stay in the selected product's custody.
|
|
246
246
|
if (declaration.sourceHost)
|
|
247
|
-
service = { ...service, credentials:
|
|
247
|
+
service = { ...service, credentials: [],
|
|
248
248
|
ci: { wiring: null, workflow: null, permissions: [], secrets: [], vars: [], provisioning: null } };
|
|
249
249
|
return { declaration: declaration.file, declaringRepo: declaration.repo, sourceHost: Boolean(declaration.sourceHost),
|
|
250
250
|
repository: name, projectKey: service.projects.find((project) => project.repository === name)?.key ?? null, ...service };
|
|
@@ -304,7 +304,7 @@ const checkServiceCredentials = (service, at, add, ambiguous) => {
|
|
|
304
304
|
if (!credential.custody.resolved)
|
|
305
305
|
add('suspect', 'STACKS_CUSTODY_UNVERIFIED', where, `custody repository ${credential.custody.repository} is not checked out on this machine`);
|
|
306
306
|
else if (!credential.custody.encPresent)
|
|
307
|
-
add('refuse', 'STACKS_CUSTODY_MISSING', where, `${credential.custody.repository}/${credential.custody.path}
|
|
307
|
+
add('refuse', 'STACKS_CUSTODY_MISSING', where, `${credential.custody.repository}/${credential.custody.path} is not sealed in custody; repair the declared member through starci app secret`);
|
|
308
308
|
}
|
|
309
309
|
return ids;
|
|
310
310
|
};
|
|
@@ -371,6 +371,18 @@ const checkService = (id, entry, { schema, declFile, declaration, governing, nam
|
|
|
371
371
|
checkServiceForm(service, id, entry, at, kind, add, ambiguous);
|
|
372
372
|
if (checkDisabledService(service, id, at, ciUses, ambiguous, add))
|
|
373
373
|
return;
|
|
374
|
+
let product = false;
|
|
375
|
+
try {
|
|
376
|
+
product = JSON.parse(fs.readFileSync(path.join(declaration?.repo ?? '', 'hfs.json'), 'utf8')).kind === 'app';
|
|
377
|
+
}
|
|
378
|
+
catch { /* other checks own a missing app declaration */ }
|
|
379
|
+
if (id === 'sonar' && product) {
|
|
380
|
+
const selected = serviceBindings(declaration?.doc, name);
|
|
381
|
+
for (const problem of selected.problems.filter((problem) => problem.at.startsWith('services.sonar')))
|
|
382
|
+
ambiguous(problem.message);
|
|
383
|
+
if (service.ci.wiring === 'required' && !selected.bindings.some((binding) => binding.service === 'sonar' && binding.name === service.ci.token))
|
|
384
|
+
ambiguous('required Sonar analysis needs this product project credential in its declared environment sonar.enc.yaml member');
|
|
385
|
+
}
|
|
374
386
|
if (checkQualityService(service, id, at, { governing, name, ciUses, ciText, add, ambiguous }, checkServiceCi))
|
|
375
387
|
return;
|
|
376
388
|
checkEnabledService(service, id, at, { governing, name, ciUses, ciText, add, kind }, ambiguous);
|
|
@@ -546,7 +558,7 @@ export function checkStarciStacks(repoRoot, { newRepo = false, examplesRoot = pa
|
|
|
546
558
|
function summary(service) {
|
|
547
559
|
return { provider: service.provider, mode: service.mode, auth: service.auth, host: service.host,
|
|
548
560
|
stack: service.stack ? { owner: service.stack.owner ?? null, repository: service.stack.repository, root: service.stack.root, environment: service.stack.environment, compose: service.stack.compose, container: service.stack.container } : null,
|
|
549
|
-
projects: service.projects, credentials: service.credentials.map((credential) => ({ id: credential.id, env: credential.env, key: credential.key,
|
|
561
|
+
projects: service.projects, credentials: service.credentials.map((credential) => ({ id: credential.id, env: credential.env, key: credential.key, project: credential.project,
|
|
550
562
|
custody: `${credential.custody.repository}/${credential.custody.path}`, present: credential.custody.encPresent ?? null })),
|
|
551
563
|
ci: service.ci, ownerAction: service.ownerAction };
|
|
552
564
|
}
|
|
@@ -575,7 +587,7 @@ export function ownerAskConflict({ repo, question } = {}) {
|
|
|
575
587
|
continue;
|
|
576
588
|
const custody = service.credentials.map((credential) => `${credential.custody.repository}/${credential.custody.path}`);
|
|
577
589
|
const custodyNote = custody.length ? ` and its credentials in custody (${custody.join(', ')})` : ` and auth ${service.auth}`;
|
|
578
|
-
const sonarNote = id === 'sonar' ? '
|
|
590
|
+
const sonarNote = id === 'sonar' ? 'The native app release gate selects this product project credential; analysis never provisions projects or rotates credentials. ' : '';
|
|
579
591
|
const provisioningNote = service.ci.provisioning ? ` (${service.ci.provisioning})` : '';
|
|
580
592
|
return { service: id, provider: service.provider, declaration: slash(service.declaration), matched: byName ?? byWord,
|
|
581
593
|
message: `${id} (${service.provider}, ${service.mode}) is declared in ${slash(service.declaration)} with ownerAction none${custodyNote}; the owner is never asked for it. ${sonarNote}CI wiring is ${service.ci.wiring ?? 'undeclared'}${provisioningNote}.` };
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { TypeScriptApi } from './config.mjs';
|
|
2
|
+
/** True only for an unambiguous named export proven through actual declarations to a generated contract declaration. */
|
|
3
|
+
export declare function isBoundsExport(ts: TypeScriptApi, target: string, name: string, ownsGenerated: (file: string) => boolean, seen?: Set<string>): boolean;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
/** The generated input-bounds owner is contract data, not an operation. */
|
|
4
|
+
const BOUNDS_MODULE = /(?:^|\/)__generated__\/input-bounds\.ts$/;
|
|
5
|
+
/** Directly exported declarations with the requested name; multiple declarations are ambiguous. */
|
|
6
|
+
function declaredExports(ts, source, name) {
|
|
7
|
+
let count = 0;
|
|
8
|
+
for (const statement of source.statements) {
|
|
9
|
+
if (!ts.canHaveModifiers(statement) || !ts.getModifiers(statement)?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword))
|
|
10
|
+
continue;
|
|
11
|
+
if (ts.isVariableStatement(statement)) {
|
|
12
|
+
count += statement.declarationList.declarations.filter(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === name).length;
|
|
13
|
+
}
|
|
14
|
+
else if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement) ||
|
|
15
|
+
ts.isTypeAliasDeclaration(statement) || ts.isEnumDeclaration(statement)) && statement.name?.text === name)
|
|
16
|
+
count += 1;
|
|
17
|
+
}
|
|
18
|
+
return count;
|
|
19
|
+
}
|
|
20
|
+
/** The concrete file a relative module specifier resolves to, in the same order as the rule's owner resolver. */
|
|
21
|
+
function readSource(ts, target) {
|
|
22
|
+
for (const file of [`${target}.ts`, `${target}/index.ts`, target]) {
|
|
23
|
+
let text;
|
|
24
|
+
try {
|
|
25
|
+
text = readFileSync(file, 'utf8');
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
31
|
+
if (source.parseDiagnostics.length)
|
|
32
|
+
return null;
|
|
33
|
+
return { file, source };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
/** True only for an unambiguous named export proven through actual declarations to a generated contract declaration. */
|
|
38
|
+
export function isBoundsExport(ts, target, name, ownsGenerated, seen = new Set()) {
|
|
39
|
+
const found = readSource(ts, target);
|
|
40
|
+
if (!found)
|
|
41
|
+
return false;
|
|
42
|
+
const { file, source } = found;
|
|
43
|
+
const key = `${file}#${name}`;
|
|
44
|
+
if (seen.has(key) || seen.size >= 32)
|
|
45
|
+
return false;
|
|
46
|
+
const direct = declaredExports(ts, source, name);
|
|
47
|
+
const matches = [];
|
|
48
|
+
let wildcard = false;
|
|
49
|
+
for (const statement of source.statements) {
|
|
50
|
+
if (!ts.isExportDeclaration(statement))
|
|
51
|
+
continue;
|
|
52
|
+
const module = statement.moduleSpecifier && ts.isStringLiteralLike(statement.moduleSpecifier) ? statement.moduleSpecifier.text : null;
|
|
53
|
+
if (!statement.exportClause) {
|
|
54
|
+
wildcard = true;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!ts.isNamedExports(statement.exportClause))
|
|
58
|
+
continue;
|
|
59
|
+
for (const item of statement.exportClause.elements)
|
|
60
|
+
if (item.name.text === name) {
|
|
61
|
+
matches.push({ origin: (item.propertyName ?? item.name).text, module });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (wildcard || direct + matches.length !== 1)
|
|
65
|
+
return false;
|
|
66
|
+
if (BOUNDS_MODULE.test(file.replaceAll('\\', '/')))
|
|
67
|
+
return ownsGenerated(file) && direct === 1 && matches.length === 0;
|
|
68
|
+
const match = matches[0];
|
|
69
|
+
if (direct || !match || !match.module?.startsWith('.'))
|
|
70
|
+
return false;
|
|
71
|
+
return isBoundsExport(ts, resolve(dirname(file), match.module), match.origin, ownsGenerated, new Set([...seen, key]));
|
|
72
|
+
}
|
|
@@ -243,7 +243,7 @@ function runHfsMachine(config, context, paths, injectedTypeScript, base, fast, v
|
|
|
243
243
|
return hfsChecks;
|
|
244
244
|
const graph = buildHfsGraph(config, hfsContext);
|
|
245
245
|
const input = { config, context: hfsContext, graph, base };
|
|
246
|
-
const runs = { tiers: () => checkTiers(graph), reachability: () => checkReachability(input), deadExports: () => checkDeadExports(input),
|
|
246
|
+
const runs = { tiers: () => checkTiers(graph, hfsContext.ts), reachability: () => checkReachability(input), deadExports: () => checkDeadExports(input),
|
|
247
247
|
requiredFiles: () => checkRequiredFiles(input), clones: () => checkClones(input), symbols: () => checkSymbols(input), docLanguage: () => checkDocLanguage(input) };
|
|
248
248
|
if (graph.profile === 'be')
|
|
249
249
|
for (const [name, [check]] of Object.entries(BACKEND_MACHINE))
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TypeScriptApi } from './config.mjs';
|
|
1
2
|
import type { ArchitectureViolation, HfsGraph } from './hfs-graph.mjs';
|
|
2
3
|
/**
|
|
3
4
|
* HFS checks 1 and 2 (knowledge/hfs/slots.yaml `tiers`):
|
|
@@ -14,7 +15,7 @@ import type { ArchitectureViolation, HfsGraph } from './hfs-graph.mjs';
|
|
|
14
15
|
* Importing inside one owner is always allowed; the public-entry rule stays with owners.mjs (ARCH_OWNER_EXPORT_BYPASS).
|
|
15
16
|
*/
|
|
16
17
|
export declare const TIER_RULE_IDS: string[];
|
|
17
|
-
export declare function checkTiers(graph: HfsGraph): {
|
|
18
|
+
export declare function checkTiers(graph: HfsGraph, ts: TypeScriptApi): {
|
|
18
19
|
violations: ArchitectureViolation[];
|
|
19
20
|
coverage: {
|
|
20
21
|
tierDirection: number;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
2
|
+
import { dirname, relative } from 'node:path';
|
|
3
|
+
import { isBoundsExport } from './contract-bounds.mjs';
|
|
2
4
|
import { registryListImport, registrySlot } from './entity-registry.mjs';
|
|
3
5
|
/**
|
|
4
6
|
* HFS checks 1 and 2 (knowledge/hfs/slots.yaml `tiers`):
|
|
@@ -139,14 +141,53 @@ function reportReasonedTierViolation(graph, edge, answer, tierRule, counts, viol
|
|
|
139
141
|
message,
|
|
140
142
|
});
|
|
141
143
|
}
|
|
142
|
-
|
|
144
|
+
/** A public API entry may expose generated contract data without exposing operations to a hook. */
|
|
145
|
+
function contractMetadataEdge(graph, ts, edge, verdict) {
|
|
146
|
+
if (graph.profile !== 'fe' || verdict.reason !== 'tierDirection')
|
|
147
|
+
return false;
|
|
148
|
+
const from = graph.files.get(edge.from);
|
|
149
|
+
const to = graph.files.get(edge.to);
|
|
150
|
+
if (from?.tier !== 'hooks' || to?.tier !== 'transport' || to.slot !== 'fe.modules.api' || !to.owner || to.owner.slot !== 'fe.modules' || to.owner.bindings.capability !== 'api')
|
|
151
|
+
return false;
|
|
152
|
+
// The tier verdict precedes private-entry checks, so prove both app isolation and the owner entry here.
|
|
153
|
+
const fromApp = graph.resolver.classifyPath(edge.from).bindings?.app;
|
|
154
|
+
const toApp = graph.resolver.classifyPath(edge.to).bindings?.app;
|
|
155
|
+
if (!fromApp || fromApp !== toApp || edge.to !== `${to.owner.root}/index.ts`)
|
|
156
|
+
return false;
|
|
157
|
+
const declaration = edge.edge.declaration;
|
|
158
|
+
let names;
|
|
159
|
+
if (ts.isImportDeclaration(declaration)) {
|
|
160
|
+
const clause = declaration.importClause;
|
|
161
|
+
if (!clause || clause.name || !clause.namedBindings || !ts.isNamedImports(clause.namedBindings))
|
|
162
|
+
return false;
|
|
163
|
+
names = clause.namedBindings.elements.map(item => (item.propertyName ?? item.name).text);
|
|
164
|
+
}
|
|
165
|
+
else if (ts.isExportDeclaration(declaration)) {
|
|
166
|
+
if (!declaration.exportClause || !ts.isNamedExports(declaration.exportClause))
|
|
167
|
+
return false;
|
|
168
|
+
names = declaration.exportClause.elements.map(item => (item.propertyName ?? item.name).text);
|
|
169
|
+
}
|
|
170
|
+
else
|
|
171
|
+
return false;
|
|
172
|
+
const apiOwner = to.owner;
|
|
173
|
+
const dirnameOfEntry = dirname(to.abs);
|
|
174
|
+
const ownsGenerated = (file) => {
|
|
175
|
+
const rel = `${apiOwner.root}/${relative(dirnameOfEntry, file).replaceAll('\\', '/')}`;
|
|
176
|
+
const classified = graph.resolver.classifyPath(rel);
|
|
177
|
+
const owner = graph.resolver.ownerOf(rel);
|
|
178
|
+
return classified.status === 'owned' && classified.slot === 'fe.transport.generated' &&
|
|
179
|
+
owner?.slot === apiOwner.slot && owner.root === apiOwner.root;
|
|
180
|
+
};
|
|
181
|
+
return names.length > 0 && names.every(name => isBoundsExport(ts, to.abs, name, ownsGenerated));
|
|
182
|
+
}
|
|
183
|
+
function inspectTierEdge(graph, ts, edge, tierRule, counts, violations, coverage) {
|
|
143
184
|
const verdict = graph.resolver.importAllowed(edge.from, edge.to);
|
|
144
185
|
if (verdict.reason === 'unowned' || verdict.reason?.startsWith('slot')) {
|
|
145
186
|
coverage.unclassifiedEdges += 1;
|
|
146
187
|
return;
|
|
147
188
|
}
|
|
148
189
|
coverage.edgesChecked += 1;
|
|
149
|
-
if (verdict.reason === 'tierDirection' && registryListImport(graph.resolver, edge))
|
|
190
|
+
if (verdict.reason === 'tierDirection' && (registryListImport(graph.resolver, edge) || contractMetadataEdge(graph, ts, edge, verdict)))
|
|
150
191
|
return;
|
|
151
192
|
reportReasonedTierViolation(graph, edge, verdict, tierRule, counts, violations);
|
|
152
193
|
}
|
|
@@ -197,14 +238,14 @@ function addOwnerCycleViolations(adjacency, witness, violations) {
|
|
|
197
238
|
}
|
|
198
239
|
return components.length;
|
|
199
240
|
}
|
|
200
|
-
export function checkTiers(graph) {
|
|
241
|
+
export function checkTiers(graph, ts) {
|
|
201
242
|
const { resolver, profile } = graph;
|
|
202
243
|
const violations = [];
|
|
203
244
|
const tierRule = profile === 'be' ? 'BE_TIER_DIRECTION' : 'FE_TIER_DIRECTION';
|
|
204
245
|
const coverage = { edgesChecked: 0, unclassifiedEdges: 0 };
|
|
205
246
|
const counts = { tierDirection: 0, layerOrder: 0, crossApp: 0 };
|
|
206
247
|
for (const edge of graph.edges)
|
|
207
|
-
inspectTierEdge(graph, edge, tierRule, counts, violations, coverage);
|
|
248
|
+
inspectTierEdge(graph, ts, edge, tierRule, counts, violations, coverage);
|
|
208
249
|
// Owner cycles: nodes are owner units, edges the imports between two distinct units.
|
|
209
250
|
const { adjacency, witness } = ownerAdjacency(graph, resolver);
|
|
210
251
|
const cycles = addOwnerCycleViolations(adjacency, witness, violations);
|
|
@@ -45,11 +45,57 @@ export interface ValidCiDeclaration extends CiDeclaration {
|
|
|
45
45
|
}
|
|
46
46
|
/** True when readCiDeclaration found no problem in `declaration`. */
|
|
47
47
|
export declare const isValidCiDeclaration: (declaration: CiDeclaration) => declaration is ValidCiDeclaration;
|
|
48
|
+
/** A private endpoint uses an explicitly linked secret copy; public endpoints use the existing CI variable selector. */
|
|
49
|
+
export declare function serviceHostSecret(service: ParsedDoc): string | null;
|
|
50
|
+
/** Public credential selectors that ordinary child processes have no authority to inherit. */
|
|
51
|
+
export declare function serviceCredentialNames(doc: ParsedDoc): string[];
|
|
52
|
+
/** A project service's retrievable credential, and the explicitly linked CI execution copy. No value is held here. */
|
|
53
|
+
export interface ServiceBinding {
|
|
54
|
+
service: string;
|
|
55
|
+
credential: string;
|
|
56
|
+
name: string;
|
|
57
|
+
repository: string;
|
|
58
|
+
projectKey: string;
|
|
59
|
+
environment: string;
|
|
60
|
+
member: string;
|
|
61
|
+
key: string;
|
|
62
|
+
path: string;
|
|
63
|
+
}
|
|
64
|
+
export interface BindingProblem {
|
|
65
|
+
at: string;
|
|
66
|
+
message: string;
|
|
67
|
+
}
|
|
68
|
+
/** An application runtime declaration or deploy grant selects runtime authority regardless of its CI name. */
|
|
69
|
+
export declare function runtimeGranted(doc: ParsedDoc, environment: string, member: string, key: string): boolean;
|
|
70
|
+
/** Read an actual member path, retaining the legacy dotenv suffix without guessing a plaintext twin. */
|
|
71
|
+
export declare function serviceMemberPath(value: unknown): {
|
|
72
|
+
environment: string;
|
|
73
|
+
member: string;
|
|
74
|
+
path: string;
|
|
75
|
+
} | null;
|
|
76
|
+
/** The selected project's public credential reference, independent of CI: used for a sealed private endpoint too. */
|
|
77
|
+
export declare function projectServiceCredential(doc: ParsedDoc, serviceId: string, credentialId: string, repository: string): ServiceBinding | null;
|
|
78
|
+
/**
|
|
79
|
+
* Positive tooling classification: the selected product project, one explicit CI link, its own declared environment and exact
|
|
80
|
+
* member/key. Names alone, a sealed key alone, or credentials of a host or another project confer no tooling authority.
|
|
81
|
+
*/
|
|
82
|
+
export declare function serviceBindings(doc: ParsedDoc, repository: string): {
|
|
83
|
+
bindings: ServiceBinding[];
|
|
84
|
+
problems: BindingProblem[];
|
|
85
|
+
};
|
|
86
|
+
/** A sealed row belongs to this exact positive binding; a same-named key elsewhere remains unrelated custody. */
|
|
87
|
+
export declare function boundSealedKey(binding: ServiceBinding, sealed: {
|
|
88
|
+
env?: string;
|
|
89
|
+
member?: string;
|
|
90
|
+
key: string;
|
|
91
|
+
file?: string;
|
|
92
|
+
}): boolean;
|
|
48
93
|
/** Each CI name belongs to one class and scope; declarations contain names and public configuration only. */
|
|
49
94
|
export declare function readCiDeclaration(doc: ParsedDoc): CiDeclaration;
|
|
50
95
|
/** Missing quality blocks preserve the existing rendered defaults; disabled services remain explicitly disabled. */
|
|
51
|
-
export declare function qualityServices(doc: ParsedDoc, { example }?: {
|
|
96
|
+
export declare function qualityServices(doc: ParsedDoc, { example, repository }?: {
|
|
52
97
|
example?: boolean;
|
|
98
|
+
repository?: string | undefined;
|
|
53
99
|
}): ParsedDoc;
|
|
54
100
|
/** Read only encrypted envelope key names, never decrypting or returning any sealed value. */
|
|
55
101
|
export declare function sealedKeyNames(root: string, { fs }: {
|