@starci/hfs 2.0.2 → 4.0.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 +25 -0
- package/README.md +33 -33
- package/bin/hfs.mjs +94 -68
- package/lint/run.mjs +161 -0
- package/package.json +3 -2
- package/report/sonar.mjs +14 -29
- package/runtime/engine/admission.mjs +3 -3
- package/runtime/engine/ledger-db.mjs +2 -2
- package/runtime/engine/machine-db.mjs +90 -9
- package/runtime/engine/migrations/machine/0002-worktrees-no-workflow-kind.sql +13 -0
- package/runtime/engine/migrations/runtime/0005-ended-workflow-views.sql +93 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +32 -13
- package/runtime/knowledge/hfs/peer-integrations.yaml +18 -0
- package/runtime/knowledge/hfs/slots.yaml +193 -128
- package/runtime/knowledge/patterns/fe/folder.yaml +36 -36
- package/runtime/knowledge/sonar-gate.yaml +8 -7
- package/runtime/modules/kernel/failure-codes.yaml +31 -52
- package/runtime/scripts/checks/architecture/backend.mjs +1 -1
- package/runtime/scripts/checks/architecture/config.mjs +31 -11
- package/runtime/scripts/checks/architecture/contracts.mjs +4 -4
- package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +7 -3
- package/runtime/scripts/checks/architecture/framework-pinned.mjs +5 -47
- package/runtime/scripts/checks/architecture/frontend.mjs +6 -4
- package/runtime/scripts/checks/architecture/hfs-graph.mjs +1 -1
- package/runtime/scripts/checks/architecture/hfs.mjs +105 -67
- package/runtime/scripts/checks/architecture/index.mjs +18 -13
- package/runtime/scripts/checks/architecture/next-data.mjs +3 -2
- package/runtime/scripts/checks/architecture/owners.mjs +12 -6
- package/runtime/scripts/checks/architecture/registration.mjs +1 -1
- package/runtime/scripts/checks/architecture/surface.mjs +91 -0
- package/runtime/scripts/checks/architecture/symbols.mjs +13 -2
- package/runtime/scripts/checks/architecture/test-world-files.mjs +83 -45
- package/runtime/scripts/checks/architecture/typescript.mjs +127 -29
- package/runtime/scripts/checks/typescript-programs.mjs +2 -2
- package/runtime/scripts/lib/hfs-check.mjs +160 -209
- package/runtime/scripts/lib/hfs-path-findings.mjs +95 -0
- package/runtime/scripts/lib/hfs-rules/contract.mjs +15 -42
- package/runtime/scripts/lib/hfs-rules/frontend.mjs +37 -36
- package/runtime/scripts/lib/hfs-rules/peer-integrations.mjs +44 -0
- package/runtime/scripts/lib/hfs-rules/pipeline.mjs +12 -13
- package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +11 -8
- package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +1 -3
- package/runtime/scripts/lib/hfs-slots.mjs +244 -61
- package/runtime/scripts/lib/hfs-view.mjs +9 -7
- package/runtime/scripts/lib/language.mjs +11 -1
- package/runtime/scripts/lib/safe-remove.mjs +95 -10
- package/scaffold/app.mjs +179 -0
- package/scaffold/service.mjs +26 -16
- package/sync/cli.mjs +1 -1
- package/sync/hygiene.mjs +11 -8
- package/sync/index.mjs +109 -111
- package/sync/managed.mjs +9 -8
- package/sync/sonar-key.mjs +20 -22
- package/templates/{be → app}/ci-workflows/github/workflows/ci.yml +5 -11
- package/templates/app/gitignore +6 -0
- package/templates/app/hooks/husky/pre-commit +25 -0
- package/templates/app/hooks/husky/pre-push +7 -0
- package/templates/app/package-scripts/package.json +22 -0
- package/templates/{be → app}/quality-config/sonar-project.properties +4 -3
- package/templates/app/skeleton/.editorconfig +15 -0
- package/templates/app/skeleton/.gitattributes +2 -0
- package/templates/app/skeleton/.nvmrc +1 -0
- package/templates/app/skeleton/.starciwork/features/index.yaml +7 -0
- package/templates/app/skeleton/.starciwork/workspace.yaml +9 -0
- package/templates/app/skeleton/README.md +36 -0
- package/templates/app/skeleton/scripts/codegen.mjs +4 -0
- package/templates/{fe → app}/tool-config/prettierignore +4 -1
- package/templates/be/skeleton/.sops.yaml +2 -0
- package/templates/be/skeleton/.starcistacks/application-stacks.yaml +10 -0
- package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
- package/templates/be/skeleton/apps/__app__/src/app.module.ts +27 -6
- package/templates/be/skeleton/apps/__app__/src/main.ts +4 -1
- package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +2 -0
- package/templates/be/skeleton/src/modules/domain/identity/admission.policy.ts +11 -0
- package/templates/be/skeleton/src/modules/domain/identity/auth.guard.ts +25 -0
- package/templates/be/skeleton/src/modules/domain/identity/errors/identity.error.ts +16 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.contracts.ts +11 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +8 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.module.ts +14 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.options.ts +2 -0
- package/templates/be/skeleton/src/modules/domain/identity/index.ts +6 -0
- package/templates/be/skeleton/src/modules/domain/identity/messages/identity.messages.ts +11 -0
- package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -1
- package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +71 -23
- package/templates/be/skeleton/src/modules/platform/config/errors/config.error.ts +14 -16
- package/templates/be/skeleton/src/modules/platform/config/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/config/server.config.ts +2 -12
- package/templates/be/skeleton/src/modules/platform/errors/domain.error.ts +16 -7
- package/templates/be/skeleton/src/modules/platform/errors/errors/errors.error.ts +16 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.contracts.ts +33 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.decorators.ts +16 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +32 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.module.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.options.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.service.spec.ts +94 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.service.ts +47 -0
- package/templates/be/skeleton/src/modules/platform/errors/http-status.policy.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/errors/index.ts +4 -1
- package/templates/be/skeleton/src/modules/platform/errors/messages/errors.messages.ts +11 -0
- package/templates/be/skeleton/src/modules/platform/http-security/errors/http-security.error.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.config.ts +16 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.decorators.ts +10 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.module.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.options.ts +17 -0
- package/templates/be/skeleton/src/modules/platform/http-security/index.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +31 -0
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +68 -0
- package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.spec.ts +66 -0
- package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.ts +30 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.contracts.ts +18 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.decorators.ts +23 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.module.ts +24 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.options.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.port.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/i18n/index.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.spec.ts +45 -0
- package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/logging/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +85 -68
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +14 -10
- package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +0 -1
- package/templates/be/skeleton/src/modules/platform/primitives/index.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/primitives/outcome.contracts.ts +25 -0
- package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.ts +24 -0
- package/templates/fe/skeleton/apps/__app__/postcss.config.mjs +7 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/error.tsx +5 -17
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +11 -22
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/loading.tsx +6 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +3 -12
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/page.tsx +6 -24
- package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +4 -18
- package/templates/fe/skeleton/apps/__app__/src/app/globals.css +5 -0
- package/templates/fe/skeleton/apps/__app__/src/components/composites/FailureScreen/index.tsx +28 -0
- package/templates/fe/skeleton/apps/__app__/src/features/layouts/LocaleShell/index.tsx +35 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/component.tsx +21 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/index.tsx +17 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/component.tsx +26 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/index.tsx +15 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/component.tsx +25 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/index.tsx +15 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/component.tsx +27 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/index.tsx +8 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/component.tsx +32 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/index.tsx +8 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/config/index.ts +12 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/index.ts +2 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages/vi.json +6 -0
- package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/request.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/routes/index.ts +4 -0
- package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/proxy.ts +1 -1
- package/runtime/scripts/checks/architecture/size-growth.mjs +0 -73
- package/sync/skeleton.mjs +0 -76
- package/templates/be/gitignore +0 -2
- package/templates/be/hooks/husky/pre-commit +0 -13
- package/templates/be/hooks/husky/pre-push +0 -7
- package/templates/be/package-scripts/package.json +0 -22
- package/templates/be/skeleton/scripts/.gitkeep +0 -0
- package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +0 -9
- package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +0 -20
- package/templates/be/tool-config/prettierignore +0 -8
- package/templates/fe/ci-workflows/github/workflows/ci.yml +0 -54
- package/templates/fe/gitignore +0 -3
- package/templates/fe/hooks/husky/pre-commit +0 -16
- package/templates/fe/hooks/husky/pre-push +0 -6
- package/templates/fe/package-scripts/package.json +0 -17
- package/templates/fe/parts/api-client.ts +0 -44
- package/templates/fe/parts/api-outcome.ts +0 -7
- package/templates/fe/quality-config/sonar-project.properties +0 -8
- package/templates/fe/skeleton/scripts/.gitkeep +0 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +0 -1
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +0 -3
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +0 -1
- package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +0 -4
- package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/navigation.ts +0 -5
- package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +0 -12
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +0 -2
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +0 -9
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +0 -5
- package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +0 -5
- package/templates/fe/skeleton-shared/packages/__family__-api/package.json +0 -12
- package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +0 -1
- package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +0 -3
- package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +0 -1
- package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +0 -5
- package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +0 -18
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +0 -19
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +0 -2
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +0 -12
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +0 -15
- package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +0 -5
- package/templates/fe/tool-config/prettierrc +0 -1
- /package/templates/{be → app}/ci-workflows/github/workflows/e2e.yml +0 -0
- /package/templates/{be → app}/starciwork.gitignore +0 -0
- /package/templates/{be → app}/tool-config/prettierrc +0 -0
- /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/next.config.ts +0 -0
- /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/config.ts +0 -0
- /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/routing.ts +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// hfs-check.mjs - the HFS
|
|
2
|
-
// runtime check. It reads three things and nothing else: the
|
|
1
|
+
// hfs-check.mjs - the HFS app check, behind `hfs check | explain` (packages/hfs) and reusable by any
|
|
2
|
+
// runtime check. It reads three things and nothing else: the app's hfs.json, the slot manifest
|
|
3
3
|
// (knowledge/hfs/slots.yaml through scripts/lib/hfs-slots.mjs) and the pins (knowledge/hfs/canon-pins.yaml). It never
|
|
4
4
|
// writes to the repository it inspects.
|
|
5
5
|
//
|
|
6
|
-
// checkRepo() answers, for the tracked paths of one
|
|
6
|
+
// checkRepo() answers, for the tracked paths of one app (git ls-files), per scope (the app root and each side folder):
|
|
7
7
|
// HFS_SLOT_UNDECLARED a tracked path no slot owns (the nearest slot is named)
|
|
8
8
|
// HFS_SLOT_NOT_ENABLED a tracked path in an opt-in slot the repository did not declare
|
|
9
9
|
// HFS_SLOT_AMBIGUOUS two slots of equal specificity own the path (a manifest gap, reported not guessed)
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
// HFS_REPO_LOCAL_CHECK (R103, hfs-rules/repo-local-checks.mjs) a local eslint rule or plugin, a `check-*` script, a relative import in eslint.config
|
|
27
27
|
// HFS_LINT_SUPPRESSION_FILE (R104, hfs-rules/lint-suppression.mjs) an eslint suppressions file, script or option
|
|
28
28
|
// HFS_PROOF_COMMAND_FILE_MISSING (R105, hfs-rules/proof-commands.mjs) a .starciwork proof command that runs a file the repository does not hold
|
|
29
|
+
// HFS_PEER_INTEGRATION_MISSING (R111, hfs-rules/peer-integrations.mjs) the app root package.json lacks the runtime peer a driver integration needs
|
|
29
30
|
// FE_WIRE_GENERATED, FE_I18N_PLACEMENT, FE_I18N_CATALOG (R52, R59, R60, hfs-rules/frontend.mjs) the front-end tree of each app
|
|
30
31
|
// HFS_GITIGNORE_BLOCK_DRIFT, HFS_SONAR_CONFIG (R04, R11) produced by packages/hfs/sync/managed.mjs, which renders the templates
|
|
31
32
|
// HFS_FORMAT (R19) produced by packages/hfs/sync/format.mjs, which runs the repository's own prettier
|
|
@@ -45,22 +46,24 @@ import path from 'node:path';
|
|
|
45
46
|
import { skillRoot } from '../../engine/runtime-root.mjs';
|
|
46
47
|
import { ARCHITECTURE_RULE_IDS, checkArchitecture } from '../checks/architecture/index.mjs';
|
|
47
48
|
import { parseYaml } from '../../engine/yaml.mjs';
|
|
48
|
-
import { HFS_DECLARATION_FILE, HfsSlotsError, createSlotResolver, loadSlotManifest, readRepoDeclaration, resolveRepoDeclaration } from './hfs-slots.mjs';
|
|
49
|
+
import { APP_SCOPE, HFS_DECLARATION_FILE, appRelativeMessages, HfsSlotsError, SIDES, createSlotResolver, loadSlotManifest, readRepoDeclaration, resolveRepoDeclaration } from './hfs-slots.mjs';
|
|
49
50
|
import { allowsFile } from './hfs-allows.mjs';
|
|
50
|
-
import { isDir } from './fs-kind.mjs';
|
|
51
51
|
import { gitOutput } from './git.mjs';
|
|
52
52
|
import { posixPath } from './path-key.mjs';
|
|
53
53
|
import { readTree, treeFacts, untrackedEntries } from './hfs-tree.mjs';
|
|
54
54
|
import { contractFindings } from './hfs-rules/contract.mjs';
|
|
55
55
|
import { depFindings } from './hfs-rules/deps.mjs';
|
|
56
|
-
import { frontendFindings } from './hfs-rules/frontend.mjs';
|
|
56
|
+
import { appFrontendFindings, frontendFindings } from './hfs-rules/frontend.mjs';
|
|
57
57
|
import { lintSuppressionFindings } from './hfs-rules/lint-suppression.mjs';
|
|
58
|
+
import { peerIntegrationFindings } from './hfs-rules/peer-integrations.mjs';
|
|
58
59
|
import { pipelineFindings } from './hfs-rules/pipeline.mjs';
|
|
59
60
|
import { proofCommandFindings } from './hfs-rules/proof-commands.mjs';
|
|
60
61
|
import { repoLocalCheckFindings } from './hfs-rules/repo-local-checks.mjs';
|
|
61
62
|
import { readJson } from './hfs-rules/read.mjs';
|
|
62
|
-
import { secretFindings
|
|
63
|
-
import {
|
|
63
|
+
import { secretFindings } from './hfs-rules/secrets.mjs';
|
|
64
|
+
import { pathFindings } from './hfs-path-findings.mjs';
|
|
65
|
+
import { onLintSurface } from '../checks/architecture/surface.mjs';
|
|
66
|
+
import { checkAppRoot, trackedTreeView } from '../checks/architecture/hfs.mjs';
|
|
64
67
|
import { stacksFindings } from './hfs-rules/stacks.mjs';
|
|
65
68
|
import { testTopologyFindings } from './hfs-rules/test-topology.mjs';
|
|
66
69
|
import { feNoTestsFindings, isFeTestPath } from './hfs-rules/fe-no-tests.mjs';
|
|
@@ -68,11 +71,11 @@ import { feNoTestsFindings, isFeTestPath } from './hfs-rules/fe-no-tests.mjs';
|
|
|
68
71
|
export const CANON_PINS_FILE = 'knowledge/hfs/canon-pins.yaml';
|
|
69
72
|
export const FAILURE_CODES_FILE = 'modules/kernel/failure-codes.yaml';
|
|
70
73
|
/**
|
|
71
|
-
* The codes `hfs check`
|
|
72
|
-
*
|
|
74
|
+
* The codes `hfs check` reports when it cannot judge (an unreadable repository, a refused declaration or manifest, a missing
|
|
75
|
+
* formatter): infrastructure refusals, never obligations, so no rule of knowledge/hfs/rules.yaml owns them.
|
|
73
76
|
*/
|
|
74
77
|
export const REFUSAL_CODES = Object.freeze([
|
|
75
|
-
'
|
|
78
|
+
'HFS_REPO_UNREADABLE',
|
|
76
79
|
'HFS_DECLARATION_INVALID', 'HFS_MANIFEST_MAJOR_MISMATCH', 'HFS_MANIFEST_INVALID', 'HFS_FORMAT_TOOL_MISSING',
|
|
77
80
|
]);
|
|
78
81
|
/** The codes this module emits that are not the slot loader's own: the why bundle of packages/hfs ships exactly these plus the loader's. */
|
|
@@ -81,7 +84,7 @@ export const CHECK_CODES = Object.freeze([
|
|
|
81
84
|
'HFS_SLOT_REQUIRED_MISSING', 'HFS_MIN_INSTANCES', 'HFS_CANON_PIN_DRIFT', 'HFS_SIZE_SOFT_BACKLOG', 'BE_SOURCE_FORM',
|
|
82
85
|
'HFS_MANAGED_FILE_DRIFT', 'HFS_TOOL_CONFIG_LOCAL', 'HFS_RULE_OFF_WITHOUT_REPLACEMENT', 'HFS_TS_STRICT',
|
|
83
86
|
'HFS_PLAINTEXT_SECRET', 'HFS_STACKS_SHAPE', 'HFS_CI_MISSING_CANON', 'HFS_DEP_VERSION_SKEW', 'HFS_CONTRACT_SNAPSHOT_DRIFT',
|
|
84
|
-
'BE_TEST_TOPOLOGY', 'BE_SPEC_PLACEMENT', 'HFS_REPO_LOCAL_CHECK', 'HFS_LINT_SUPPRESSION_FILE', 'HFS_PROOF_COMMAND_FILE_MISSING', 'FE_NO_TESTS', 'FE_WIRE_GENERATED', 'FE_I18N_PLACEMENT', 'FE_I18N_CATALOG',
|
|
87
|
+
'BE_TEST_TOPOLOGY', 'BE_SPEC_PLACEMENT', 'HFS_REPO_LOCAL_CHECK', 'HFS_LINT_SUPPRESSION_FILE', 'HFS_PROOF_COMMAND_FILE_MISSING', 'HFS_PEER_INTEGRATION_MISSING', 'FE_NO_TESTS', 'FE_WIRE_GENERATED', 'FE_I18N_PLACEMENT', 'FE_I18N_CATALOG',
|
|
85
88
|
'HFS_GITIGNORE_BLOCK_DRIFT', 'HFS_SONAR_CONFIG', 'HFS_FORMAT',
|
|
86
89
|
'HFS_EMPTY_DIR', 'HFS_GHOST_TREE', 'HFS_UNTRACKED_ROOT_ENTRY',
|
|
87
90
|
...REFUSAL_CODES,
|
|
@@ -136,7 +139,8 @@ function pinFindings({ repoRoot, files, profile, pins, only }) {
|
|
|
136
139
|
let pkg;
|
|
137
140
|
try { pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, file), 'utf8')); } catch { continue; }
|
|
138
141
|
for (const [name, pin] of Object.entries(pins)) {
|
|
139
|
-
|
|
142
|
+
// The app root's one package.json carries the pins of both sides.
|
|
143
|
+
if (profile !== APP_SCOPE && pin.side !== 'both' && pin.side !== profile) continue;
|
|
140
144
|
for (const section of DEP_SECTIONS) {
|
|
141
145
|
const spec = pkg[section]?.[name];
|
|
142
146
|
if (spec === undefined) continue;
|
|
@@ -148,53 +152,6 @@ function pinFindings({ repoRoot, files, profile, pins, only }) {
|
|
|
148
152
|
return findings;
|
|
149
153
|
}
|
|
150
154
|
|
|
151
|
-
const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
152
|
-
const SOURCE_ROOT = /^(?:src|apps)\//;
|
|
153
|
-
const FREE_NAMES = new Set(['index.ts', 'main.ts']);
|
|
154
|
-
const PLAIN_ENTRY = /^<[a-z][a-z0-9-]*>.ts$/;
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* BE_SOURCE_FORM (R89): every tracked src/ or apps/ TypeScript file of a back end is index.ts, main.ts, a migration of
|
|
158
|
-
* be.persistence, or <kebab-name>.<suffix>.ts with <suffix> in the closed vocabulary ruleParams.be.suffixes (a name such
|
|
159
|
-
* as api.composition.spec.ts keeps its inner words kebab-case). A suffix of ruleParams.be.bannedSuffixes anywhere in the
|
|
160
|
-
* name is refused by name. Paths no slot owns are HFS_SLOT_UNDECLARED's, not this code's.
|
|
161
|
-
*/
|
|
162
|
-
function sourceFormFindings({ files, resolver }) {
|
|
163
|
-
const { suffixes, bannedSuffixes } = resolver.ruleParams();
|
|
164
|
-
// A suffix a slot names in its own file pattern (`*.builder.ts` of be.tests.fixtures.builders) is that slot's role: a file with it
|
|
165
|
-
// anywhere else is refused, so a builder cannot live beside a service or in the fixtures root.
|
|
166
|
-
const boundSuffixes = new Map();
|
|
167
|
-
for (const slot of resolver.slots()) {
|
|
168
|
-
const bound = /\*\.([a-z0-9-]+)\.ts$/.exec(slot.path ?? '')?.[1];
|
|
169
|
-
if (bound && suffixes.includes(bound)) boundSuffixes.set(bound, slot);
|
|
170
|
-
}
|
|
171
|
-
const findings = [];
|
|
172
|
-
for (const file of files) {
|
|
173
|
-
if (!file.endsWith('.ts') || !SOURCE_ROOT.test(file)) continue;
|
|
174
|
-
const c = resolver.classifyPath(file);
|
|
175
|
-
if (c.status !== 'owned' || c.tracking === 'ignored') continue;
|
|
176
|
-
const base = path.posix.basename(file);
|
|
177
|
-
if (FREE_NAMES.has(base)) continue;
|
|
178
|
-
// A literal file name the owning slot itself requires or allows (persistence/connection.ts, world/global-setup.ts) is its role.
|
|
179
|
-
const slot = resolver.slot(c.slot);
|
|
180
|
-
if ([...(slot?.requires ?? []), ...(slot?.allows ?? [])].some((entry) => entry === base)) continue;
|
|
181
|
-
// A slot whose `allows` holds a bare <name>.ts entry (be.tests.world.kit) names its files plainly, as platform/primitives does: kebab-case is the whole form.
|
|
182
|
-
const admitted = allowsFile(resolver, file);
|
|
183
|
-
if (admitted?.allowed && PLAIN_ENTRY.test(admitted.entry ?? '') && KEBAB.test(base.slice(0, -'.ts'.length))) continue;
|
|
184
|
-
if (c.slot === 'be.persistence' && path.posix.basename(path.posix.dirname(file)) === 'migrations') continue;
|
|
185
|
-
const parts = base.slice(0, -'.ts'.length).split('.');
|
|
186
|
-
const banned = parts.slice(1).find((part) => bannedSuffixes.includes(part));
|
|
187
|
-
if (banned) {
|
|
188
|
-
findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: banned, message: `${file}: the suffix .${banned} is banned; use a role from the closed suffix list (${suffixes.join(', ')})` });
|
|
189
|
-
} else if (boundSuffixes.has(parts.at(-1)) && parts.length >= 2 && boundSuffixes.get(parts.at(-1)).id !== c.slot) {
|
|
190
|
-
findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: parts.at(-1), message: `${file}: the suffix .${parts.at(-1)}.ts belongs to ${boundSuffixes.get(parts.at(-1)).path} only; move the file there` });
|
|
191
|
-
} else if (parts.length < 2 || !parts.every((part) => KEBAB.test(part)) || !suffixes.includes(parts.at(-1))) {
|
|
192
|
-
findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, message: `${file}: the name must be <kebab-name>.<suffix>.ts with a suffix from the closed list (${suffixes.join(', ')}), or index.ts, main.ts or a migration` });
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
return findings;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
155
|
/** Instances (slot, root, bindings) present in the tracked tree, for every slot that names required files or a minimum. */
|
|
199
156
|
function instancesOf(resolver, files) {
|
|
200
157
|
const found = new Map();
|
|
@@ -257,53 +214,24 @@ function treeFindings({ repoRoot, resolver }) {
|
|
|
257
214
|
return findings;
|
|
258
215
|
}
|
|
259
216
|
|
|
217
|
+
/** A side-relative path (or null) as an app-relative one. */
|
|
218
|
+
const onSide = (side, p) => (p ? path.posix.normalize(`${side}/${p}`) : p);
|
|
219
|
+
|
|
260
220
|
/**
|
|
261
|
-
* The
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
* over `files`, which is a dry run. `extraFindings` are findings another emitter produced for the same repository (the
|
|
266
|
-
* managed files), judged and counted with this module's own. Returns {ok, profile, apps, manifest, tracked, findings,
|
|
267
|
-
* counts}; a missing or invalid hfs.json is one HFS_DECLARATION_INVALID / HFS_MANIFEST_MAJOR_MISMATCH error finding, never
|
|
268
|
-
* an exception.
|
|
221
|
+
* The findings of one scope: the app root (profile app; its own files, and the rules of the files only the root holds: the one
|
|
222
|
+
* package.json and lockfile, CI, hooks, .starciwork) or one side (profile be or fe; the side folder is `repoRoot` and every path is
|
|
223
|
+
* relative to it, exactly as the standalone repository root was). `files` are the scope's tracked paths; `all` (root only) every
|
|
224
|
+
* tracked path of the app, for the rules that read across it (dependency skew, proof commands).
|
|
269
225
|
*/
|
|
270
|
-
|
|
271
|
-
const why = readWhy(root);
|
|
272
|
-
let repo;
|
|
273
|
-
try {
|
|
274
|
-
repo = declaration === undefined ? readRepoDeclaration(manifest, repoRoot) : resolveRepoDeclaration(manifest, declaration);
|
|
275
|
-
} catch (error) {
|
|
276
|
-
if (!(error instanceof HfsSlotsError) || !['HFS_DECLARATION_INVALID', 'HFS_MANIFEST_MAJOR_MISMATCH'].includes(error.code)) throw error;
|
|
277
|
-
const findings = withWhy([{ code: error.code, level: 'error', path: HFS_DECLARATION_FILE, message: error.message.replace(/^[A-Z_]+: /, ''), problems: error.details.problems }], why);
|
|
278
|
-
return { ok: false, repoRoot, manifest: manifest.version, profile: null, apps: [], tracked: 0, findings, counts: summarize(findings) };
|
|
279
|
-
}
|
|
280
|
-
const resolver = createSlotResolver(manifest, repo);
|
|
281
|
-
const tracked = files ?? trackedFiles(repoRoot);
|
|
282
|
-
const trackedSet = new Set(tracked);
|
|
283
|
-
const present = (p) => (p.endsWith('/') ? tracked.some((f) => f.startsWith(p)) : trackedSet.has(p));
|
|
284
|
-
const findings = [];
|
|
285
|
-
const scoped = only ? new Set(only) : null;
|
|
226
|
+
function scopeFindings({ repoRoot, root, repo, resolver, files, all = files, scoped }) {
|
|
286
227
|
const inScope = (file) => !scoped || scoped.has(file);
|
|
228
|
+
// A required directory of the root (be/, fe/) is present through the files below it, which are the sides' own.
|
|
229
|
+
const trackedSet = new Set(all);
|
|
230
|
+
const present = (p) => (p.endsWith('/') ? all.some((f) => f.startsWith(p)) : trackedSet.has(p));
|
|
231
|
+
const findings = [];
|
|
232
|
+
const isRoot = repo.profile === APP_SCOPE;
|
|
287
233
|
|
|
288
|
-
|
|
289
|
-
if (!inScope(file)) continue;
|
|
290
|
-
if (repo.profile === 'fe' && isFeTestPath(file)) continue; // a test path of a front end is FE_NO_TESTS's, the one finding of that file
|
|
291
|
-
const c = resolver.classifyPath(file);
|
|
292
|
-
if (c.status === 'no-slot') {
|
|
293
|
-
findings.push({ code: 'HFS_SLOT_UNDECLARED', level: 'error', path: file, nearest: c.nearest, message: `${file} matches no slot${c.nearest ? `; nearest slot ${c.nearest.slot} (${c.nearest.pattern}), matched ${c.nearest.matchedPrefix || '.'} then expected ${c.nearest.expectedNext ?? 'nothing'}` : ''}` });
|
|
294
|
-
} else if (c.status === 'ambiguous') {
|
|
295
|
-
findings.push({ code: 'HFS_SLOT_AMBIGUOUS', level: 'error', path: file, candidates: c.candidates, message: `${file} is owned equally by ${c.candidates.map((x) => x.slot ?? x).join(', ')}` });
|
|
296
|
-
} else if (c.status === 'not-enabled') {
|
|
297
|
-
findings.push({ code: 'HFS_SLOT_NOT_ENABLED', level: 'error', path: file, slot: c.slot, message: `${file} belongs to ${c.slot}, an opt-in slot hfs.json neither lists in optionalSlots nor implies through an app kind` });
|
|
298
|
-
} else if (c.status === 'forbidden') {
|
|
299
|
-
const slot = resolver.slot(c.slot);
|
|
300
|
-
if (slotOwnsSecrets(slot)) continue; // the secret scan reports the file (R06): one finding per file
|
|
301
|
-
const own = slot.rules?.includes('HFS_TOOL_CONFIG_LOCAL') ? 'HFS_TOOL_CONFIG_LOCAL' : 'HFS_FORBIDDEN_PRESENT';
|
|
302
|
-
findings.push({ code: own, level: 'error', path: file, slot: c.slot, goesTo: c.goesTo, message: `${file} is tracked but ${c.slot} is forbidden in the tree${c.goesTo ? `; it belongs at ${c.goesTo}` : ''}` });
|
|
303
|
-
} else if (c.tracking === 'ignored') {
|
|
304
|
-
findings.push({ code: 'HFS_TRACKED_MUST_BE_IGNORED', level: 'error', path: file, slot: c.slot, message: `${file} is tracked but ${c.slot} must be gitignored` });
|
|
305
|
-
}
|
|
306
|
-
}
|
|
234
|
+
findings.push(...pathFindings({ files: files.filter(inScope), resolver, profile: repo.profile }));
|
|
307
235
|
|
|
308
236
|
const required = resolver.requiredPaths();
|
|
309
237
|
const missing = new Set();
|
|
@@ -314,46 +242,104 @@ export function checkRepo({ repoRoot, root = skillRoot, declaration, files, only
|
|
|
314
242
|
const app = appOf(p);
|
|
315
243
|
findings.push({ code: 'HFS_SLOT_REQUIRED_MISSING', level: 'error', path: p, slot, via, ...(app ? { app } : {}), message: `${slot} requires ${p}${app ? ` (app ${app})` : ''}, which is not tracked` });
|
|
316
244
|
};
|
|
317
|
-
|
|
318
|
-
const
|
|
245
|
+
// The app's own required paths; each side reports its own (the resolver of the app lists them too, with their side).
|
|
246
|
+
for (const entry of required.paths) if (!entry.side) missingFile(entry.slot, entry.path, entry.via);
|
|
247
|
+
const instances = instancesOf(resolver, files);
|
|
319
248
|
for (const instance of instances) for (const p of requiredOf(resolver.slot(instance.slot), instance)) missingFile(instance.slot, p, 'requires');
|
|
320
|
-
for (const { slot, min, appKind } of required.minimums) {
|
|
321
|
-
if (appKind !== undefined) continue; // an app-kind minimum is checked by requiredPaths (one path set per declared app)
|
|
249
|
+
for (const { slot, min, appKind, side } of required.minimums) {
|
|
250
|
+
if (appKind !== undefined || side) continue; // an app-kind minimum is checked by requiredPaths (one path set per declared app)
|
|
322
251
|
const count = instances.filter((i) => i.slot === slot).length;
|
|
323
252
|
if (count < min) findings.push({ code: 'HFS_MIN_INSTANCES', level: 'error', path: resolver.slot(slot).path, slot, min, count, message: `${slot} needs at least ${min} instance${min === 1 ? '' : 's'} (${resolver.slot(slot).path}), found ${count}` });
|
|
324
253
|
}
|
|
325
254
|
|
|
326
255
|
const pins = readPins(root);
|
|
327
|
-
findings.push(...pinFindings({ repoRoot, files
|
|
256
|
+
findings.push(...pinFindings({ repoRoot, files, profile: repo.profile, pins, only: scoped }));
|
|
328
257
|
|
|
329
|
-
// The tree checks of the rules that read file content or configuration (hfs-rules/*): whole-
|
|
330
|
-
const secrets = secretFindings({ repoRoot, files: tracked.filter(inScope), resolver });
|
|
331
|
-
const declared = declaration === undefined ? readJson(repoRoot, 'hfs.json') : declaration;
|
|
332
|
-
if (repo.profile === 'be') findings.push(...sourceFormFindings({ files: tracked.filter(inScope), resolver }));
|
|
258
|
+
// The tree checks of the rules that read file content or configuration (hfs-rules/*): whole-scope, cheap, no tool run.
|
|
333
259
|
findings.push(
|
|
334
|
-
...
|
|
335
|
-
...
|
|
336
|
-
...
|
|
337
|
-
...contractFindings({ repoRoot, files: tracked, repo, resolver, stacks: declared?.stacks }),
|
|
338
|
-
...repoLocalCheckFindings({ repoRoot, files: tracked }),
|
|
339
|
-
...lintSuppressionFindings({ repoRoot, files: tracked }),
|
|
340
|
-
...(repo.profile === 'be' ? [...stacksFindings({ repoRoot, files: tracked, resolver }), ...testTopologyFindings({ repoRoot, files: tracked }), ...specPlacementFindings({ files: tracked, resolver }), ...proofCommandFindings({ repoRoot, files: tracked, resolver })] : [...frontendFindings({ repoRoot, files: tracked, repo }), ...feNoTestsFindings({ repoRoot, files: tracked.filter(inScope) })]),
|
|
341
|
-
...extraFindings,
|
|
260
|
+
...secretFindings({ repoRoot, files: files.filter(inScope), resolver }),
|
|
261
|
+
...repoLocalCheckFindings({ repoRoot, files }),
|
|
262
|
+
...lintSuppressionFindings({ repoRoot, files }),
|
|
342
263
|
);
|
|
264
|
+
if (isRoot) {
|
|
265
|
+
findings.push(
|
|
266
|
+
...depFindings({ repoRoot, files: all }),
|
|
267
|
+
...peerIntegrationFindings({ repoRoot, files: all }),
|
|
268
|
+
...pipelineFindings({ repoRoot, files, pins }),
|
|
269
|
+
...testTopologyFindings({ repoRoot, files }),
|
|
270
|
+
...proofCommandFindings({ repoRoot, files: all, resolver, sides: Object.keys(repo.sides ?? {}) }),
|
|
271
|
+
...appFrontendFindings({ repoRoot, files: all, repo }),
|
|
272
|
+
// The tree check of the app root the machine runs per side for a side folder: README, root entries, automatic gates, hooks path.
|
|
273
|
+
...checkAppRoot({ root: repoRoot, resolver, tree: trackedTreeView(all) }).violations.map((item) => ({ code: item.ruleId, level: 'error', path: item.path, line: item.line, column: item.column, source: 'machine', message: `${item.path}: ${item.message}` })),
|
|
274
|
+
);
|
|
275
|
+
} else if (repo.profile === 'be') {
|
|
276
|
+
findings.push(...contractFindings({ files, repo, resolver }), ...stacksFindings({ repoRoot, files, resolver }), ...testTopologyFindings({ repoRoot, files }));
|
|
277
|
+
} else {
|
|
278
|
+
findings.push(...frontendFindings({ repoRoot, files, repo }), ...feNoTestsFindings({ repoRoot, files: files.filter(inScope) }));
|
|
279
|
+
}
|
|
343
280
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
281
|
+
if (!isRoot) {
|
|
282
|
+
const soft = resolver.ruleParams().fileLines.soft;
|
|
283
|
+
for (const file of files) {
|
|
284
|
+
if (!inScope(file) || !SOURCE_EXT.test(file) || resolver.classifyPath(file).status !== 'owned') continue;
|
|
285
|
+
let lines;
|
|
286
|
+
try { lines = fs.readFileSync(path.join(repoRoot, file), 'utf8').split('\n').length; } catch { continue; }
|
|
287
|
+
if (lines > soft) findings.push({ code: 'HFS_SIZE_SOFT_BACKLOG', level: 'info', path: file, lines, soft, message: `${file} has ${lines} lines, above the soft size ${soft}; report only` });
|
|
288
|
+
}
|
|
350
289
|
}
|
|
290
|
+
return findings;
|
|
291
|
+
}
|
|
351
292
|
|
|
293
|
+
/**
|
|
294
|
+
* The check of one app. `repoRoot` is the app root (a side folder is checked as that side alone). `declaration` overrides hfs.json
|
|
295
|
+
* (a dry run over an app that has none); `files` overrides git ls-files (specs), app-relative. `only` (a list of app-relative paths)
|
|
296
|
+
* limits the per-path checks (slot, pin, size) to those paths; the checks of the tree as a whole (required files, minimum instances,
|
|
297
|
+
* empty directories, untracked entries) are not per-path. `tree: false` skips the file-system checks (empty directories, ghosts,
|
|
298
|
+
* untracked); they also do not run over `files`, which is a dry run. `extraFindings` are findings another emitter produced for the
|
|
299
|
+
* same app (the managed files, the formatter, the contract emit), app-relative, judged and counted with this module's own.
|
|
300
|
+
* The root scope and each side run their own rules over their own files (hfs checks per side, the side folder as the old repository
|
|
301
|
+
* root, plus the root checks); every finding path is app-relative. Returns {ok, profile, apps, manifest, tracked, findings, counts};
|
|
302
|
+
* a missing or invalid hfs.json is one HFS_DECLARATION_INVALID / HFS_MANIFEST_MAJOR_MISMATCH error finding, never an exception.
|
|
303
|
+
*/
|
|
304
|
+
export function checkRepo({ repoRoot, root = skillRoot, declaration, files, only, extraFindings = [], tree = files === undefined, manifest = loadSlotManifest({ root }), surface = 'all' }) {
|
|
305
|
+
const why = readWhy(root);
|
|
306
|
+
let repo;
|
|
307
|
+
try {
|
|
308
|
+
repo = declaration === undefined ? readRepoDeclaration(manifest, repoRoot) : resolveRepoDeclaration(manifest, declaration);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (!(error instanceof HfsSlotsError) || !['HFS_DECLARATION_INVALID', 'HFS_MANIFEST_MAJOR_MISMATCH'].includes(error.code)) throw error;
|
|
311
|
+
const findings = withWhy([{ code: error.code, level: 'error', path: HFS_DECLARATION_FILE, message: error.message.replace(/^[A-Z_]+: /, ''), problems: error.details.problems }], why);
|
|
312
|
+
return { ok: false, repoRoot, manifest: manifest.version, profile: null, apps: [], tracked: 0, findings, counts: summarize(findings) };
|
|
313
|
+
}
|
|
314
|
+
const resolver = createSlotResolver(manifest, repo);
|
|
315
|
+
const tracked = files ?? trackedFiles(repoRoot);
|
|
316
|
+
const scoped = only ? new Set(only) : null;
|
|
317
|
+
const findings = [];
|
|
318
|
+
// `hfs check` leaves to the lint canon the per-path findings that sit on an existing TypeScript file (hfs-path-findings.mjs).
|
|
319
|
+
const keep = (list, scopeRoot) => (surface === 'check' ? list.filter((finding) => !(finding.origin === 'repo' && onLintSurface(scopeRoot, finding))) : list);
|
|
320
|
+
if (repo.profile === APP_SCOPE) {
|
|
321
|
+
const own = tracked.filter((file) => resolver.sideOf(file) === null);
|
|
322
|
+
findings.push(...keep(scopeFindings({ repoRoot, root, repo, resolver, files: own, all: tracked, scoped }), repoRoot));
|
|
323
|
+
for (const side of SIDES) {
|
|
324
|
+
const prefix = `${side}/`;
|
|
325
|
+
const sideRoot = path.join(repoRoot, side);
|
|
326
|
+
const sideFiles = tracked.filter((file) => file.startsWith(prefix)).map((file) => file.slice(prefix.length));
|
|
327
|
+
const sideScoped = scoped ? new Set([...scoped].filter((file) => file.startsWith(prefix)).map((file) => file.slice(prefix.length))) : null;
|
|
328
|
+
const sideFindings = scopeFindings({ repoRoot: sideRoot, root, repo: repo.sides[side], resolver: resolver.sides[side], files: sideFiles, scoped: sideScoped });
|
|
329
|
+
const message = appRelativeMessages(side, sideRoot);
|
|
330
|
+
findings.push(...keep(sideFindings, sideRoot).map((finding) => ({ ...finding, side, path: onSide(side, finding.path), message: message(finding.message) })));
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
findings.push(...keep(scopeFindings({ repoRoot, root, repo, resolver, files: tracked, scoped }), repoRoot));
|
|
334
|
+
}
|
|
335
|
+
findings.push(...extraFindings);
|
|
352
336
|
if (tree) findings.push(...treeFindings({ repoRoot, resolver }));
|
|
353
337
|
|
|
354
|
-
|
|
338
|
+
// The app-root tree check reports machine codes: their why is read with the check's own.
|
|
339
|
+
const finished = withWhy(findings, { ...why, ...readWhy(root, [...new Set(findings.map((f) => f.code).filter((code) => !why[code]))]) });
|
|
355
340
|
const counts = summarize(finished);
|
|
356
|
-
|
|
341
|
+
const apps = repo.profile === APP_SCOPE ? SIDES.flatMap((side) => repo.sides[side].apps.map((app) => ({ ...app, side }))) : repo.apps;
|
|
342
|
+
return { ok: counts.error === 0, repoRoot, manifest: manifest.version, profile: repo.profile, apps, tracked: tracked.length, findings: finished, counts };
|
|
357
343
|
}
|
|
358
344
|
|
|
359
345
|
// ------------------------------------------------------------------------------------------ the whole check
|
|
@@ -390,45 +376,66 @@ function machineFindings(report) {
|
|
|
390
376
|
}
|
|
391
377
|
|
|
392
378
|
/**
|
|
393
|
-
* The whole `hfs check` of one
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
* paths, the machine on the owners of the changed source files
|
|
397
|
-
* checks. `extraFindings` are the findings of the emitters that render
|
|
398
|
-
* (packages/hfs/sync), judged with checkRepo's own. `machine` is injectable for specs. A missing
|
|
379
|
+
* The whole `hfs check` of one app: checkRepo() (the root and both sides: slots, pins, size, tree) and then the architecture machine
|
|
380
|
+
* over each side folder (the side as the repository root it judges), its violations and errors merged in as findings with the
|
|
381
|
+
* Vietnamese why of their codes and the side prefixed onto their paths. `fast` judges only what changed since the merge-base (`base`
|
|
382
|
+
* names another ref): the per-path slot and pin checks on the changed paths, the machine on the owners of the changed source files
|
|
383
|
+
* without clones and dead exports, and no file-system tree checks. `extraFindings` are the findings of the emitters that render
|
|
384
|
+
* templates or run the formatter (packages/hfs/sync), judged with checkRepo's own. `machine` is injectable for specs. A missing
|
|
385
|
+
* merge-base under `fast` is an Error, never a silent full pass. `repoRoot` may also be a side folder: that side alone is checked.
|
|
399
386
|
*/
|
|
400
387
|
export function checkRepository({ repoRoot, root = skillRoot, fast = false, base, extraFindings = [], manifest = loadSlotManifest({ root }), machine = checkArchitecture }) {
|
|
401
388
|
const changed = fast ? changedSince(repoRoot, base) : null;
|
|
402
|
-
const
|
|
403
|
-
const slotResult = checkRepo({ repoRoot, root, manifest, extraFindings, ...(changed ? { only: changed.files, tree: false } : {}) });
|
|
389
|
+
const slotResult = checkRepo({ repoRoot, root, manifest, extraFindings, surface: 'check', ...(changed ? { only: changed.files, tree: false } : {}) });
|
|
404
390
|
if (slotResult.profile === null) return { ...slotResult, machine: { status: 'skipped', reason: 'hfs.json is not valid' } };
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
} catch (error) {
|
|
416
|
-
report = { ok: false, files: 0, kinds: [], violations: [], errors: [{ ruleId: 'ARCH_EXECUTION_UNAVAILABLE', message: String(error?.message ?? error) }] };
|
|
391
|
+
const repo = readRepoDeclaration(manifest, repoRoot);
|
|
392
|
+
const scopes = repo.profile === APP_SCOPE
|
|
393
|
+
? SIDES.map((side) => ({ side, repoRoot: path.join(repoRoot, side), prefix: `${side}/` }))
|
|
394
|
+
: [{ side: null, repoRoot, prefix: '' }];
|
|
395
|
+
const runs = [];
|
|
396
|
+
const found = [];
|
|
397
|
+
for (const scope of scopes) {
|
|
398
|
+
const run = machineOver({ scope, manifest, machine, changed });
|
|
399
|
+
runs.push(run.info);
|
|
400
|
+
found.push(...run.findings);
|
|
417
401
|
}
|
|
418
|
-
const found = machineFindings(report);
|
|
419
402
|
const why = readWhy(root, [...new Set(found.map((f) => f.code))]);
|
|
420
403
|
const findings = [...slotResult.findings, ...withWhy(found, why)];
|
|
421
404
|
const counts = summarize(findings);
|
|
405
|
+
const ran = runs.filter((info) => info.status === 'ran');
|
|
422
406
|
return {
|
|
423
407
|
...slotResult,
|
|
424
408
|
ok: counts.error === 0,
|
|
425
409
|
findings,
|
|
426
410
|
counts,
|
|
427
|
-
machine:
|
|
411
|
+
machine: ran.length
|
|
412
|
+
? { status: 'ran', files: ran.reduce((sum, info) => sum + info.files, 0), kinds: [...new Set(ran.flatMap((info) => info.kinds ?? []))], sides: runs, ...(changed ? { paths: ran.flatMap((info) => info.paths ?? []), base: changed.base } : {}) }
|
|
413
|
+
: { status: 'skipped', reason: runs.map((info) => info.reason).join('; '), sides: runs, ...(changed ? { base: changed.base } : {}) },
|
|
428
414
|
...(changed ? { fast: { base: changed.base, changed: changed.files.length } } : {}),
|
|
429
415
|
};
|
|
430
416
|
}
|
|
431
417
|
|
|
418
|
+
/** The architecture machine over one scope (a side folder): `{ info, findings }`, finding paths app-relative. */
|
|
419
|
+
function machineOver({ scope, manifest, machine, changed }) {
|
|
420
|
+
const { side, prefix } = scope;
|
|
421
|
+
const label = side ?? 'repository';
|
|
422
|
+
let paths;
|
|
423
|
+
if (changed) {
|
|
424
|
+
const resolver = createSlotResolver(manifest, readRepoDeclaration(manifest, scope.repoRoot));
|
|
425
|
+
const mine = changed.files.filter((f) => f.startsWith(prefix)).map((f) => f.slice(prefix.length));
|
|
426
|
+
paths = [...new Set(mine.filter((f) => SOURCE_EXT.test(f) || resolver.ownerOf(f)).map((f) => resolver.ownerOf(f)?.root ?? f))].sort();
|
|
427
|
+
if (!paths.length) return { info: { side, status: 'skipped', reason: `no changed source file in ${label}` }, findings: [] };
|
|
428
|
+
}
|
|
429
|
+
let report;
|
|
430
|
+
try {
|
|
431
|
+
report = machine({ repositoryRoot: scope.repoRoot, base: changed?.base, surface: 'check', ...(changed ? { paths, fast: true } : {}) });
|
|
432
|
+
} catch (error) {
|
|
433
|
+
report = { ok: false, files: 0, kinds: [], violations: [], errors: [{ ruleId: 'ARCH_EXECUTION_UNAVAILABLE', message: String(error?.message ?? error) }] };
|
|
434
|
+
}
|
|
435
|
+
const message = side ? appRelativeMessages(side, scope.repoRoot) : null;
|
|
436
|
+
const findings = machineFindings(report).map((finding) => (side ? { ...finding, side, message: message(finding.message), ...(finding.path ? { path: onSide(side, finding.path) } : {}) } : finding));
|
|
437
|
+
return { info: { side, status: 'ran', files: report.files, kinds: report.kinds, ...(paths ? { paths: paths.map((p) => `${prefix}${p}`) } : {}) }, findings };
|
|
438
|
+
}
|
|
432
439
|
// --------------------------------------------------------------------------------------------------- explain
|
|
433
440
|
|
|
434
441
|
const TEST_KIND = {
|
|
@@ -450,7 +457,9 @@ export function explainPath({ repoRoot, input, root = skillRoot, declaration, ma
|
|
|
450
457
|
const slot = resolver.slot(c.slot);
|
|
451
458
|
const tier = resolver.tierOf(c.path);
|
|
452
459
|
const owner = resolver.ownerOf(c.path);
|
|
453
|
-
|
|
460
|
+
// A path of a side imports by its side's direction matrix; the app root has none of its own.
|
|
461
|
+
const scope = c.side ? resolver.sides[c.side] : resolver;
|
|
462
|
+
const mayImport = tier && tier !== 'none' ? scope.allowedImports(tier) : null;
|
|
454
463
|
return {
|
|
455
464
|
path: c.path,
|
|
456
465
|
status: c.status,
|
|
@@ -471,61 +480,3 @@ export function explainPath({ repoRoot, input, root = skillRoot, declaration, ma
|
|
|
471
480
|
...(c.status === 'not-enabled' ? { code: 'HFS_SLOT_NOT_ENABLED', titleVi: why.HFS_SLOT_NOT_ENABLED.titleVi, whyVi: why.HFS_SLOT_NOT_ENABLED.whyVi } : {}),
|
|
472
481
|
};
|
|
473
482
|
}
|
|
474
|
-
|
|
475
|
-
// ------------------------------------------------------------------------------------------------- init
|
|
476
|
-
|
|
477
|
-
const SLUG_SUFFIX = /-(backend|be|frontend|fe|api|web|app)$/;
|
|
478
|
-
const exists = (p) => fs.existsSync(p);
|
|
479
|
-
const readPackage = (dir) => { try { return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { return null; } };
|
|
480
|
-
const depsOf = (pkg) => ({ ...pkg?.devDependencies, ...pkg?.dependencies });
|
|
481
|
-
|
|
482
|
-
/**
|
|
483
|
-
* A starter hfs.json by detection: profile from the dependencies (next -> fe, @nestjs/core -> be, over the root and
|
|
484
|
-
* every apps/<name>), apps from the apps/<name> directories (fe: kind next; be: worker/migrate/cli by name, else api).
|
|
485
|
-
* optionalSlots are the opt-in slots (not implied by an app kind) that tracked files already occupy. Connections are not guessed; a repository that keeps a database declares them by hand and gains the migrate app.
|
|
486
|
-
* A repository the detection cannot classify is HFS_INIT_UNDETECTED, never a guess.
|
|
487
|
-
*/
|
|
488
|
-
export function detectDeclaration({ repoRoot, manifest }) {
|
|
489
|
-
const appsDir = path.join(repoRoot, 'apps');
|
|
490
|
-
const names = isDir(appsDir) ? fs.readdirSync(appsDir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== 'node_modules').map((e) => e.name).sort() : [];
|
|
491
|
-
const rootPkg = readPackage(repoRoot);
|
|
492
|
-
const all = { ...depsOf(rootPkg) };
|
|
493
|
-
for (const name of names) Object.assign(all, depsOf(readPackage(path.join(appsDir, name))));
|
|
494
|
-
const isFe = 'next' in all || names.some((n) => ['next.config.ts', 'next.config.js', 'next.config.mjs'].some((f) => exists(path.join(appsDir, n, f))));
|
|
495
|
-
const isBe = '@nestjs/core' in all || exists(path.join(repoRoot, 'nest-cli.json'));
|
|
496
|
-
if (isFe === isBe) refuse('HFS_INIT_UNDETECTED', `cannot tell the profile of ${repoRoot}: ${isFe ? 'both next and Nest are present' : 'neither next nor @nestjs/core is declared'}`, { repoRoot });
|
|
497
|
-
const profile = isFe ? 'fe' : 'be';
|
|
498
|
-
const apps = names.map((name) => {
|
|
499
|
-
if (profile === 'fe') return { name, kind: 'next' };
|
|
500
|
-
if (!isDir(path.join(appsDir, name, 'src'))) return null;
|
|
501
|
-
const kind = /migrat/.test(name) ? 'migrate' : (/worker/.test(name) ? 'worker' : (/(^|-)cli($|-)/.test(name) ? 'cli' : 'api'));
|
|
502
|
-
return { name, kind };
|
|
503
|
-
}).filter(Boolean).filter((a) => manifest.appKinds[profile].includes(a.kind));
|
|
504
|
-
if (!apps.length) refuse('HFS_INIT_UNDETECTED', `${repoRoot} has no apps/<name> ${profile === 'fe' ? 'Next application' : 'Nest application with a src directory'} to declare`, { repoRoot, profile });
|
|
505
|
-
const project = String(rootPkg?.name ?? path.basename(repoRoot)).replace(/^@[^/]+\//, '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').replace(SLUG_SUFFIX, '');
|
|
506
|
-
const declaration = { hfs: manifest.major, profile, project: /^[a-z]/.test(project) ? project : `p-${project}`, apps };
|
|
507
|
-
const optionalSlots = occupiedOptInSlots({ repoRoot, manifest, declaration });
|
|
508
|
-
return optionalSlots.length ? { ...declaration, optionalSlots } : declaration;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
/** The opt-in slots (not enabled by an app kind) that at least one tracked file falls in, found by enabling them all for a look. */
|
|
512
|
-
function occupiedOptInSlots({ repoRoot, manifest, declaration }) {
|
|
513
|
-
let files;
|
|
514
|
-
try { files = trackedFiles(repoRoot); } catch (error) { if (error.code === 'HFS_REPO_UNREADABLE') return []; throw error; }
|
|
515
|
-
const optIn = manifest.slots.filter((s) => s.profiles.includes(declaration.profile) && s.presence === 'opt-in' && s.appKind === undefined).map((s) => s.id);
|
|
516
|
-
const everything = createSlotResolver(manifest, resolveRepoDeclaration(manifest, { ...declaration, optionalSlots: optIn }));
|
|
517
|
-
const used = new Set();
|
|
518
|
-
for (const file of files) { const c = everything.classifyPath(file); if (c.status === 'owned' && optIn.includes(c.slot)) used.add(c.slot); }
|
|
519
|
-
return optIn.filter((id) => used.has(id));
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
/** Write hfs.json unless one exists (HFS_INIT_EXISTS); `write: false` returns the text only. */
|
|
523
|
-
export function initRepo({ repoRoot, root = skillRoot, write = true, manifest = loadSlotManifest({ root }) }) {
|
|
524
|
-
const file = path.join(repoRoot, HFS_DECLARATION_FILE);
|
|
525
|
-
if (write && exists(file)) refuse('HFS_INIT_EXISTS', `${file} already exists; init never rewrites a declaration`, { file });
|
|
526
|
-
const declaration = detectDeclaration({ repoRoot, manifest });
|
|
527
|
-
resolveRepoDeclaration(manifest, declaration);
|
|
528
|
-
const text = `${JSON.stringify(declaration, null, 2)}\n`;
|
|
529
|
-
if (write) fs.writeFileSync(file, text);
|
|
530
|
-
return { file, declaration, text, written: write };
|
|
531
|
-
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// hfs-path-findings.mjs - the per-path judgements of the slot manifest: what a tracked path is. One function, two surfaces: `hfs check`
|
|
2
|
+
// (checkRepo) runs it over the tracked tree and keeps the findings that have no TypeScript file to sit on; the lint canon's project graph
|
|
3
|
+
// (scripts/lib/project-graph.mjs) runs it over the same tree and serves the findings on a TypeScript file as ESLint reports.
|
|
4
|
+
// HFS_SLOT_UNDECLARED / HFS_SLOT_AMBIGUOUS / HFS_SLOT_NOT_ENABLED / HFS_FORBIDDEN_PRESENT / HFS_TOOL_CONFIG_LOCAL / HFS_TRACKED_MUST_BE_IGNORED (R01, R16)
|
|
5
|
+
// BE_SOURCE_FORM (R89, the file name), BE_SPEC_PLACEMENT (R102)
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { allowsFile } from './hfs-allows.mjs';
|
|
8
|
+
import { isFeTestPath } from './hfs-rules/fe-no-tests.mjs';
|
|
9
|
+
import { slotOwnsSecrets } from './hfs-rules/secrets.mjs';
|
|
10
|
+
import { specPlacementFindings } from './hfs-rules/spec-placement.mjs';
|
|
11
|
+
import { APP_SCOPE } from './hfs-slots.mjs';
|
|
12
|
+
|
|
13
|
+
const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
14
|
+
const SOURCE_ROOT = /^(?:src|apps)\//;
|
|
15
|
+
const FREE_NAMES = new Set(['index.ts', 'main.ts']);
|
|
16
|
+
const PLAIN_ENTRY = /^<[a-z][a-z0-9-]*>.ts$/;
|
|
17
|
+
/**
|
|
18
|
+
* Where the work of a banned data-access suffix goes. The suffix is banned by the manifest (ruleParams.be.bannedSuffixes);
|
|
19
|
+
* this only adds the convention's home to the finding, so a file that is a repository or a store is told what replaces it.
|
|
20
|
+
*/
|
|
21
|
+
const DATA_ACCESS_HOME = 'SQL text is a constant in <name>.sql.ts of the capability persistence/ folder, and data access is the capability *.service.ts (or the application *.handler.ts) calling the shared EntityManager through its Inject<Conn>EntityManager()';
|
|
22
|
+
const BANNED_SUFFIX_HOME = Object.freeze({ repository: DATA_ACCESS_HOME, store: DATA_ACCESS_HOME });
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* BE_SOURCE_FORM (R89): every tracked src/ or apps/ TypeScript file of a back end is index.ts, main.ts, a migration of
|
|
26
|
+
* be.persistence, or <kebab-name>.<suffix>.ts with <suffix> in the closed vocabulary ruleParams.be.suffixes (a name such
|
|
27
|
+
* as order.service.spec.ts keeps its inner words kebab-case). A suffix of ruleParams.be.bannedSuffixes anywhere in the
|
|
28
|
+
* name is refused by name. Paths no slot owns are HFS_SLOT_UNDECLARED's, not this code's.
|
|
29
|
+
*/
|
|
30
|
+
function sourceFormFindings({ files, resolver }) {
|
|
31
|
+
const { suffixes, bannedSuffixes } = resolver.ruleParams();
|
|
32
|
+
// A suffix a slot names in its own file pattern (`*.builder.ts` of be.tests.fixtures.builders) is that slot's role: a file with it
|
|
33
|
+
// anywhere else is refused, so a builder cannot live beside a service or in the fixtures root.
|
|
34
|
+
const boundSuffixes = new Map();
|
|
35
|
+
for (const slot of resolver.slots()) {
|
|
36
|
+
const bound = /\*\.([a-z0-9-]+)\.ts$/.exec(slot.path ?? '')?.[1];
|
|
37
|
+
if (bound && suffixes.includes(bound)) boundSuffixes.set(bound, slot);
|
|
38
|
+
}
|
|
39
|
+
const findings = [];
|
|
40
|
+
for (const file of files) {
|
|
41
|
+
if (!file.endsWith('.ts') || !SOURCE_ROOT.test(file)) continue;
|
|
42
|
+
const c = resolver.classifyPath(file);
|
|
43
|
+
if (c.status !== 'owned' || c.tracking === 'ignored') continue;
|
|
44
|
+
const base = path.posix.basename(file);
|
|
45
|
+
if (FREE_NAMES.has(base)) continue;
|
|
46
|
+
// A literal file name the owning slot itself requires or allows (persistence/connection.ts, world/global-setup.ts) is its role.
|
|
47
|
+
const slot = resolver.slot(c.slot);
|
|
48
|
+
if ([...(slot?.requires ?? []), ...(slot?.allows ?? [])].some((entry) => entry === base)) continue;
|
|
49
|
+
// So is an allows entry below the slot root whose last segment is that literal name (be.tests.world fakes/<provider>/server.ts).
|
|
50
|
+
const admitted = allowsFile(resolver, file);
|
|
51
|
+
if (admitted?.allowed && admitted.entry?.includes('/') && path.posix.basename(admitted.entry) === base) continue;
|
|
52
|
+
// A slot whose `allows` holds a bare <name>.ts entry (be.tests.world.kit) names its files plainly, as platform/primitives does: kebab-case is the whole form.
|
|
53
|
+
if (admitted?.allowed && PLAIN_ENTRY.test(admitted.entry ?? '') && KEBAB.test(base.slice(0, -'.ts'.length))) continue;
|
|
54
|
+
if (c.slot === 'be.persistence' && path.posix.basename(path.posix.dirname(file)) === 'migrations') continue;
|
|
55
|
+
const parts = base.slice(0, -'.ts'.length).split('.');
|
|
56
|
+
const banned = parts.slice(1).find((part) => bannedSuffixes.includes(part));
|
|
57
|
+
if (banned) {
|
|
58
|
+
findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: banned, message: `${file}: the suffix .${banned} is banned; use a role from the closed suffix list (${suffixes.join(', ')})${BANNED_SUFFIX_HOME[banned] ? `. ${BANNED_SUFFIX_HOME[banned]}` : ''}` });
|
|
59
|
+
} else if (boundSuffixes.has(parts.at(-1)) && parts.length >= 2 && boundSuffixes.get(parts.at(-1)).id !== c.slot) {
|
|
60
|
+
findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: parts.at(-1), message: `${file}: the suffix .${parts.at(-1)}.ts belongs to ${boundSuffixes.get(parts.at(-1)).path} only; move the file there` });
|
|
61
|
+
} else if (parts.length < 2 || !parts.every((part) => KEBAB.test(part)) || !suffixes.includes(parts.at(-1))) {
|
|
62
|
+
findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, message: `${file}: the name must be <kebab-name>.<suffix>.ts with a suffix from the closed list (${suffixes.join(', ')}), or index.ts, main.ts or a migration` });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return findings;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The findings of the slot manifest over `files` (repository-relative tracked paths) of a repository of `profile`. */
|
|
69
|
+
export function pathFindings({ files, resolver, profile }) {
|
|
70
|
+
const findings = [];
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
if (profile === 'fe' && isFeTestPath(file)) continue; // a test path of a front end is FE_NO_TESTS's, the one finding of that file
|
|
73
|
+
const c = resolver.classifyPath(file);
|
|
74
|
+
if (c.status === 'no-slot') {
|
|
75
|
+
findings.push({ code: 'HFS_SLOT_UNDECLARED', level: 'error', path: file, nearest: c.nearest, message: `${file} matches no slot${c.nearest ? `; nearest slot ${c.nearest.slot} (${c.nearest.pattern}), matched ${c.nearest.matchedPrefix || '.'} then expected ${c.nearest.expectedNext ?? 'nothing'}` : ''}` });
|
|
76
|
+
} else if (c.status === 'ambiguous') {
|
|
77
|
+
findings.push({ code: 'HFS_SLOT_AMBIGUOUS', level: 'error', path: file, candidates: c.candidates, message: `${file} is owned equally by ${c.candidates.map((x) => x.slot ?? x).join(', ')}` });
|
|
78
|
+
} else if (c.status === 'not-enabled') {
|
|
79
|
+
findings.push({ code: 'HFS_SLOT_NOT_ENABLED', level: 'error', path: file, slot: c.slot, message: `${file} belongs to ${c.slot}, an opt-in slot hfs.json neither lists in optionalSlots nor implies through an app kind` });
|
|
80
|
+
} else if (c.status === 'forbidden') {
|
|
81
|
+
const slot = resolver.slot(c.slot);
|
|
82
|
+
if (slotOwnsSecrets(slot)) continue; // the secret scan reports the file (R06): one finding per file
|
|
83
|
+
const own = slot.rules?.includes('HFS_TOOL_CONFIG_LOCAL') ? 'HFS_TOOL_CONFIG_LOCAL' : 'HFS_FORBIDDEN_PRESENT';
|
|
84
|
+
findings.push({ code: own, level: 'error', path: file, slot: c.slot, goesTo: c.goesTo, message: `${file} is tracked but ${c.slot} is forbidden in the tree${c.goesTo ? `; it belongs at ${c.goesTo}` : ''}` });
|
|
85
|
+
} else if (c.tracking === 'ignored') {
|
|
86
|
+
findings.push({ code: 'HFS_TRACKED_MUST_BE_IGNORED', level: 'error', path: file, slot: c.slot, message: `${file} is tracked but ${c.slot} must be gitignored` });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (profile === 'be') findings.push(...sourceFormFindings({ files, resolver }), ...specPlacementFindings({ files, resolver }));
|
|
91
|
+
// The app root (scripts/, CI, hooks) holds no test layer: a spec there is the same finding as a spec of an operational script.
|
|
92
|
+
else if (profile === APP_SCOPE) findings.push(...specPlacementFindings({ files, resolver }));
|
|
93
|
+
// The lint canon's project graph serves these findings on a TypeScript file; the origin keeps them apart from the machine's.
|
|
94
|
+
return findings.map((finding) => ({ ...finding, origin: 'repo' }));
|
|
95
|
+
}
|