@starci/hfs 1.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.
Files changed (74) hide show
  1. package/README.md +38 -0
  2. package/bin/hfs.mjs +100 -0
  3. package/package.json +28 -0
  4. package/runtime/engine/runtime-root.mjs +32 -0
  5. package/runtime/engine/yaml.mjs +161 -0
  6. package/runtime/knowledge/hfs/canon-pins.yaml +212 -0
  7. package/runtime/knowledge/hfs/slots.yaml +842 -0
  8. package/runtime/modules/kernel/failure-codes.yaml +169 -0
  9. package/runtime/scripts/lib/glob.mjs +23 -0
  10. package/runtime/scripts/lib/hfs-check.mjs +305 -0
  11. package/runtime/scripts/lib/hfs-slots.mjs +675 -0
  12. package/runtime/scripts/lib/path-key.mjs +15 -0
  13. package/sync/cli.mjs +15 -0
  14. package/sync/hygiene.mjs +92 -0
  15. package/sync/index.mjs +224 -0
  16. package/sync/skeleton.mjs +54 -0
  17. package/sync/sonar-key.mjs +45 -0
  18. package/templates/be/e2e.yml +21 -0
  19. package/templates/be/gitignore +2 -0
  20. package/templates/be/pre-commit +8 -0
  21. package/templates/be/skeleton/apps/__app__/src/__app__.composition.spec.ts +31 -0
  22. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +7 -0
  23. package/templates/be/skeleton/apps/__app__/src/app.module.ts +23 -0
  24. package/templates/be/skeleton/apps/__app__/src/main.ts +20 -0
  25. package/templates/be/skeleton/src/features/system-health/index.ts +1 -0
  26. package/templates/be/skeleton/src/features/system-health/system-health.module.ts +6 -0
  27. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.spec.ts +13 -0
  28. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +18 -0
  29. package/templates/be/skeleton/src/features/system-health/transport/http/system-health-http.module.ts +10 -0
  30. package/templates/be/skeleton/src/modules/platform/config/env-source.spec.ts +36 -0
  31. package/templates/be/skeleton/src/modules/platform/config/env-source.ts +40 -0
  32. package/templates/be/skeleton/src/modules/platform/config/errors/config.error.ts +21 -0
  33. package/templates/be/skeleton/src/modules/platform/config/index.ts +5 -0
  34. package/templates/be/skeleton/src/modules/platform/config/server.config.spec.ts +19 -0
  35. package/templates/be/skeleton/src/modules/platform/config/server.config.ts +15 -0
  36. package/templates/be/skeleton/src/modules/platform/config/server.options.ts +8 -0
  37. package/templates/be/skeleton/src/modules/platform/errors/domain-error.spec.ts +15 -0
  38. package/templates/be/skeleton/src/modules/platform/errors/domain-error.ts +11 -0
  39. package/templates/be/skeleton/src/modules/platform/errors/error.filter.spec.ts +38 -0
  40. package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +24 -0
  41. package/templates/be/skeleton/src/modules/platform/errors/index.ts +2 -0
  42. package/templates/be/skeleton/src/modules/platform/logging/index.ts +5 -0
  43. package/templates/be/skeleton/src/modules/platform/logging/json-logger.spec.ts +33 -0
  44. package/templates/be/skeleton/src/modules/platform/logging/json-logger.ts +35 -0
  45. package/templates/be/skeleton/src/modules/platform/logging/log-id.ts +9 -0
  46. package/templates/be/skeleton/src/modules/platform/logging/logger.port.ts +19 -0
  47. package/templates/be/skeleton/src/modules/platform/logging/logging.module.ts +11 -0
  48. package/templates/be/sonar-project.properties +10 -0
  49. package/templates/be/starciwork.gitignore +39 -0
  50. package/templates/common/ci.yml +50 -0
  51. package/templates/common/codecov.yml +13 -0
  52. package/templates/common/gitignore.base +33 -0
  53. package/templates/common/pre-push +5 -0
  54. package/templates/fe/e2e.yml +22 -0
  55. package/templates/fe/gitignore +3 -0
  56. package/templates/fe/pre-commit +7 -0
  57. package/templates/fe/skeleton/apps/__app__/next.config.ts +11 -0
  58. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/error.tsx +22 -0
  59. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +31 -0
  60. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +15 -0
  61. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/page.tsx +27 -0
  62. package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +24 -0
  63. package/templates/fe/skeleton/apps/__app__/src/app/globals.css +1 -0
  64. package/templates/fe/skeleton/apps/__app__/src/app/health/live/route.spec.ts +10 -0
  65. package/templates/fe/skeleton/apps/__app__/src/app/health/live/route.ts +5 -0
  66. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/config.ts +8 -0
  67. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages/vi.json +19 -0
  68. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages.spec.ts +27 -0
  69. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/navigation.ts +5 -0
  70. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/request.ts +13 -0
  71. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/routing.spec.ts +10 -0
  72. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/routing.ts +9 -0
  73. package/templates/fe/skeleton/apps/__app__/src/proxy.ts +10 -0
  74. package/templates/fe/sonar-project.properties +11 -0
@@ -0,0 +1,36 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { EnvSource } from "./env-source"
5
+ import { ConfigError } from "./errors/config.error"
6
+
7
+ describe("EnvSource", () => {
8
+ it("reads a key and treats an empty value as unset", () => {
9
+ const env = EnvSource.of({ PORT: "8080", EMPTY: "" })
10
+ expect(env.optional("PORT")).toBe("8080")
11
+ expect(env.optional("EMPTY")).toBeUndefined()
12
+ expect(env.optional("ABSENT")).toBeUndefined()
13
+ })
14
+
15
+ it("refuses a missing required key by naming it", () => {
16
+ const failure = () => EnvSource.of({}).required("DATABASE_URL")
17
+ expect(failure).toThrow(ConfigError)
18
+ expect(failure).toThrow("Configuration key DATABASE_URL is required and has no default.")
19
+ })
20
+
21
+ it("resolves <KEY>_FILE to the trimmed content of the file", () => {
22
+ const dir = mkdtempSync(join(tmpdir(), "env-source-"))
23
+ try {
24
+ const path = join(dir, "token")
25
+ writeFileSync(path, "value-from-file\n")
26
+ expect(EnvSource.of({ API_TOKEN_FILE: path }).required("API_TOKEN")).toBe("value-from-file")
27
+ } finally {
28
+ rmSync(dir, { recursive: true, force: true })
29
+ }
30
+ })
31
+
32
+ it("names the _FILE key, not a path or a value, when the file cannot be read", () => {
33
+ const failure = () => EnvSource.of({ API_TOKEN_FILE: join(tmpdir(), "no-such-dir", "token") }).optional("API_TOKEN")
34
+ expect(failure).toThrow("Configuration key API_TOKEN_FILE points at a file that cannot be read.")
35
+ })
36
+ })
@@ -0,0 +1,40 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { ConfigError } from "./errors/config.error"
3
+
4
+ type Values = Readonly<Record<string, string | undefined>>
5
+
6
+ /** The only reader of the process environment. Every key may instead be supplied as `<KEY>_FILE`, a path to its value. */
7
+ export class EnvSource {
8
+ private constructor(private readonly values: Values) {}
9
+
10
+ /** The live process environment; called once, from `main.ts`. */
11
+ static fromProcess(): EnvSource {
12
+ return new EnvSource(process.env)
13
+ }
14
+
15
+ /** A fixed snapshot, for specs and tools. */
16
+ static of(values: Values): EnvSource {
17
+ return new EnvSource(values)
18
+ }
19
+
20
+ /** The value of `key`, or `undefined` when it is unset or empty. */
21
+ optional(key: string): string | undefined {
22
+ const path = this.values[`${key}_FILE`]
23
+ if (path !== undefined && path !== "") {
24
+ try {
25
+ return readFileSync(path, "utf8").trim()
26
+ } catch (cause) {
27
+ throw new ConfigError("CONFIG_FILE_UNREADABLE", `${key}_FILE`, { cause })
28
+ }
29
+ }
30
+ const value = this.values[key]
31
+ return value === undefined || value === "" ? undefined : value
32
+ }
33
+
34
+ /** The value of `key`; a missing key stops the boot with an error that names it. */
35
+ required(key: string): string {
36
+ const value = this.optional(key)
37
+ if (value === undefined) throw new ConfigError("CONFIG_KEY_MISSING", key)
38
+ return value
39
+ }
40
+ }
@@ -0,0 +1,21 @@
1
+ import { DomainError } from "@modules/platform/errors"
2
+
3
+ /** Why a configuration key was refused. */
4
+ export type ConfigErrorCode = "CONFIG_KEY_MISSING" | "CONFIG_KEY_INVALID" | "CONFIG_FILE_UNREADABLE"
5
+
6
+ const REASONS: Readonly<Record<ConfigErrorCode, string>> = {
7
+ CONFIG_KEY_MISSING: "is required and has no default",
8
+ CONFIG_KEY_INVALID: "does not hold a valid value",
9
+ CONFIG_FILE_UNREADABLE: "points at a file that cannot be read",
10
+ }
11
+
12
+ /** Startup configuration refusal: names the key and the rule it broke, never the value it was given. */
13
+ export class ConfigError extends DomainError {
14
+ /** The environment key that was refused. */
15
+ readonly key: string
16
+
17
+ constructor(code: ConfigErrorCode, key: string, options?: { readonly cause?: unknown }) {
18
+ super(code, `Configuration key ${key} ${REASONS[code]}.`, options)
19
+ this.key = key
20
+ }
21
+ }
@@ -0,0 +1,5 @@
1
+ export { EnvSource } from "./env-source"
2
+ export { ConfigError } from "./errors/config.error"
3
+ export { parseServerConfig } from "./server.config"
4
+ export { SERVER_OPTIONS } from "./server.options"
5
+ export type { ServerOptions } from "./server.options"
@@ -0,0 +1,19 @@
1
+ import { EnvSource } from "./env-source"
2
+ import { ConfigError } from "./errors/config.error"
3
+ import { parseServerConfig } from "./server.config"
4
+
5
+ describe("parseServerConfig", () => {
6
+ it("falls back to port 3000 when PORT is unset", () => {
7
+ expect(parseServerConfig(EnvSource.of({}))).toEqual({ port: 3000 })
8
+ })
9
+
10
+ it("accepts an integer PORT inside the TCP range", () => {
11
+ expect(parseServerConfig(EnvSource.of({ PORT: "8080" }))).toEqual({ port: 8080 })
12
+ })
13
+
14
+ it.each(["80a", "0", "70000", "1.5"])("refuses PORT=%s by naming the key only", (port) => {
15
+ const failure = () => parseServerConfig(EnvSource.of({ PORT: port }))
16
+ expect(failure).toThrow(ConfigError)
17
+ expect(failure).toThrow("Configuration key PORT does not hold a valid value.")
18
+ })
19
+ })
@@ -0,0 +1,15 @@
1
+ import type { EnvSource } from "./env-source"
2
+ import { ConfigError } from "./errors/config.error"
3
+ import type { ServerOptions } from "./server.options"
4
+
5
+ const DEFAULT_PORT = 3000
6
+ const MAX_PORT = 65535
7
+
8
+ /** Parses the server keys of an environment; a malformed PORT stops the boot. */
9
+ export const parseServerConfig = (env: EnvSource): ServerOptions => {
10
+ const raw = env.optional("PORT")
11
+ if (raw === undefined) return { port: DEFAULT_PORT }
12
+ const port = Number(raw)
13
+ if (!Number.isInteger(port) || port < 1 || port > MAX_PORT) throw new ConfigError("CONFIG_KEY_INVALID", "PORT")
14
+ return { port }
15
+ }
@@ -0,0 +1,8 @@
1
+ /** The typed server configuration, validated once before the listener starts. */
2
+ export interface ServerOptions {
3
+ /** TCP port the HTTP listener binds. */
4
+ readonly port: number
5
+ }
6
+
7
+ /** Injection token under which the app module provides its {@link ServerOptions}. */
8
+ export const SERVER_OPTIONS = "SERVER_OPTIONS"
@@ -0,0 +1,15 @@
1
+ import { DomainError } from "./domain-error"
2
+
3
+ class SampleError extends DomainError {}
4
+
5
+ describe("DomainError", () => {
6
+ it("carries the code, the message and the cause, and is named after its subclass", () => {
7
+ const cause = new SyntaxError("bad input")
8
+ const error = new SampleError("SAMPLE_FAILED", "The sample failed.", { cause })
9
+ expect(error.code).toBe("SAMPLE_FAILED")
10
+ expect(error.message).toBe("The sample failed.")
11
+ expect(error.cause).toBe(cause)
12
+ expect(error.name).toBe("SampleError")
13
+ expect(error).toBeInstanceOf(Error)
14
+ })
15
+ })
@@ -0,0 +1,11 @@
1
+ /** The base of every error a capability declares: a stable machine code, a sentence for people, and the cause. */
2
+ export abstract class DomainError extends Error {
3
+ /** Stable machine code that transports map to a status and logs group by. */
4
+ readonly code: string
5
+
6
+ constructor(code: string, message: string, options?: { readonly cause?: unknown }) {
7
+ super(message, options)
8
+ this.name = new.target.name
9
+ this.code = code
10
+ }
11
+ }
@@ -0,0 +1,38 @@
1
+ import { ArgumentsHost, HttpException, HttpStatus } from "@nestjs/common"
2
+ import { HttpArgumentsHost } from "@nestjs/common/interfaces"
3
+ import { mock } from "@starci/jest-preset/mock"
4
+ import type { Response } from "express"
5
+ import { Logger, LogId } from "@modules/platform/logging"
6
+ import { ErrorFilter } from "./error.filter"
7
+
8
+ const arrange = () => {
9
+ const response = mock<Response>()
10
+ response.status.mockReturnValue(response)
11
+ const http = mock<HttpArgumentsHost>()
12
+ http.getResponse.mockReturnValue(response)
13
+ const host = mock<ArgumentsHost>()
14
+ host.switchToHttp.mockReturnValue(http)
15
+ const logger = mock<Logger>()
16
+ return { response, host, logger, filter: new ErrorFilter(logger) }
17
+ }
18
+
19
+ describe("ErrorFilter", () => {
20
+ it("answers an unknown failure with a 500 that names no detail, and logs it once", () => {
21
+ const { response, host, logger, filter } = arrange()
22
+ filter.catch(new TypeError("secret detail"), host)
23
+ expect(response.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR)
24
+ expect(response.json).toHaveBeenCalledWith({ code: "internal_error" })
25
+ expect(logger.error).toHaveBeenCalledTimes(1)
26
+ expect(logger.error).toHaveBeenCalledWith(LogId.RequestFailed, {
27
+ status: HttpStatus.INTERNAL_SERVER_ERROR,
28
+ failure: { name: "TypeError" },
29
+ })
30
+ })
31
+
32
+ it("keeps the status of a framework exception and answers with its status code only", () => {
33
+ const { response, host, filter } = arrange()
34
+ filter.catch(new HttpException("Not Found", HttpStatus.NOT_FOUND), host)
35
+ expect(response.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND)
36
+ expect(response.json).toHaveBeenCalledWith({ code: "http_404" })
37
+ })
38
+ })
@@ -0,0 +1,24 @@
1
+ import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from "@nestjs/common"
2
+ import type { Response } from "express"
3
+ import { Logger, LogId } from "@modules/platform/logging"
4
+ import { DomainError } from "./domain-error"
5
+
6
+ /** What a log line says about a failure: its name and code, never the stack or the request. */
7
+ const describeFailure = (exception: unknown): Readonly<Record<string, unknown>> =>
8
+ exception instanceof DomainError
9
+ ? { name: exception.name, code: exception.code }
10
+ : { name: exception instanceof Error ? exception.name : typeof exception }
11
+
12
+ /** The one filter of an app: logs every failure once and answers with a status and a code that reveal nothing else. */
13
+ @Catch()
14
+ export class ErrorFilter implements ExceptionFilter {
15
+ constructor(private readonly logger: Logger) {}
16
+
17
+ /** Answers the request; an unknown failure is a 500 whose body names no detail. */
18
+ catch(exception: unknown, host: ArgumentsHost): void {
19
+ const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR
20
+ this.logger.error(LogId.RequestFailed, { status, failure: describeFailure(exception) })
21
+ const code = status === HttpStatus.INTERNAL_SERVER_ERROR ? "internal_error" : `http_${status}`
22
+ host.switchToHttp().getResponse<Response>().status(status).json({ code })
23
+ }
24
+ }
@@ -0,0 +1,2 @@
1
+ export { DomainError } from "./domain-error"
2
+ export { ErrorFilter } from "./error.filter"
@@ -0,0 +1,5 @@
1
+ export { createJsonLogger } from "./json-logger"
2
+ export { LogId } from "./log-id"
3
+ export { Logger } from "./logger.port"
4
+ export type { LogPayload } from "./logger.port"
5
+ export { LoggingModule } from "./logging.module"
@@ -0,0 +1,33 @@
1
+ import { Writable } from "node:stream"
2
+ import { createJsonLogger } from "./json-logger"
3
+ import { LogId } from "./log-id"
4
+
5
+ const capture = () => {
6
+ const lines: string[] = []
7
+ const sink = new Writable({
8
+ write(chunk: Buffer, _encoding, done) {
9
+ lines.push(chunk.toString())
10
+ done()
11
+ },
12
+ })
13
+ return { lines, logger: createJsonLogger(sink) }
14
+ }
15
+
16
+ describe("createJsonLogger", () => {
17
+ it("writes one JSON line carrying the level, the enum identity, a timestamp and the payload", () => {
18
+ const { lines, logger } = capture()
19
+ logger.error(LogId.StartupFailed, { code: "CONFIG_KEY_MISSING" })
20
+ expect(lines).toHaveLength(1)
21
+ expect(lines[0].endsWith("\n")).toBe(true)
22
+ expect(JSON.parse(lines[0])).toMatchObject({ level: "error", id: "server.startup_failed", code: "CONFIG_KEY_MISSING" })
23
+ expect(JSON.parse(lines[0]).time).toEqual(expect.any(String))
24
+ })
25
+
26
+ it("maps every level to its own name", () => {
27
+ const { lines, logger } = capture()
28
+ logger.debug(LogId.ServerStarted)
29
+ logger.info(LogId.ServerStarted)
30
+ logger.warn(LogId.ServerStarted)
31
+ expect(lines.map((line) => JSON.parse(line).level)).toEqual(["debug", "info", "warn"])
32
+ })
33
+ })
@@ -0,0 +1,35 @@
1
+ import type { Writable } from "node:stream"
2
+ import type { LogId } from "./log-id"
3
+ import { Logger, LogPayload } from "./logger.port"
4
+
5
+ type Level = "debug" | "info" | "warn" | "error"
6
+
7
+ /** The default adapter: one JSON object per line on a stream, so a collector reads it without parsing prose. */
8
+ class JsonLogger extends Logger {
9
+ constructor(private readonly sink: Writable) {
10
+ super()
11
+ }
12
+
13
+ debug(id: LogId, payload?: LogPayload): void {
14
+ this.write("debug", id, payload)
15
+ }
16
+
17
+ info(id: LogId, payload?: LogPayload): void {
18
+ this.write("info", id, payload)
19
+ }
20
+
21
+ warn(id: LogId, payload?: LogPayload): void {
22
+ this.write("warn", id, payload)
23
+ }
24
+
25
+ error(id: LogId, payload?: LogPayload): void {
26
+ this.write("error", id, payload)
27
+ }
28
+
29
+ private write(level: Level, id: LogId, payload?: LogPayload): void {
30
+ this.sink.write(`${JSON.stringify({ level, id, time: new Date().toISOString(), ...payload })}\n`)
31
+ }
32
+ }
33
+
34
+ /** Builds the JSON logger; `sink` is stdout unless a caller (a spec, a process manager) supplies its own stream. */
35
+ export const createJsonLogger = (sink: Writable = process.stdout): Logger => new JsonLogger(sink)
@@ -0,0 +1,9 @@
1
+ /** Every log line is named by a member of this enum, so a line can be found from the code that wrote it. */
2
+ export enum LogId {
3
+ /** The HTTP listener is bound and the app serves requests. */
4
+ ServerStarted = "server.started",
5
+ /** The app failed before it could serve; the process exits non-zero. */
6
+ StartupFailed = "server.startup_failed",
7
+ /** A request ended in a failure the app's filter answered. */
8
+ RequestFailed = "http.request_failed",
9
+ }
@@ -0,0 +1,19 @@
1
+ import type { LogId } from "./log-id"
2
+
3
+ /** The structured data of one log line; values are plain data, never a request, a token or a secret. */
4
+ export type LogPayload = Readonly<Record<string, unknown>>
5
+
6
+ /** The logging port: capabilities log through it with an enum identity and a structured payload, never `console`. */
7
+ export abstract class Logger {
8
+ /** Detail for people debugging one environment. */
9
+ abstract debug(id: LogId, payload?: LogPayload): void
10
+
11
+ /** A normal event worth keeping. */
12
+ abstract info(id: LogId, payload?: LogPayload): void
13
+
14
+ /** Something unexpected the app recovered from. */
15
+ abstract warn(id: LogId, payload?: LogPayload): void
16
+
17
+ /** A failure the app could not recover from in place. */
18
+ abstract error(id: LogId, payload?: LogPayload): void
19
+ }
@@ -0,0 +1,11 @@
1
+ import { Global, Module } from "@nestjs/common"
2
+ import { createJsonLogger } from "./json-logger"
3
+ import { Logger } from "./logger.port"
4
+
5
+ /** Provides the logging port to every capability; one of the three platform modules that may be global. */
6
+ @Global()
7
+ @Module({
8
+ providers: [{ provide: Logger, useFactory: (): Logger => createJsonLogger() }],
9
+ exports: [Logger],
10
+ })
11
+ export class LoggingModule {}
@@ -0,0 +1,10 @@
1
+ sonar.projectKey={{sonarKey}}
2
+ sonar.host.url=https://sonar.starci.org
3
+ sonar.sourceEncoding=UTF-8
4
+ sonar.sources=apps,src
5
+ sonar.tests=apps,src
6
+ sonar.exclusions={{sonarExclusions}}
7
+ sonar.test.inclusions=**/*.spec.ts
8
+ sonar.coverage.exclusions={{sonarCoverageExclusions}}
9
+ sonar.javascript.lcov.reportPaths=coverage/lcov.info
10
+ sonar.nodejs.maxspace=8192
@@ -0,0 +1,39 @@
1
+ # StarCi .starciwork: product content only (ARCHITECTURE-DB §5.1, work-layout.yaml shape.productPaths).
2
+ # Agent output (reports, checks, captures, draw rounds, UAT runs, logs, ledgers, worktrees) lives in
3
+ # runtime.sqlite rows and blobs outside the repository.
4
+ /*
5
+ !/.gitignore
6
+ !/.gitattributes
7
+ !/workspace.yaml
8
+ !/index.yaml
9
+ !/brand/
10
+ !/shell/
11
+ !/_resources/
12
+ !/_derived/
13
+ !/features/
14
+ /brand/*
15
+ !/brand/index.yaml
16
+ !/brand/evidence.yaml
17
+ !/brand/assets/
18
+ /shell/*
19
+ !/shell/index.yaml
20
+ !/shell/evidence.yaml
21
+ /_resources/*
22
+ !/_resources/environments/
23
+ !/_resources/identities/
24
+ !/_resources/fixtures/
25
+ !/_resources/runtimes/
26
+ !/_resources/grammar-captures/
27
+ /_derived/*
28
+ !/_derived/index.yaml
29
+ !/_derived/frontier.md
30
+ !/_derived/critique.yaml
31
+ !/_derived/critique.md
32
+ evidence/
33
+ runs/
34
+ draw-loop/
35
+ operations/
36
+ report*.json
37
+ *.tmp.json
38
+ /features/**/impl/**/assets/
39
+ /shell/assets/
@@ -0,0 +1,50 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+ id-token: write
11
+
12
+ jobs:
13
+ ci:
14
+ runs-on: ubuntu-latest
15
+ env:
16
+ SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: actions/setup-node@v4
20
+ with:
21
+ node-version: {{nodeMajor}}
22
+ cache: npm
23
+ - run: npm ci
24
+ - name: hfs sync
25
+ run: npx hfs sync --check
26
+ - name: hfs check
27
+ run: npx hfs check
28
+ - name: lint
29
+ run: npm run lint:check
30
+ - name: typecheck
31
+ run: npm run typecheck
32
+ - name: unit
33
+ run: npm run test:ci
34
+ - name: build
35
+ run: npm run build
36
+ - uses: codecov/codecov-action@v5
37
+ with:
38
+ files: coverage/lcov.info
39
+ disable_search: true
40
+ fail_ci_if_error: true
41
+ use_oidc: true
42
+ - uses: SonarSource/sonarqube-scan-action@v7
43
+ if: env.SONAR_TOKEN != ''
44
+ env:
45
+ SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
46
+ - uses: SonarSource/sonarqube-quality-gate-action@v1
47
+ if: env.SONAR_TOKEN != ''
48
+ timeout-minutes: 10
49
+ env:
50
+ SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
@@ -0,0 +1,13 @@
1
+ coverage:
2
+ status:
3
+ project:
4
+ default:
5
+ target: 80%
6
+ informational: false
7
+ patch:
8
+ default:
9
+ target: 90%
10
+ informational: false
11
+ comment: false
12
+ ignore:
13
+ - {{codecovIgnore}}
@@ -0,0 +1,33 @@
1
+ # Build output and tool output the tools need in place.
2
+ node_modules/
3
+ dist/
4
+ coverage/
5
+ test-results/
6
+ playwright-report/
7
+ *.tsbuildinfo
8
+ .scannerwork/
9
+ .turbo/
10
+ .tools/
11
+ # Generated code is rebuilt by prebuild / pretypecheck, never tracked.
12
+ __generated__/
13
+ # Plaintext secrets never enter the tree; the sealed form is .starcistacks/<env>/secrets/<slug>.enc.
14
+ .env
15
+ .env.*
16
+ !.env.example
17
+ .secrets/
18
+ *.pem
19
+ # Agent output lives in the scratchpad and the blob store, never in the repository.
20
+ report*.json
21
+ *.tmp.json
22
+ *-lint.json
23
+ lf.json
24
+ lt.json
25
+ %*%
26
+ nul
27
+ .qwen*/
28
+ .artifacts/
29
+ .tmp-*
30
+ # Worktrees live under D:/starci-lanes, never inside the repository.
31
+ .worktrees/
32
+ # StarCi runtime state of the repository (ledger), never tracked.
33
+ .starci/
@@ -0,0 +1,5 @@
1
+ # Push gate: types, lint, the architecture check on the owners this push touches, unit specs affected since main. Never e2e.
2
+ npm run typecheck
3
+ npm run lint:check
4
+ npx hfs check --fast
5
+ npm run test:affected
@@ -0,0 +1,22 @@
1
+ # End-to-end runs are manual: nothing triggers this workflow but a person dispatching it.
2
+ name: e2e
3
+
4
+ on:
5
+ workflow_dispatch:
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ e2e:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-node@v4
16
+ with:
17
+ node-version: {{nodeMajor}}
18
+ cache: npm
19
+ - run: npm ci
20
+ - run: npx playwright install --with-deps chromium
21
+ - name: e2e
22
+ run: npm run test:e2e
@@ -0,0 +1,3 @@
1
+ {{> common/gitignore.base}}
2
+ .next/
3
+ next-env.d.ts
@@ -0,0 +1,7 @@
1
+ # Commit gate: staged lint, types, the unit specs the staged files touch. Never e2e.
2
+ npx lint-staged
3
+ npm run typecheck
4
+ staged=$(git diff --cached --name-only --diff-filter=ACMR -- '*.ts' '*.tsx' | grep -v -E '(^|/)e2e/|\.e2e-spec\.ts$' || true)
5
+ if [ -n "$staged" ]; then
6
+ npx vitest related --run --passWithNoTests $staged
7
+ fi
@@ -0,0 +1,11 @@
1
+ import type { NextConfig } from "next"
2
+ import createNextIntlPlugin from "next-intl/plugin"
3
+
4
+ const withNextIntl = createNextIntlPlugin("./src/modules/i18n/request.ts")
5
+
6
+ /** Next config of the {{app}} app: next-intl wired to the request config, Turbopack rooted at the repository. */
7
+ const nextConfig: NextConfig = {
8
+ turbopack: { root: process.cwd() },
9
+ }
10
+
11
+ export default withNextIntl(nextConfig)
@@ -0,0 +1,22 @@
1
+ "use client"
2
+
3
+ import { useTranslations } from "next-intl"
4
+
5
+ interface ErrorPageProps {
6
+ readonly reset: () => void
7
+ }
8
+
9
+ /** Boundary of the locale segment: a render failure shows this instead of a blank page, and retry re-renders the segment. */
10
+ const ErrorPage = ({ reset }: ErrorPageProps) => {
11
+ const t = useTranslations("errors.page")
12
+ return (
13
+ <main role="alert">
14
+ <h1>{t("title")}</h1>
15
+ <button type="button" onClick={reset}>
16
+ {t("retry")}
17
+ </button>
18
+ </main>
19
+ )
20
+ }
21
+
22
+ export default ErrorPage
@@ -0,0 +1,31 @@
1
+ import { hasLocale, NextIntlClientProvider } from "next-intl"
2
+ import { getMessages, setRequestLocale } from "next-intl/server"
3
+ import { notFound } from "next/navigation"
4
+ import type { ReactNode } from "react"
5
+ import { routing } from "../../modules/i18n/routing"
6
+ import "../globals.css"
7
+
8
+ interface LocaleLayoutProps {
9
+ readonly children: ReactNode
10
+ readonly params: Promise<{ readonly locale: string }>
11
+ }
12
+
13
+ /** One static shell per served locale. */
14
+ export const generateStaticParams = () => routing.locales.map((locale) => ({ locale }))
15
+
16
+ /** Root layout: sets `<html lang>` from the route segment and gives client components only the `errors` namespace. */
17
+ const LocaleLayout = async ({ children, params }: LocaleLayoutProps) => {
18
+ const { locale } = await params
19
+ if (!hasLocale(routing.locales, locale)) notFound()
20
+ setRequestLocale(locale)
21
+ const messages = await getMessages()
22
+ return (
23
+ <html lang={locale}>
24
+ <body className="min-h-dvh">
25
+ <NextIntlClientProvider messages={{ errors: messages.errors }}>{children}</NextIntlClientProvider>
26
+ </body>
27
+ </html>
28
+ )
29
+ }
30
+
31
+ export default LocaleLayout
@@ -0,0 +1,15 @@
1
+ import { getTranslations } from "next-intl/server"
2
+ import { Link } from "../../modules/i18n/navigation"
3
+
4
+ /** Shown when a route calls `notFound()`. */
5
+ const NotFound = async () => {
6
+ const t = await getTranslations("notFound")
7
+ return (
8
+ <main>
9
+ <h1>{t("title")}</h1>
10
+ <Link href="/">{t("home")}</Link>
11
+ </main>
12
+ )
13
+ }
14
+
15
+ export default NotFound