@starci/hfs 1.0.1 → 2.0.1
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 +51 -0
- package/README.md +110 -12
- package/bin/hfs.mjs +119 -15
- package/emit/compiler.mjs +35 -0
- package/emit/contracts.mjs +97 -0
- package/emit/operations-worker.mjs +24 -0
- package/emit/operations.mjs +126 -0
- package/emit/schema-worker.mjs +117 -0
- package/emit/static-graph.mjs +670 -0
- package/emit/type-schema.mjs +145 -0
- package/package.json +4 -1
- package/report/sonar.mjs +180 -0
- package/runtime/engine/admission.mjs +284 -0
- package/runtime/engine/digest.mjs +10 -0
- package/runtime/engine/ledger-db.mjs +1245 -0
- package/runtime/engine/machine-db.mjs +1484 -0
- package/runtime/engine/migrations/machine/0001-init.sql +887 -0
- package/runtime/engine/migrations/runtime/0001-init.sql +1072 -0
- package/runtime/engine/migrations/runtime/0003-usage-unavailable.sql +13 -0
- package/runtime/engine/migrations/runtime/0004-attempt-why.sql +43 -0
- package/runtime/engine/plain-object.mjs +5 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +16 -58
- package/runtime/knowledge/hfs/slots.yaml +405 -137
- package/runtime/knowledge/patterns/fe/folder.yaml +309 -0
- package/runtime/knowledge/sonar-gate.yaml +85 -0
- package/runtime/modules/kernel/failure-codes.yaml +1480 -16
- package/runtime/scripts/checks/architecture/backend.mjs +350 -0
- package/runtime/scripts/checks/architecture/background-unowned.mjs +107 -0
- package/runtime/scripts/checks/architecture/client-reaches-server.mjs +94 -0
- package/runtime/scripts/checks/architecture/clones.mjs +200 -0
- package/runtime/scripts/checks/architecture/config-unread.mjs +35 -0
- package/runtime/scripts/checks/architecture/config.mjs +310 -0
- package/runtime/scripts/checks/architecture/connection-map.mjs +208 -0
- package/runtime/scripts/checks/architecture/constructor-deps.mjs +100 -0
- package/runtime/scripts/checks/architecture/contract-fixture-guard.mjs +128 -0
- package/runtime/scripts/checks/architecture/contracts.mjs +792 -0
- package/runtime/scripts/checks/architecture/cross-app-duplicate.mjs +73 -0
- package/runtime/scripts/checks/architecture/dead-exports.mjs +265 -0
- package/runtime/scripts/checks/architecture/default-deny.mjs +129 -0
- package/runtime/scripts/checks/architecture/doc-language.mjs +39 -0
- package/runtime/scripts/checks/architecture/entrypoint.mjs +57 -0
- package/runtime/scripts/checks/architecture/error-codes.mjs +45 -0
- package/runtime/scripts/checks/architecture/error-masked.mjs +60 -0
- package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +38 -0
- package/runtime/scripts/checks/architecture/feature-shape.mjs +49 -0
- package/runtime/scripts/checks/architecture/framework-pinned.mjs +83 -0
- package/runtime/scripts/checks/architecture/frontend.mjs +995 -0
- package/runtime/scripts/checks/architecture/hfs-graph.mjs +61 -0
- package/runtime/scripts/checks/architecture/hfs.mjs +521 -0
- package/runtime/scripts/checks/architecture/hooks-are-hooks.mjs +88 -0
- package/runtime/scripts/checks/architecture/i18n-keys.mjs +146 -0
- package/runtime/scripts/checks/architecture/index.mjs +316 -0
- package/runtime/scripts/checks/architecture/injection-token-exported.mjs +62 -0
- package/runtime/scripts/checks/architecture/machine-ast.mjs +138 -0
- package/runtime/scripts/checks/architecture/module-per-transport.mjs +130 -0
- package/runtime/scripts/checks/architecture/next-data.mjs +775 -0
- package/runtime/scripts/checks/architecture/owners.mjs +89 -0
- package/runtime/scripts/checks/architecture/package-shape.mjs +63 -0
- package/runtime/scripts/checks/architecture/reachability.mjs +233 -0
- package/runtime/scripts/checks/architecture/register-once.mjs +141 -0
- package/runtime/scripts/checks/architecture/registration.mjs +319 -0
- package/runtime/scripts/checks/architecture/required-files.mjs +172 -0
- package/runtime/scripts/checks/architecture/route-files-thin.mjs +97 -0
- package/runtime/scripts/checks/architecture/schema-owner.mjs +261 -0
- package/runtime/scripts/checks/architecture/size-growth.mjs +73 -0
- package/runtime/scripts/checks/architecture/source-names.mjs +607 -0
- package/runtime/scripts/checks/architecture/sql-owner.mjs +142 -0
- package/runtime/scripts/checks/architecture/sql-tokens.mjs +327 -0
- package/runtime/scripts/checks/architecture/symbols.mjs +193 -0
- package/runtime/scripts/checks/architecture/test-world-files.mjs +163 -0
- package/runtime/scripts/checks/architecture/tiers.mjs +130 -0
- package/runtime/scripts/checks/architecture/transport-owner.mjs +112 -0
- package/runtime/scripts/checks/architecture/typescript.mjs +500 -0
- package/runtime/scripts/checks/architecture/unit-spec-providers.mjs +122 -0
- package/runtime/scripts/checks/architecture.mjs +41 -0
- package/runtime/scripts/checks/common.mjs +37 -0
- package/runtime/scripts/checks/typescript-programs.mjs +82 -0
- package/runtime/scripts/lib/artifact-hold.mjs +89 -0
- package/runtime/scripts/lib/artifact-store.mjs +103 -0
- package/runtime/scripts/lib/fs-kind.mjs +10 -0
- package/runtime/scripts/lib/git.mjs +53 -0
- package/runtime/scripts/lib/hfs-allows.mjs +57 -0
- package/runtime/scripts/lib/hfs-check.mjs +254 -28
- package/runtime/scripts/lib/hfs-rules/contract.mjs +126 -0
- package/runtime/scripts/lib/hfs-rules/deps.mjs +63 -0
- package/runtime/scripts/lib/hfs-rules/fe-no-tests.mjs +47 -0
- package/runtime/scripts/lib/hfs-rules/frontend.mjs +124 -0
- package/runtime/scripts/lib/hfs-rules/lint-suppression.mjs +34 -0
- package/runtime/scripts/lib/hfs-rules/pipeline.mjs +51 -0
- package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +64 -0
- package/runtime/scripts/lib/hfs-rules/read.mjs +28 -0
- package/runtime/scripts/lib/hfs-rules/repo-local-checks.mjs +32 -0
- package/runtime/scripts/lib/hfs-rules/secrets.mjs +54 -0
- package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +31 -0
- package/runtime/scripts/lib/hfs-rules/stacks.mjs +54 -0
- package/runtime/scripts/lib/hfs-rules/test-topology.mjs +31 -0
- package/runtime/scripts/lib/hfs-slots.mjs +88 -40
- package/runtime/scripts/lib/hfs-tree.mjs +80 -0
- package/runtime/scripts/lib/hfs-view.mjs +68 -0
- package/runtime/scripts/lib/json.mjs +22 -0
- package/runtime/scripts/lib/language.mjs +107 -0
- package/runtime/scripts/lib/path-key.mjs +2 -0
- package/runtime/scripts/lib/redact.mjs +148 -0
- package/runtime/scripts/lib/repo-identity.mjs +50 -0
- package/runtime/scripts/lib/safe-remove.mjs +179 -0
- package/runtime/scripts/lib/secret-patterns.mjs +44 -0
- package/runtime/scripts/lib/sleep-sync.mjs +17 -0
- package/runtime/scripts/lib/stack-declaration.mjs +52 -0
- package/runtime/scripts/lib/stack-services.mjs +217 -0
- package/runtime/scripts/lib/test-secrets.mjs +120 -0
- package/scaffold/service.mjs +333 -0
- package/sync/format.mjs +46 -0
- package/sync/hygiene.mjs +56 -24
- package/sync/index.mjs +133 -41
- package/sync/managed.mjs +170 -0
- package/sync/skeleton.mjs +32 -10
- package/sync/sonar-key.mjs +13 -0
- package/sync/ts-strict.mjs +48 -0
- package/templates/{common → be/ci-workflows/github/workflows}/ci.yml +13 -13
- package/templates/be/{e2e.yml → ci-workflows/github/workflows/e2e.yml} +1 -0
- package/templates/be/hooks/husky/pre-commit +13 -0
- package/templates/be/hooks/husky/pre-push +7 -0
- package/templates/be/package-scripts/package.json +22 -0
- package/templates/be/{sonar-project.properties → quality-config/sonar-project.properties} +2 -3
- package/templates/be/skeleton/apps/__app__/src/app.module.ts +14 -4
- package/templates/be/skeleton/apps/__app__/src/main.ts +9 -6
- package/templates/be/skeleton/scripts/.gitkeep +0 -0
- package/templates/be/skeleton/src/features/system-health/application/check-liveness.contracts.ts +2 -0
- package/templates/be/skeleton/src/features/system-health/application/check-liveness.handler.ts +22 -0
- package/templates/be/skeleton/src/features/system-health/application/check-liveness.query.ts +11 -0
- package/templates/be/skeleton/src/features/system-health/index.ts +1 -1
- package/templates/be/skeleton/src/features/system-health/system-health.module.ts +3 -3
- package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +8 -9
- package/templates/be/skeleton/src/features/system-health/transport/http/system-health-http.module.ts +3 -6
- package/templates/be/skeleton/src/modules/domain/liveness/index.ts +3 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.contracts.ts +9 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.module.ts +14 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.options.ts +2 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.spec.ts +40 -0
- package/templates/be/skeleton/src/modules/domain/liveness/liveness.service.ts +26 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.module.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/clock/clock.port.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/clock/index.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.spec.ts +20 -0
- package/templates/be/skeleton/src/modules/platform/clock/system-clock.service.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -0
- package/templates/be/skeleton/src/modules/platform/composition/index.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/config/index.ts +2 -3
- package/templates/be/skeleton/src/modules/platform/config/server.config.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/config/server.options.ts +0 -3
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.contracts.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.handler.ts +27 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.log-events.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.module.ts +18 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/cqrs.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/cqrs/index.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +8 -12
- package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/errors/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/logging/index.ts +4 -4
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +99 -0
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +38 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.module.ts +23 -10
- package/templates/be/skeleton/src/modules/platform/logging/logging.options.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +15 -0
- package/templates/be/tool-config/eslint.config.mjs +3 -0
- package/templates/be/tool-config/jest.config.js +1 -0
- package/templates/be/tool-config/prettierignore +8 -0
- package/templates/be/tool-config/prettierrc +1 -0
- package/templates/be/tool-config/src/tests/tsconfig.json +5 -0
- package/templates/be/tool-config/tsconfig.build.json +5 -0
- package/templates/be/tool-config/tsconfig.json +11 -0
- package/templates/common/gitignore.base +1 -1
- package/templates/fe/ci-workflows/github/workflows/ci.yml +54 -0
- package/templates/fe/hooks/husky/pre-commit +16 -0
- package/templates/fe/hooks/husky/pre-push +6 -0
- package/templates/fe/package-scripts/package.json +17 -0
- package/templates/fe/parts/api-client.ts +44 -0
- package/templates/fe/parts/api-outcome.ts +7 -0
- package/templates/fe/quality-config/sonar-project.properties +8 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +1 -1
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +1 -1
- package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +1 -1
- package/templates/fe/skeleton/scripts/.gitkeep +0 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +1 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +3 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +1 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +4 -0
- package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +12 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +2 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +9 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +5 -0
- package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +5 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/package.json +12 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +1 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +3 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +1 -0
- package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +5 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +18 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +19 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +2 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +12 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +15 -0
- package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +5 -0
- package/templates/fe/tool-config/eslint.config.mjs +3 -0
- package/templates/fe/tool-config/prettierignore +10 -0
- package/templates/fe/tool-config/prettierrc +1 -0
- package/templates/fe/tool-config/stylelint.config.mjs +3 -0
- package/templates/fe/tool-config/tsconfig.json +4 -0
- package/templates/be/pre-commit +0 -8
- package/templates/be/skeleton/apps/__app__/src/__app__.composition.spec.ts +0 -31
- package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.spec.ts +0 -13
- package/templates/be/skeleton/src/modules/platform/config/env-source.spec.ts +0 -36
- package/templates/be/skeleton/src/modules/platform/config/server.config.spec.ts +0 -19
- package/templates/be/skeleton/src/modules/platform/errors/domain-error.spec.ts +0 -15
- package/templates/be/skeleton/src/modules/platform/errors/error.filter.spec.ts +0 -38
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.spec.ts +0 -33
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.ts +0 -35
- package/templates/be/skeleton/src/modules/platform/logging/log-id.ts +0 -9
- package/templates/be/skeleton/src/modules/platform/logging/logger.port.ts +0 -19
- package/templates/common/codecov.yml +0 -13
- package/templates/common/pre-push +0 -5
- package/templates/fe/e2e.yml +0 -22
- package/templates/fe/pre-commit +0 -7
- package/templates/fe/skeleton/apps/__app__/src/app/health/live/route.spec.ts +0 -10
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages.spec.ts +0 -27
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/routing.spec.ts +0 -10
- package/templates/fe/sonar-project.properties +0 -11
- /package/templates/be/skeleton/src/modules/platform/config/{env-source.ts → env-source.config.ts} +0 -0
- /package/templates/be/skeleton/src/modules/platform/errors/{domain-error.ts → domain.error.ts} +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/next.config.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/config.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/navigation.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/request.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/modules/i18n/routing.ts +0 -0
- /package/templates/fe/{skeleton → skeleton-app}/apps/__app__/src/proxy.ts +0 -0
|
@@ -12,8 +12,9 @@ import fs from 'node:fs';
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { skillRoot } from '../../engine/runtime-root.mjs';
|
|
14
14
|
import { parseYaml } from '../../engine/yaml.mjs';
|
|
15
|
-
import { braceVariants } from './glob.mjs';
|
|
15
|
+
import { braceVariants, globExpression } from './glob.mjs';
|
|
16
16
|
import { posixPath } from './path-key.mjs';
|
|
17
|
+
import { isPlainObject } from '../../engine/plain-object.mjs';
|
|
17
18
|
|
|
18
19
|
export const HFS_MANIFEST_FILE = 'knowledge/hfs/slots.yaml';
|
|
19
20
|
export const HFS_DECLARATION_FILE = 'hfs.json';
|
|
@@ -30,12 +31,12 @@ export class HfsSlotsError extends Error {
|
|
|
30
31
|
|
|
31
32
|
const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
32
33
|
const NAME = /^[a-z][a-z0-9-]*$/;
|
|
34
|
+
const ENV_PREFIX = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/;
|
|
33
35
|
const SLOT_ID = /^(repo|be|fe)\.[a-z0-9-]+(\.[a-z0-9-]+)*$/;
|
|
34
36
|
const PRESENCE = ['required', 'optional', 'opt-in', 'forbidden'];
|
|
35
37
|
const TRACKED = ['tracked', 'ignored', 'external'];
|
|
36
38
|
const TESTS = ['unit-beside', 'e2e', 'none'];
|
|
37
39
|
const PROFILES = ['be', 'fe'];
|
|
38
|
-
const isMap = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
39
40
|
const strList = (v) => Array.isArray(v) && v.every((s) => typeof s === 'string' && s.length > 0);
|
|
40
41
|
|
|
41
42
|
// ------------------------------------------------------------------------------------------------ patterns
|
|
@@ -118,17 +119,17 @@ const fail = (code, message, details) => { throw new HfsSlotsError(code, message
|
|
|
118
119
|
/** Shape problems of a parsed manifest, in the words of modules/schemas/hfs-slots.schema.yaml. */
|
|
119
120
|
function manifestShapeProblems(m) {
|
|
120
121
|
const bad = [];
|
|
121
|
-
if (!
|
|
122
|
+
if (!isPlainObject(m)) return ['the manifest is not a map'];
|
|
122
123
|
const allowed = new Set(['schema', 'version', 'versioning', 'presenceValues', 'trackedValues', 'testValues', 'appKinds', 'tiers', 'ruleParams', 'crossOwner', 'crossApp', 'slots', 'consumers']);
|
|
123
124
|
for (const key of Object.keys(m)) if (!allowed.has(key)) bad.push(`unknown top-level key ${key}`);
|
|
124
125
|
if (!/^starci\/hfs-slots@\d+$/.test(String(m.schema))) bad.push('schema must be starci/hfs-slots@<major>');
|
|
125
126
|
if (!SEMVER.test(String(m.version))) bad.push('version must be MAJOR.MINOR.PATCH');
|
|
126
|
-
if (!
|
|
127
|
+
if (!isPlainObject(m.versioning) || !['patch', 'minor', 'major', 'retire', 'pins'].every((k) => typeof m.versioning[k] === 'string')) bad.push('versioning needs patch, minor, major, retire and pins text');
|
|
127
128
|
if (JSON.stringify(m.presenceValues) !== JSON.stringify(PRESENCE)) bad.push(`presenceValues must be ${PRESENCE.join(', ')}`);
|
|
128
129
|
if (JSON.stringify(m.trackedValues) !== JSON.stringify(TRACKED)) bad.push(`trackedValues must be ${TRACKED.join(', ')}`);
|
|
129
130
|
if (JSON.stringify(m.testValues) !== JSON.stringify(TESTS)) bad.push(`testValues must be ${TESTS.join(', ')}`);
|
|
130
131
|
for (const key of ['appKinds', 'tiers']) {
|
|
131
|
-
if (!
|
|
132
|
+
if (!isPlainObject(m[key])) { bad.push(`${key} must be a map with be and fe`); continue; }
|
|
132
133
|
for (const extra of Object.keys(m[key])) if (!PROFILES.includes(extra)) bad.push(`${key}.${extra} is not a profile`);
|
|
133
134
|
for (const profile of PROFILES) if (!(profile in m[key])) bad.push(`${key}.${profile} is missing`);
|
|
134
135
|
}
|
|
@@ -137,26 +138,40 @@ function manifestShapeProblems(m) {
|
|
|
137
138
|
if (kinds !== undefined && (!Array.isArray(kinds) || !kinds.length || !kinds.every((k) => NAME.test(String(k))) || new Set(kinds).size !== kinds.length)) bad.push(`appKinds.${profile} must be a non-empty list of unique names`);
|
|
138
139
|
const tiers = m.tiers?.[profile];
|
|
139
140
|
if (tiers === undefined) continue;
|
|
140
|
-
if (!
|
|
141
|
+
if (!isPlainObject(tiers) || !Object.keys(tiers).length) { bad.push(`tiers.${profile} must be a non-empty map`); continue; }
|
|
141
142
|
for (const [tier, def] of Object.entries(tiers)) {
|
|
142
143
|
if (!NAME.test(tier)) bad.push(`tiers.${profile}.${tier} is not a tier name`);
|
|
143
|
-
if (!
|
|
144
|
+
if (!isPlainObject(def) || !Array.isArray(def.mayImport) || !def.mayImport.every((t) => NAME.test(String(t)))) bad.push(`tiers.${profile}.${tier}.mayImport must be a list of tier names`);
|
|
144
145
|
for (const key of Object.keys(def ?? {})) if (!['mayImport', 'acyclic', 'lowerLayerOnly'].includes(key)) bad.push(`tiers.${profile}.${tier}.${key} is not a tier field`);
|
|
145
146
|
}
|
|
146
147
|
}
|
|
147
|
-
const blockOk = (v) =>
|
|
148
|
-
const fileLinesOk = (v) =>
|
|
148
|
+
const blockOk = (v) => isPlainObject(v) && Number.isInteger(v.lines) && v.lines >= 2 && Number.isInteger(v.tokens) && v.tokens >= 1 && Object.keys(v).length === 2;
|
|
149
|
+
const fileLinesOk = (v) => isPlainObject(v) && Number.isInteger(v.soft) && v.soft >= 1 && typeof v.hardGrowth === 'boolean' && Object.keys(v).length === 2;
|
|
149
150
|
const rp = m.ruleParams;
|
|
150
|
-
if (!
|
|
151
|
+
if (!isPlainObject(rp) || Object.keys(rp).some((k) => !PROFILES.includes(k)) || !PROFILES.every((p) => isPlainObject(rp[p]))) bad.push('ruleParams must be a map with be and fe');
|
|
151
152
|
else {
|
|
152
|
-
if (!
|
|
153
|
-
if (!
|
|
153
|
+
if (!fileLinesOk(rp.be.fileLines) || !blockOk(rp.be.duplicateBlock) || Object.keys(rp.be).length !== 7) bad.push('ruleParams.be needs fileLines {soft, hardGrowth}, duplicateBlock {lines >= 2, tokens >= 1}, infraOwners, specDoubles, suffixes, bannedSuffixes and contractShape {helper}');
|
|
154
|
+
if (!isPlainObject(rp.be.contractShape) || Object.keys(rp.be.contractShape).length !== 1 || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(String(rp.be.contractShape.helper))) bad.push('ruleParams.be.contractShape must be {helper: <identifier>}');
|
|
155
|
+
const owners = rp.be.infraOwners;
|
|
156
|
+
const ownerId = /^(platform|integrations)\/[a-z][a-z0-9-]*$/;
|
|
157
|
+
if (!isPlainObject(owners) || !Object.keys(owners).length || !Object.entries(owners).every(([key, list]) => key && Array.isArray(list) && list.every((o) => ownerId.test(String(o))) && new Set(list).size === list.length)) bad.push('ruleParams.be.infraOwners must map a non-empty specifier to a list of unique platform/<capability> or integrations/<provider> owners ([] means nowhere)');
|
|
158
|
+
const roleList = (v) => Array.isArray(v) && v.length > 0 && v.every((x) => /^[a-z][a-z0-9-]*$/.test(String(x))) && new Set(v).size === v.length;
|
|
159
|
+
if (!roleList(rp.be.suffixes)) bad.push('ruleParams.be.suffixes must be a non-empty list of unique kebab-case role suffixes');
|
|
160
|
+
if (!roleList(rp.be.bannedSuffixes)) bad.push('ruleParams.be.bannedSuffixes must be a non-empty list of unique kebab-case suffixes');
|
|
161
|
+
else if (roleList(rp.be.suffixes) && rp.be.suffixes.some((x) => rp.be.bannedSuffixes.includes(x))) bad.push('ruleParams.be.suffixes and bannedSuffixes must be disjoint');
|
|
162
|
+
const formNames = ['call', 'new', 'curried', 'object', 'primitive', 'array'];
|
|
163
|
+
const formsOk = (v) => Array.isArray(v) && v.length > 0 && v.every((x) => formNames.includes(x)) && new Set(v).size === v.length;
|
|
164
|
+
const doubleOk = (v) => isPlainObject(v) && /^[A-Za-z][A-Za-z0-9]*$/.test(String(v.double)) && formsOk(v.forms);
|
|
165
|
+
const regexOk = (v) => { try { return typeof v === 'string' && v.length > 0 && Boolean(new RegExp(v)); } catch { return false; } };
|
|
166
|
+
const sd = rp.be.specDoubles;
|
|
167
|
+
if (!isPlainObject(sd) || Object.keys(sd).sort().join() !== 'doubles,fallback,kit' || typeof sd.kit !== 'string' || !sd.kit || !Array.isArray(sd.doubles) || !sd.doubles.length || !sd.doubles.every((e) => doubleOk(e) && regexOk(e.token) && Object.keys(e).length === 3) || !doubleOk(sd.fallback) || Object.keys(sd.fallback).length !== 2) bad.push('ruleParams.be.specDoubles must be {kit, doubles: [{token: regex, double, forms}], fallback: {double, forms}} with forms drawn from call, new, curried, object, primitive, array');
|
|
168
|
+
if (!fileLinesOk(rp.fe.fileLines) || !blockOk(rp.fe.duplicateBlock) || Object.keys(rp.fe).length !== 2) bad.push('ruleParams.fe needs fileLines {soft, hardGrowth} and duplicateBlock {lines >= 2, tokens >= 1}');
|
|
154
169
|
}
|
|
155
170
|
if (!Array.isArray(m.slots) || !m.slots.length) { bad.push('slots must be a non-empty list'); return bad; }
|
|
156
|
-
const slotKeys = new Set(['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'allows', 'forbids', 'layers', 'budget', 'managedBy', 'rules', 'goesTo', 'why', 'since', 'retiredIn', 'successor']);
|
|
171
|
+
const slotKeys = new Set(['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'rules', 'goesTo', 'why', 'since', 'retiredIn', 'successor']);
|
|
157
172
|
m.slots.forEach((slot, index) => {
|
|
158
|
-
const at =
|
|
159
|
-
if (!
|
|
173
|
+
const at = isPlainObject(slot) && typeof slot.id === 'string' ? `slot ${slot.id}` : `slots[${index}]`;
|
|
174
|
+
if (!isPlainObject(slot)) { bad.push(`${at} is not a map`); return; }
|
|
160
175
|
for (const key of Object.keys(slot)) if (!slotKeys.has(key)) bad.push(`${at}: unknown field ${key}`);
|
|
161
176
|
if (!SLOT_ID.test(String(slot.id))) bad.push(`${at}: id must look like be.transport.http`);
|
|
162
177
|
if (!Array.isArray(slot.profiles) || !slot.profiles.length || !slot.profiles.every((p) => PROFILES.includes(p)) || new Set(slot.profiles).size !== slot.profiles.length) bad.push(`${at}: profiles must be a unique non-empty subset of be, fe`);
|
|
@@ -169,9 +184,12 @@ function manifestShapeProblems(m) {
|
|
|
169
184
|
if (slot.appKind !== undefined && !NAME.test(String(slot.appKind))) bad.push(`${at}: appKind must be a name`);
|
|
170
185
|
if (slot.minInstances !== undefined && !(Number.isInteger(slot.minInstances) && slot.minInstances >= 1)) bad.push(`${at}: minInstances must be a positive integer`);
|
|
171
186
|
if (slot.requiredWhen !== undefined && slot.requiredWhen !== 'connections') bad.push(`${at}: requiredWhen may only be connections`);
|
|
172
|
-
if (slot.requiredInstances !== undefined && !(
|
|
173
|
-
for (const key of ['requires', 'allows', 'forbids', 'layers']) if (slot[key] !== undefined && !strList(slot[key])) bad.push(`${at}: ${key} must be a list of strings`);
|
|
174
|
-
if (slot.
|
|
187
|
+
if (slot.requiredInstances !== undefined && !(isPlainObject(slot.requiredInstances) && Object.values(slot.requiredInstances).every((v) => strList(v) && v.length))) bad.push(`${at}: requiredInstances must map a variable to a non-empty list of names`);
|
|
188
|
+
for (const key of ['requires', 'allows', 'forbids', 'layers', 'kinds']) if (slot[key] !== undefined && !strList(slot[key])) bad.push(`${at}: ${key} must be a list of strings`);
|
|
189
|
+
if (slot.kinds !== undefined && strList(slot.kinds) && (!slot.kinds.length || new Set(slot.kinds).size !== slot.kinds.length || slot.kinds.some((k) => !NAME.test(k) || (slot.layers ?? []).includes(k)))) bad.push(`${at}: kinds must be a non-empty list of unique folder names that are not layers`);
|
|
190
|
+
if (slot.roles !== undefined && !(isPlainObject(slot.roles) && Object.keys(slot.roles).length && Object.entries(slot.roles).every(([role, file]) => NAME.test(role) && typeof file === 'string' && file && !file.includes('/')))) bad.push(`${at}: roles must map a role name to a file name`);
|
|
191
|
+
if (slot.composedBy !== undefined && !(strList(slot.composedBy) && slot.composedBy.length && new Set(slot.composedBy).size === slot.composedBy.length)) bad.push(`${at}: composedBy must be a non-empty list of unique app kinds`);
|
|
192
|
+
if (slot.budget !== undefined && !(isPlainObject(slot.budget) && Object.keys(slot.budget).length && Object.values(slot.budget).every((v) => Number.isInteger(v) && v >= 1))) bad.push(`${at}: budget must map names to positive integers`);
|
|
175
193
|
if (slot.managedBy !== undefined && !NAME.test(String(slot.managedBy))) bad.push(`${at}: managedBy must be a template id`);
|
|
176
194
|
if (slot.rules !== undefined && !(Array.isArray(slot.rules) && slot.rules.every((r) => /^[A-Z][A-Z0-9_]*\*?$/.test(String(r))) && new Set(slot.rules).size === slot.rules.length)) bad.push(`${at}: rules must be unique rule ids`);
|
|
177
195
|
if (slot.since !== undefined && !SEMVER.test(String(slot.since))) bad.push(`${at}: since must be a version`);
|
|
@@ -207,11 +225,13 @@ function manifestSemanticProblems(m) {
|
|
|
207
225
|
if (slot.appKind !== undefined && !pathVars.has('app')) bad.push(`slot ${slot.id}: an app-kind slot binds <app> in its path`);
|
|
208
226
|
if (slot.requiredWhen !== undefined && slot.presence !== 'required') bad.push(`slot ${slot.id}: requiredWhen belongs to a required slot`);
|
|
209
227
|
for (const name of Object.keys(slot.requiredInstances ?? {})) if (!pathVars.has(name)) bad.push(`slot ${slot.id}: requiredInstances names <${name}>, which the path does not bind`);
|
|
228
|
+
for (const file of Object.values(slot.roles ?? {})) for (const name of varsOf(file)) if (!pathVars.has(name)) bad.push(`slot ${slot.id}: roles names <${name}> in ${file}, which the path does not bind`);
|
|
210
229
|
for (const entry of slot.requires ?? []) for (const name of varsOf(entry)) if (!pathVars.has(name)) bad.push(`slot ${slot.id}: requires ${entry} uses <${name}>, which the path does not bind`);
|
|
211
230
|
for (const variant of braceVariants(slot.path)) {
|
|
212
231
|
try { compileVariant(slot, variant); } catch (error) { bad.push(`slot ${slot.id}: pattern ${variant} does not compile (${error.message})`); }
|
|
213
232
|
}
|
|
214
233
|
if (slot.successor !== undefined && !m.slots.some((s) => s.id === slot.successor)) bad.push(`slot ${slot.id}: successor ${slot.successor} is not a slot`);
|
|
234
|
+
for (const kind of slot.composedBy ?? []) if (!slot.profiles.every((p) => m.appKinds[p].includes(kind))) bad.push(`slot ${slot.id}: composedBy names ${kind}, which is not an app kind of every profile of the slot`);
|
|
215
235
|
if (slot.layers !== undefined && slot.tier !== 'none' && !slot.profiles.every((p) => m.tiers[p][slot.tier]?.lowerLayerOnly)) bad.push(`slot ${slot.id}: layers need a lowerLayerOnly tier`);
|
|
216
236
|
}
|
|
217
237
|
for (const profile of PROFILES) {
|
|
@@ -247,20 +267,25 @@ export function loadSlotManifest({ root = skillRoot, file = path.join(root, HFS_
|
|
|
247
267
|
/** Shape problems of a parsed hfs.json, in the words of modules/schemas/hfs-repo.schema.yaml. */
|
|
248
268
|
function declarationShapeProblems(d) {
|
|
249
269
|
const bad = [];
|
|
250
|
-
if (!
|
|
270
|
+
if (!isPlainObject(d)) return ['hfs.json is not an object'];
|
|
251
271
|
for (const key of Object.keys(d)) if (!['hfs', 'profile', 'project', 'apps', 'optionalSlots', 'connections', 'stacks'].includes(key)) bad.push(`unknown key ${key}`);
|
|
252
272
|
if (!(Number.isInteger(d.hfs) && d.hfs >= 1)) bad.push('hfs must be the pinned manifest major (an integer, 1 or more)');
|
|
253
273
|
if (!PROFILES.includes(d.profile)) bad.push('profile must be be or fe');
|
|
254
274
|
if (!NAME.test(String(d.project))) bad.push('project must be a project name');
|
|
255
275
|
if (!Array.isArray(d.apps) || !d.apps.length) bad.push('apps must list every apps/<name> with its kind');
|
|
256
276
|
else d.apps.forEach((app, i) => {
|
|
257
|
-
if (!
|
|
277
|
+
if (!isPlainObject(app) || !NAME.test(String(app.name)) || !NAME.test(String(app.kind)) || Object.keys(app).some((k) => k !== 'name' && k !== 'kind')) bad.push(`apps[${i}] must be {name, kind}`);
|
|
258
278
|
});
|
|
259
279
|
if (d.stacks !== undefined && (d.profile !== 'fe' || typeof d.stacks !== 'string' || !d.stacks || /^([a-zA-Z]:)?[\/]/.test(d.stacks))) bad.push('stacks is front end only and must be a relative path to the sibling back-end repository');
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
280
|
+
if (d.optionalSlots !== undefined && (!Array.isArray(d.optionalSlots) || !d.optionalSlots.every((v) => SLOT_ID.test(String(v))) || new Set(d.optionalSlots).size !== d.optionalSlots.length)) bad.push('optionalSlots must be a unique list of slot ids');
|
|
281
|
+
if (d.connections !== undefined) {
|
|
282
|
+
// One physical database = one entry (R84): {name, envPrefix}; names and env prefixes unique, no prefix inside another's keys.
|
|
283
|
+
const list = Array.isArray(d.connections) ? d.connections : null;
|
|
284
|
+
if (!list || !list.every((c) => isPlainObject(c) && NAME.test(String(c.name)) && ENV_PREFIX.test(String(c.envPrefix)) && Object.keys(c).length === 2)) bad.push('connections must be a list of {name: kebab-case database name, envPrefix: UPPER_SNAKE prefix of its env keys}');
|
|
285
|
+
else {
|
|
286
|
+
if (new Set(list.map((c) => c.name)).size !== list.length) bad.push('connections names must be unique');
|
|
287
|
+
for (const a of list) for (const b of list) if (a !== b && `${b.envPrefix}_`.startsWith(`${a.envPrefix}_`)) bad.push(`connections ${a.name} and ${b.name} share env keys (${a.envPrefix}_ covers ${b.envPrefix}_)`);
|
|
288
|
+
}
|
|
264
289
|
}
|
|
265
290
|
if (d.profile === 'fe' && d.connections !== undefined) bad.push('connections belong to a backend repository');
|
|
266
291
|
return bad;
|
|
@@ -305,7 +330,7 @@ export function resolveRepoDeclaration(manifest, declaration, { file = HFS_DECLA
|
|
|
305
330
|
project: declaration.project,
|
|
306
331
|
apps: Object.freeze(declaration.apps.map((a) => Object.freeze({ name: a.name, kind: a.kind }))),
|
|
307
332
|
optionalSlots: Object.freeze([...(declaration.optionalSlots ?? [])]),
|
|
308
|
-
connections: Object.freeze(
|
|
333
|
+
connections: Object.freeze(connections.map((c) => Object.freeze({ name: c.name, envPrefix: c.envPrefix }))),
|
|
309
334
|
manifestVersion: manifest.version,
|
|
310
335
|
});
|
|
311
336
|
}
|
|
@@ -389,18 +414,41 @@ export function createSlotResolver(manifest, repo) {
|
|
|
389
414
|
};
|
|
390
415
|
}
|
|
391
416
|
|
|
417
|
+
/**
|
|
418
|
+
* The folder kind of a file: the layer or kind folder its slot names (`layers`, `kinds`) that the file sits in. A slot whose
|
|
419
|
+
* path spells the choice (`components/{blocks,leaves}/<name>/`) answers from the matched pattern; a slot that owns a whole
|
|
420
|
+
* directory (`packages/<family>-ui/`) answers from the first folder below its root that the list names.
|
|
421
|
+
*/
|
|
422
|
+
function kindOf({ variant, slot, root }, p) {
|
|
423
|
+
const names = [...(slot.layers ?? []), ...(slot.kinds ?? [])];
|
|
424
|
+
if (!names.length) return null;
|
|
425
|
+
const literal = variant.segments.find((segment) => names.includes(segment));
|
|
426
|
+
if (literal) return literal;
|
|
427
|
+
const below = root ? p.slice(root.length + 1) : p;
|
|
428
|
+
return below.split('/').slice(0, -1).find((segment) => names.includes(segment)) ?? null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** The role of a file in its slot: the entry of `roles` whose file name (variables filled from the path, `*` a wildcard inside the name) matches the file's name. */
|
|
432
|
+
function roleOf(slot, bindings, p) {
|
|
433
|
+
const name = p.split('/').pop();
|
|
434
|
+
for (const [role, file] of Object.entries(slot.roles ?? {})) if (globExpression(fillVars(file, bindings)).test(name)) return role;
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
|
|
392
438
|
/**
|
|
393
439
|
* status: owned | forbidden (external slot) | not-enabled (opt-in slot the repository did not declare) | ambiguous
|
|
394
|
-
* (two slots of equal specificity; a manifest gap) | no-slot (code
|
|
440
|
+
* (two slots of equal specificity; a manifest gap) | no-slot (code HFS_SLOT_UNDECLARED, with the nearest slot).
|
|
395
441
|
*/
|
|
396
442
|
function classifyPath(input) {
|
|
397
443
|
const p = clean(input);
|
|
398
444
|
const { hit, ambiguous } = best(matches(p));
|
|
399
445
|
if (ambiguous.length) return { path: p, status: 'ambiguous', candidates: ambiguous };
|
|
400
|
-
if (!hit) return { path: p, status: 'no-slot', code: '
|
|
446
|
+
if (!hit) return { path: p, status: 'no-slot', code: 'HFS_SLOT_UNDECLARED', nearest: nearest(p) };
|
|
401
447
|
const { slot, root, bindings } = hit;
|
|
402
448
|
const status = slot.presence === 'forbidden' ? 'forbidden' : (slotEnabled(slot) ? 'owned' : 'not-enabled');
|
|
403
|
-
|
|
449
|
+
const kind = kindOf(hit, p);
|
|
450
|
+
const role = roleOf(slot, bindings, p);
|
|
451
|
+
return { path: p, status, slot: slot.id, root, bindings, ...(kind ? { kind } : {}), ...(role ? { role } : {}), presence: slot.presence, tracking: slot.tracked, ...(status === 'forbidden' ? { goesTo: slot.goesTo } : {}) };
|
|
404
452
|
}
|
|
405
453
|
|
|
406
454
|
const slotOf = (p) => { const c = classifyPath(p); return c.slot ? byId.get(c.slot) : null; };
|
|
@@ -426,7 +474,7 @@ export function createSlotResolver(manifest, repo) {
|
|
|
426
474
|
|
|
427
475
|
/**
|
|
428
476
|
* Whether `fromPath` may import `toPath`: {allowed, reason, ...}. Reasons: sameOwner, untiered, crossApp,
|
|
429
|
-
* tierDirection, layerOrder, notPublicEntry, allowed, slotForbidden, slotNotEnabled, slotAmbiguous, and unowned (
|
|
477
|
+
* tierDirection, layerOrder, notPublicEntry, allowed, slotForbidden, slotNotEnabled, slotAmbiguous, and unowned (HFS_SLOT_UNDECLARED for the path no slot owns).
|
|
430
478
|
* Cycles are a graph property and belong to the architecture check, not to one edge.
|
|
431
479
|
*/
|
|
432
480
|
function importAllowed(fromPath, toPath) {
|
|
@@ -535,7 +583,7 @@ export function createSlotResolver(manifest, repo) {
|
|
|
535
583
|
});
|
|
536
584
|
}
|
|
537
585
|
|
|
538
|
-
/** The rule parameters of one profile (be:
|
|
586
|
+
/** The rule parameters of one profile (be: fileLines, duplicateBlock, infraOwners, suffixes, bannedSuffixes; fe: fileLines, duplicateBlock), as a frozen deep copy. */
|
|
539
587
|
export function ruleParams(manifest, profile) {
|
|
540
588
|
if (!PROFILES.includes(profile)) fail('HFS_MANIFEST_INVALID', `ruleParams has no profile ${profile}`, { profile });
|
|
541
589
|
const deepFreeze = (v) => { if (v && typeof v === 'object') Object.values(v).forEach(deepFreeze); return Object.freeze(v); };
|
|
@@ -561,14 +609,14 @@ const FILE_ENFORCERS = ['machine', 'hfs', 'work-validate', 'sonar'];
|
|
|
561
609
|
/** Shape and semantic problems of a parsed knowledge/hfs/rules.yaml, in the words of modules/schemas/hfs-rules.schema.yaml. */
|
|
562
610
|
function ruleCatalogProblems(d) {
|
|
563
611
|
const bad = [];
|
|
564
|
-
if (!
|
|
612
|
+
if (!isPlainObject(d)) return ['the rule catalog is not a map'];
|
|
565
613
|
for (const key of Object.keys(d)) if (!['schema', 'version', 'gates', 'enforcerKinds', 'rules'].includes(key)) bad.push(`unknown top-level key ${key}`);
|
|
566
614
|
const schemaOk = /^starci\/hfs-rules@\d+$/.test(String(d.schema));
|
|
567
615
|
if (!schemaOk) bad.push('schema must be starci/hfs-rules@<major>');
|
|
568
616
|
if (!SEMVER.test(String(d.version))) bad.push('version must be MAJOR.MINOR.PATCH');
|
|
569
617
|
else if (schemaOk && d.schema.split('@')[1] !== d.version.split('.')[0]) bad.push('the major of version must equal the number after @ in schema');
|
|
570
618
|
const vocabulary = (key, names) => {
|
|
571
|
-
if (!
|
|
619
|
+
if (!isPlainObject(d[key])) { bad.push(`${key} must be a map`); return; }
|
|
572
620
|
if (JSON.stringify(Object.keys(d[key])) !== JSON.stringify(names)) bad.push(`${key} must list exactly ${names.join(', ')} in that order`);
|
|
573
621
|
for (const [name, text] of Object.entries(d[key])) if (typeof text !== 'string' || !text.trim()) bad.push(`${key}.${name} needs a description`);
|
|
574
622
|
};
|
|
@@ -578,14 +626,14 @@ function ruleCatalogProblems(d) {
|
|
|
578
626
|
const codeOwner = new Map();
|
|
579
627
|
d.rules.forEach((r, index) => {
|
|
580
628
|
const at = `rules[${index}]`;
|
|
581
|
-
if (!
|
|
629
|
+
if (!isPlainObject(r)) { bad.push(`${at} is not a map`); return; }
|
|
582
630
|
const label = typeof r.id === 'string' ? r.id : at;
|
|
583
|
-
for (const key of Object.keys(r)) if (!['id', 'code', '
|
|
584
|
-
|
|
585
|
-
if (!/^R\d{2}$/.test(String(r.id))) bad.push(`${at}.id must be R<two digits>`);
|
|
586
|
-
else if (r.id
|
|
631
|
+
for (const key of Object.keys(r)) if (!['id', 'code', 'law', 'kinds', 'gates', 'failureCodes', 'enforcers'].includes(key)) bad.push(`${label} has unknown key ${key}`);
|
|
632
|
+
// A retired rule leaves its id unused for good (never reused), so ids only have to increase.
|
|
633
|
+
if (!/^R\d{2,3}$/.test(String(r.id))) bad.push(`${at}.id must be R<two or three digits>`);
|
|
634
|
+
else if (index > 0 && typeof d.rules[index - 1]?.id === 'string' && Number(r.id.slice(1)) <= Number(d.rules[index - 1].id.slice(1))) bad.push(`${label} is out of order: ids must increase, and ${d.rules[index - 1].id} comes before it`);
|
|
587
635
|
if (!FINDING_CODE.test(String(r.code))) bad.push(`${label}.code must be an UPPER_SNAKE finding code`);
|
|
588
|
-
|
|
636
|
+
if (typeof r.law !== 'string' || !r.law.trim()) bad.push(`${label}.law is missing`);
|
|
589
637
|
if (typeof r.law === 'string' && r.law.includes('\n')) bad.push(`${label}.law must be one line`);
|
|
590
638
|
const enumList = (key, allowed) => {
|
|
591
639
|
if (!Array.isArray(r[key]) || !r[key].length) { bad.push(`${label}.${key} must be a non-empty list`); return []; }
|
|
@@ -612,7 +660,7 @@ function ruleCatalogProblems(d) {
|
|
|
612
660
|
const seen = new Set();
|
|
613
661
|
r.enforcers.forEach((e, n) => {
|
|
614
662
|
const eat = `${label}.enforcers[${n}]`;
|
|
615
|
-
if (!
|
|
663
|
+
if (!isPlainObject(e)) { bad.push(`${eat} is not a map`); return; }
|
|
616
664
|
for (const key of Object.keys(e)) if (!['kind', 'id', 'status', 'at'].includes(key)) bad.push(`${eat} has unknown key ${key}`);
|
|
617
665
|
if (!ENFORCER_FAMILIES.includes(e.kind)) bad.push(`${eat}.kind must be one of ${ENFORCER_FAMILIES.join(', ')}`);
|
|
618
666
|
if (!ENFORCER_ID.test(String(e.id))) bad.push(`${eat}.id must be kebab-case`);
|
|
@@ -626,7 +674,7 @@ function ruleCatalogProblems(d) {
|
|
|
626
674
|
} else if (FILE_ENFORCERS.includes(e.kind) && e.status !== 'planned') bad.push(`${eat} exists, so it names the file (at) that emits its code`);
|
|
627
675
|
});
|
|
628
676
|
if (Array.isArray(r.gates)) {
|
|
629
|
-
const hasSonar = r.enforcers.some((e) =>
|
|
677
|
+
const hasSonar = r.enforcers.some((e) => isPlainObject(e) && e.kind === 'sonar');
|
|
630
678
|
if (r.gates.includes('sonar') !== hasSonar) bad.push(`${label}: the sonar gate and a sonar enforcer go together`);
|
|
631
679
|
}
|
|
632
680
|
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// hfs-tree.mjs - the facts about the work tree that `git ls-files` cannot show: directories with no file below them, sibling
|
|
2
|
+
// directories one typo apart (a renamed structure whose old copy was never removed), and entries git neither tracks nor
|
|
3
|
+
// ignores. hfs-check.mjs turns these facts into HFS_EMPTY_DIR, HFS_GHOST_TREE and HFS_UNTRACKED_ROOT_ENTRY findings (R03).
|
|
4
|
+
// Read-only: it walks the file system and asks git, nothing else.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { gitOutput } from './git.mjs';
|
|
8
|
+
import { posixPath } from './path-key.mjs';
|
|
9
|
+
|
|
10
|
+
/** Directory names no tree check enters: git's own store and installed packages. */
|
|
11
|
+
const NEVER_WALKED = new Set(['.git', 'node_modules']);
|
|
12
|
+
/** Two sibling names this close (Levenshtein) are the same name misspelt. */
|
|
13
|
+
export const GHOST_DISTANCE = 2;
|
|
14
|
+
|
|
15
|
+
/** Levenshtein distance of two names. */
|
|
16
|
+
export function editDistance(a, b) {
|
|
17
|
+
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
18
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
19
|
+
const row = [i];
|
|
20
|
+
for (let j = 1; j <= b.length; j += 1) row[j] = Math.min(previous[j] + 1, row[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
21
|
+
previous = row;
|
|
22
|
+
}
|
|
23
|
+
return previous[b.length];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The directory tree of `repoRoot` below any `ignored` slot: {name, rel, files, dirs: [node]}. A directory in an
|
|
28
|
+
* ignored slot is not entered and counts as holding a file (its content is build output the repository does not own).
|
|
29
|
+
* Links are not followed.
|
|
30
|
+
*/
|
|
31
|
+
export function readTree(repoRoot, { isIgnored }) {
|
|
32
|
+
const walk = (abs, rel) => {
|
|
33
|
+
const node = { name: path.basename(abs), rel, files: 0, dirs: [] };
|
|
34
|
+
let entries;
|
|
35
|
+
try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { return node; }
|
|
36
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
37
|
+
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
38
|
+
if (!entry.isDirectory()) { node.files += 1; continue; }
|
|
39
|
+
if (NEVER_WALKED.has(entry.name)) continue;
|
|
40
|
+
if (isIgnored(childRel)) { node.files += 1; continue; }
|
|
41
|
+
node.dirs.push(walk(path.join(abs, entry.name), childRel));
|
|
42
|
+
}
|
|
43
|
+
return node;
|
|
44
|
+
};
|
|
45
|
+
return walk(repoRoot, '');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const holdsNoFile = (node) => node.files === 0 && node.dirs.every(holdsNoFile);
|
|
49
|
+
const countDirs = (node) => node.dirs.reduce((sum, child) => sum + 1 + countDirs(child), 0);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The tree's own findings: `empty` are the topmost directories with no file anywhere below them (each with the number of
|
|
53
|
+
* directories it holds); `ghosts` are pairs of sibling directories within GHOST_DISTANCE edits where `ghost` has no file
|
|
54
|
+
* below it and `of` is its lookalike.
|
|
55
|
+
*/
|
|
56
|
+
export function treeFacts(tree) {
|
|
57
|
+
const empty = [];
|
|
58
|
+
const ghosts = [];
|
|
59
|
+
const visit = (node) => {
|
|
60
|
+
for (const child of node.dirs) {
|
|
61
|
+
if (holdsNoFile(child)) empty.push({ path: child.rel, below: countDirs(child) });
|
|
62
|
+
else visit(child);
|
|
63
|
+
}
|
|
64
|
+
for (const [i, a] of node.dirs.entries()) {
|
|
65
|
+
for (const b of node.dirs.slice(i + 1)) {
|
|
66
|
+
if (editDistance(a.name, b.name) > GHOST_DISTANCE) continue;
|
|
67
|
+
if (holdsNoFile(a)) ghosts.push({ path: a.rel, of: b.rel, distance: editDistance(a.name, b.name) });
|
|
68
|
+
if (holdsNoFile(b)) ghosts.push({ path: b.rel, of: a.rel, distance: editDistance(a.name, b.name) });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
visit(tree);
|
|
73
|
+
return { empty, ghosts };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Entries git neither tracks nor ignores, as it prints them (a wholly untracked directory is one entry ending in '/'). */
|
|
77
|
+
export function untrackedEntries(repoRoot) {
|
|
78
|
+
const out = gitOutput(['ls-files', '-z', '--others', '--exclude-standard', '--directory', '--no-empty-directory'], { dir: repoRoot, maxBuffer: 256 * 1024 * 1024 });
|
|
79
|
+
return out.split('\0').filter(Boolean).map(posixPath);
|
|
80
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// hfs-view.mjs - the frozen view of one repository's HFS slots that the lint factories hand their rules
|
|
2
|
+
// (`settings.starci.hfs`). Both @starci/eslint-canon-be and @starci/eslint-canon-fe ship a byte copy of this file in their
|
|
3
|
+
// runtime/ bundle (packages/hfs/scripts/sync-runtime.mjs), so a path-scoped rule in either package asks the SAME resolver the
|
|
4
|
+
// architecture machine and `hfs check` use, and never tests a path with a regular expression of its own.
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { allowsFile } from './hfs-allows.mjs';
|
|
7
|
+
import { openHfs } from './hfs-slots.mjs';
|
|
8
|
+
|
|
9
|
+
const posix = (p) => String(p).replace(/\\/g, '/');
|
|
10
|
+
const isAbsoluteAny = (text) => path.isAbsolute(text) || /^[A-Za-z]:\//.test(text);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The view rules read, over an opened resolver.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} opened - The result of `openHfs` (manifest, repo and resolver methods).
|
|
16
|
+
* @param {string} repoRoot - The absolute repository root.
|
|
17
|
+
* @returns {object} The frozen view: profile, apps, connections, ruleParams, relative, classify, slotOf, tierOf, ownerOf, slot.
|
|
18
|
+
*/
|
|
19
|
+
export function hfsView(opened, repoRoot) {
|
|
20
|
+
const rel = (file) => {
|
|
21
|
+
const text = posix(file);
|
|
22
|
+
if (!isAbsoluteAny(text)) return text.replace(/^\.\//, '');
|
|
23
|
+
return posix(path.relative(repoRoot, file));
|
|
24
|
+
};
|
|
25
|
+
const slotCache = new Map();
|
|
26
|
+
const classify = (file) => {
|
|
27
|
+
const key = rel(file);
|
|
28
|
+
if (!slotCache.has(key)) slotCache.set(key, opened.classifyPath(key));
|
|
29
|
+
return slotCache.get(key);
|
|
30
|
+
};
|
|
31
|
+
return Object.freeze({
|
|
32
|
+
repoRoot,
|
|
33
|
+
profile: opened.repo.profile,
|
|
34
|
+
apps: opened.repo.apps,
|
|
35
|
+
connections: opened.repo.connections,
|
|
36
|
+
ruleParams: opened.ruleParams(),
|
|
37
|
+
/** The repository-relative, forward-slash form of a linted filename. */
|
|
38
|
+
relative: rel,
|
|
39
|
+
/** The classification of a file: `{ slot, tier, owner?, bindings? ... }` or a no-slot status. */
|
|
40
|
+
classify,
|
|
41
|
+
/** The slot id that owns a file, or null when no slot does. */
|
|
42
|
+
slotOf: (file) => classify(file).slot ?? null,
|
|
43
|
+
/** The tier of a file, or null. */
|
|
44
|
+
tierOf: (file) => opened.tierOf(rel(file)) ?? null,
|
|
45
|
+
/** The owner root of a file, or null. */
|
|
46
|
+
ownerOf: (file) => opened.ownerOf(rel(file))?.root ?? null,
|
|
47
|
+
/** What the slot that owns a file says about it: `{ slot, root, relative, allowed, entry?, forbiddenBy?, allows }`, or null when the slot names no `allows`. */
|
|
48
|
+
allows: (file) => allowsFile(opened, rel(file)),
|
|
49
|
+
/** A slot definition by id. */
|
|
50
|
+
slot: (id) => opened.slot(id),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The view of the repository on disk whose root is `repoRoot`, against the manifest under `runtimeRoot`.
|
|
56
|
+
*
|
|
57
|
+
* @param {{ runtimeRoot: string, repoRoot: string }} input - Where the manifest copy lives and the repository root.
|
|
58
|
+
* @returns {object} The frozen view.
|
|
59
|
+
*/
|
|
60
|
+
export const openHfsView = ({ runtimeRoot, repoRoot }) => hfsView(openHfs({ root: runtimeRoot, repoRoot }), repoRoot);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The view of an in-memory declaration (rule tests): no file is read except the manifest.
|
|
64
|
+
*
|
|
65
|
+
* @param {{ runtimeRoot: string, declaration: object, repoRoot: string }} input - Manifest root, hfs.json object, linted root.
|
|
66
|
+
* @returns {object} The frozen view.
|
|
67
|
+
*/
|
|
68
|
+
export const declaredHfsView = ({ runtimeRoot, declaration, repoRoot }) => hfsView(openHfs({ root: runtimeRoot, declaration }), repoRoot);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// json.mjs — the ledger's forgiving JSON read. Every one of these rows may hold text a peer wrote or a
|
|
2
|
+
// truncated payload, so a malformed column is the caller's fallback, never a throw. The parse of the
|
|
3
|
+
// literal 'null' is kept as null (callers that want {} instead write `parseJsonOr`).
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
|
|
6
|
+
/** `text` as JSON, or `fallback` when it does not parse. */
|
|
7
|
+
export const parseJson = (text, fallback = null) => {
|
|
8
|
+
try { return JSON.parse(text); } catch { return fallback; }
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** `text` as JSON, or `fallback` when it does not parse OR parses to null/undefined. Rows written
|
|
12
|
+
* as the literal 'null' read back as the fallback too, so a caller that wants an object gets one. */
|
|
13
|
+
export const parseJsonOr = (text, fallback = {}) => parseJson(text) ?? fallback;
|
|
14
|
+
|
|
15
|
+
/** A jobs/events row spread with `payload`: the row's payload_json parsed via parseJsonOr
|
|
16
|
+
* (`fallback` defaults to {}). Pass fallback null to keep a raw parseJson result, nulls included. */
|
|
17
|
+
export const withPayload = (row, fallback = {}) => (row ? { ...row, payload: parseJsonOr(row.payload_json, fallback) } : row);
|
|
18
|
+
|
|
19
|
+
/** `file` read and parsed as JSON, or `fallback` when the file is missing, unreadable or malformed. */
|
|
20
|
+
export const readJsonFile = (file, fallback = null) => {
|
|
21
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
|
|
22
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// language.mjs - the one home of "is this text English?" for source, comments, tests and docs (HFS_LANGUAGE_NOT_ENGLISH,
|
|
2
|
+
// BE_SOURCE_FORM, FE_SOURCE_FORM). Shared by the architecture machine (docs) and by both lint canons (each ships a byte
|
|
3
|
+
// copy in its runtime/ bundle, kept by packages/hfs/scripts/sync-runtime.mjs).
|
|
4
|
+
//
|
|
5
|
+
// Detection is structural on characters, never a word list: the letters Vietnamese adds to the Latin alphabet (a-breve, a-circumflex,
|
|
6
|
+
// d-stroke, e-circumflex, o-circumflex, o-horn, u-horn) and every vowel carrying a tone mark. Text is folded to NFC first, so a
|
|
7
|
+
// decomposed spelling (base letter + combining mark) is caught exactly like the precomposed one, and a loanword such as
|
|
8
|
+
// `naive`, `facade` or `Muller` (no tone mark, no Vietnamese letter) is never a hit.
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
/** One Vietnamese letter, precomposed (NFC): the Vietnamese-specific base letters, tone-marked vowels and the Latin Extended Additional block. */
|
|
12
|
+
export const SECOND_LANGUAGE_LETTER = /[À-ÃÈ-ÊÌÍÒ-ÕÙÚÝà-ãè-êìíò-õùúýĂăĐđĨĩŨũƠơƯưẠ-ỿ]/;
|
|
13
|
+
|
|
14
|
+
/** Whether `text` holds a Vietnamese letter, in NFC or NFD spelling. */
|
|
15
|
+
export const hasSecondLanguage = (text) => typeof text === 'string' && SECOND_LANGUAGE_LETTER.test(text.normalize('NFC'));
|
|
16
|
+
|
|
17
|
+
/** Every line of `text` with a Vietnamese letter: `[{ line, column }]`, both 1-based. */
|
|
18
|
+
export function secondLanguageHits(text) {
|
|
19
|
+
const hits = [];
|
|
20
|
+
String(text).split(/\r?\n/).forEach((raw, index) => {
|
|
21
|
+
const match = SECOND_LANGUAGE_LETTER.exec(raw.normalize('NFC'));
|
|
22
|
+
if (match) hits.push({ line: index + 1, column: match.index + 1 });
|
|
23
|
+
});
|
|
24
|
+
return hits;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The Vietnamese fields of one failure-code entry: the operator text the owner mandated for the failure-code catalog
|
|
29
|
+
* (scripts/checks/failure-codes.mjs types the entry with exactly these). causes_vi is the list companion of the three scalar fields.
|
|
30
|
+
*/
|
|
31
|
+
export const FAILURE_CODE_VIETNAMESE_FIELDS = Object.freeze(['title_vi', 'meaning_vi', 'nextStep_vi', 'causes_vi']);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The fields of a document that carry deliberate Vietnamese text, keyed by file: a declared field-level exception of that file's
|
|
35
|
+
* typed content, never a path pattern over content. `fields` are block keys (the key's own value and its list items are exempt);
|
|
36
|
+
* `flowFields` are keys inside an inline flow map (`{ vi: ..., en: ... }`), where only that key's value is exempt. A Vietnamese
|
|
37
|
+
* value under any other key of the same file is still a finding.
|
|
38
|
+
* - modules/kernel/failure-codes.yaml: the operator text the owner mandated for the failure-code catalog.
|
|
39
|
+
* - modules/ops/_labels.yaml: the op-label catalogue, one `{ vi, en }` pair per op (the `vi` field is the localized label).
|
|
40
|
+
* - modules/goal/archetypes.yaml: the Vietnamese phrase lexicons matched against owner input (the signal phrase sets, and the
|
|
41
|
+
* phrase lists of the archetype recognisers).
|
|
42
|
+
*/
|
|
43
|
+
export const DECLARED_VIETNAMESE_FIELDS = Object.freeze({
|
|
44
|
+
'modules/kernel/failure-codes.yaml': Object.freeze({ fields: FAILURE_CODE_VIETNAMESE_FIELDS }),
|
|
45
|
+
'modules/ops/_labels.yaml': Object.freeze({ flowFields: Object.freeze(['vi']) }),
|
|
46
|
+
'modules/goal/archetypes.yaml': Object.freeze({
|
|
47
|
+
fields: Object.freeze([
|
|
48
|
+
'buildIntent', 'canonIntent', 'e2eIntent', 'uatIntent', 'proofNegation', 'integrationIntent', 'integrationNegation', 'brandIntent',
|
|
49
|
+
'phrases', 'requires', 'excludes', 'backend', 'frontend', 'package',
|
|
50
|
+
]),
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The slots whose files may carry another language, and why: a catalog is product copy in two languages, and an i18n
|
|
56
|
+
* fixture reproduces a real localized string a parser or formatter must accept. Placement is the whole marker - there is no
|
|
57
|
+
* comment pragma and no path pattern. The slots are declared in knowledge/hfs/slots.yaml.
|
|
58
|
+
*/
|
|
59
|
+
export const LOCALIZED_TEXT_SLOTS = Object.freeze([
|
|
60
|
+
'be.domain.messages', 'be.feature.messages', 'be.tests.fixtures.i18n',
|
|
61
|
+
'fe.modules.i18n', 'fe.package.i18n',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/** Whether a document (Markdown or YAML) of `slot` is exempt: only catalogue and fixture DATA (YAML) is; Markdown is prose and never is. */
|
|
65
|
+
export const isLocalizedDataFile = (rel, slot) => LOCALIZED_TEXT_SLOTS.includes(slot) && /\.ya?ml$/.test(rel);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The key each line of a YAML text belongs to: a `key:` line owns itself, and every deeper-indented line after it (a list item,
|
|
69
|
+
* a folded block, a wrapped scalar) belongs to the same key. A line at or above the key's indent that is not a key belongs to none.
|
|
70
|
+
*/
|
|
71
|
+
export function yamlKeyOfEachLine(text) {
|
|
72
|
+
let key = null;
|
|
73
|
+
let keyIndent = -1;
|
|
74
|
+
return String(text).split(/\r?\n/).map((raw) => {
|
|
75
|
+
if (raw.trim() === '') return key;
|
|
76
|
+
const indent = raw.length - raw.trimStart().length;
|
|
77
|
+
const own = /^(\s*(?:-\s+)?)([A-Za-z_][\w-]*)\s*:(?:\s|$)/.exec(raw);
|
|
78
|
+
if (own) { key = own[2]; keyIndent = own[1].length; return key; }
|
|
79
|
+
// a list item may sit at the key's own indent (`key:` then `- item` at the same column)
|
|
80
|
+
if (indent > keyIndent || (indent === keyIndent && /^\s*-\s/.test(raw))) return key;
|
|
81
|
+
key = null;
|
|
82
|
+
keyIndent = -1;
|
|
83
|
+
return null;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The Vietnamese hits of a document, minus the declared field-level exceptions of `rel` (a repository-relative POSIX path). */
|
|
88
|
+
export function documentLanguageHits(rel, text) {
|
|
89
|
+
const declared = DECLARED_VIETNAMESE_FIELDS[rel];
|
|
90
|
+
const hits = secondLanguageHits(text);
|
|
91
|
+
if (!declared) return hits;
|
|
92
|
+
const keys = yamlKeyOfEachLine(text);
|
|
93
|
+
const lines = String(text).split(/\r?\n/);
|
|
94
|
+
return hits.filter((hit) => {
|
|
95
|
+
if (declared.fields?.includes(keys[hit.line - 1])) return false;
|
|
96
|
+
if (!declared.flowFields) return true;
|
|
97
|
+
// an inline flow map: drop the declared fields' values and judge what is left of the line
|
|
98
|
+
const rest = declared.flowFields.reduce((line, field) => line.replace(new RegExp(`\\b${field}\\s*:\\s*[^,}]*`, 'g'), ''), lines[hit.line - 1]);
|
|
99
|
+
return hasSecondLanguage(rest);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The file extensions of a prose document (Markdown and YAML). */
|
|
104
|
+
export const DOCUMENT_EXTENSIONS = Object.freeze(['.md', '.yaml', '.yml']);
|
|
105
|
+
|
|
106
|
+
/** Whether `rel` is a document a language check reads. */
|
|
107
|
+
export const isDocument = (rel) => DOCUMENT_EXTENSIONS.includes(path.posix.extname(rel));
|
|
@@ -7,6 +7,8 @@ const WIN = process.platform === 'win32';
|
|
|
7
7
|
export const slash = (p) => String(p ?? '').replaceAll('\\', '/');
|
|
8
8
|
/** A relative path in git's spelling: forward slashes, no leading './'. */
|
|
9
9
|
export const posixPath = (p) => slash(p).replace(/^\.\//, '');
|
|
10
|
+
/** Whether posix `file` is `prefix` itself or under it (`file === prefix || file` starts with `prefix/`). */
|
|
11
|
+
export const sameOrUnder = (file, prefix) => file === prefix || file.startsWith(`${prefix}/`);
|
|
10
12
|
/** `p` case-folded where the filesystem ignores case (Windows). */
|
|
11
13
|
export const foldCase = (p) => (WIN ? p.toLowerCase() : p);
|
|
12
14
|
/** Whether two spellings name the same path on this host's filesystem. */
|