Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
- 実装完了の条件: 変更したパッケージに対応する CI ワークフロー(`.github/workflows/ci-{api,client,shared,task}.yml`)に書かれているコマンドをローカルで実行し、build/lint/test がエラーなく通ること。CI と異なるコマンドを実行して「通った」と判断しない
- 特定の作業をするときだけ必要な詳細ガイドラインは `agents/` 配下に置き、この AGENTS.md からは「いつ読むか」を1行で指す

## Logging Guidelines

- `logger.*` を呼ぶとき、logger / redact 実装や access log / error handler を変更するときは `agents/logging.md` を読む(console の単一入口、sensitive field の redaction、error の安全な serialize、requestId の引き回し)

## Monorepo Guidelines

- 複数パッケージ(api/bin/client/shared)の実装がたまたま似ていても、それだけを理由に共通化しない(ルートに tsconfig.base.json を作って extends させる、logger/fetcher のような実装コードを shared に抽出する、など)
Expand Down
3 changes: 3 additions & 0 deletions agents/lint-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@

- `coding-style/no-process-env-outside-config` → `process.env` の参照を config ファイル(`config.ts` / `config.server.ts` 等)に集約する(テストファイルは対象外)
- `coding-style/enforce-zod-entrypoint` → zod の entrypoint をパッケージごとに強制する(client は `zod/mini`、api は `zod`)
- `coding-style/enforce-logger-literal` → `logger.*` の第1引数と `meta` を object literal(spread / computed key なし)に限定する。何をログへ載せてよいかは logger の closed event schema(型)が決め、lint は excess property check が確実に効く形だけを保証する(テストファイルは runtime の最終防衛を検証するため対象外。詳細は `agents/logging.md`)

builtin ルールでは `no-console` を `packages/{api,client,task}/src` に対して error にし、logger 実装本体とテストだけ `vite.config.ts` の override で除外している(structured log の単一入口を保つため)。

ルールを追加・変更したら、違反例・準拠例を `lint-rules/run-tests.ts` に追加し、`npm run test-lint-rules` を通すこと(CI では `ci-lint-rules.yml` が実行する)。

Expand Down
11 changes: 11 additions & 0 deletions agents/logging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Logging Guidelines

ログ出力を伴うコード(`logger.*` の呼び出し、logger / redact 実装、access log / error handler)を書くときの原則。機械検査の詳細は `agents/lint-rules.md` を、何を meta へ載せられるかは各 logger の `LogEvents` 型を参照する。

- application code は `console.*` を直接呼ばず、パッケージごとの logger(api: `packages/api/src/lib/logger/index.ts`、client: `packages/client/src/app/_lib/logger/index.ts`、task: `packages/task/src/lib/logger/index.ts`)を単一入口にする。dev 専用 CLI(`packages/bin`)は人間が読む出力なので対象外
- logger の public API は closed event schema。meta を持てるのは各 logger の `LogEvents` に定義した label だけで、field は primitive のみ。新しい情報をログへ載せたいときは、値を渡す前に `LogEvents` へ label / field を追加する
- `LogEvents` に secret / credential(authorization / cookie / token 等)を表す field や、request / response / headers / body を丸ごと表す field を追加しない。必要な primitive field だけを schema に起こす
- 何を渡してよいかの判断は型に寄せる。型検査に失敗する値を assertion で無理に通さない。runtime の `redactMeta()`(sensitive key 名 → `[REDACTED]`、primitive 以外 → `[UNSUPPORTED]`)は型をすり抜けた値への最後の防波堤であって、設計上の免罪符ではない
- `error` は `serializeError()` が name / message / stack / cause の限定 shape へ変換する。`{ ...error }` のような spread は、external SDK が enumerable property に詰めた credential / request payload を持ち出すため行わない
- `requestId` で access log と application log を突き合わせる。api では `accessLogMiddleware` が発行した値を `c.get('requestId')` から渡す。client / task は追跡したい単位の ID を呼び出し側が明示的に渡す
- `logger/` は api / client / task がそれぞれ自己完結して持ち、shared へ切り出さない。実行コンテキストが異なるため(AGENTS.md の Monorepo Guidelines)
4 changes: 3 additions & 1 deletion lint-rules/coding-style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@
import type { Plugin } from './types.ts'
import { enforceZodEntrypointRule } from './rules/enforce-zod-entrypoint.ts'
import { noProcessEnvOutsideConfigRule } from './rules/no-process-env-outside-config.ts'
import { enforceLoggerLiteralRule } from './rules/enforce-logger-literal.ts'

const plugin: Plugin = {
meta: {
name: 'coding-style'
},
rules: {
'no-process-env-outside-config': noProcessEnvOutsideConfigRule,
'enforce-zod-entrypoint': enforceZodEntrypointRule
'enforce-zod-entrypoint': enforceZodEntrypointRule,
'enforce-logger-literal': enforceLoggerLiteralRule
}
}

Expand Down
113 changes: 113 additions & 0 deletions lint-rules/rules/enforce-logger-literal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* logger 呼び出しの引数を、型検査(closed event schema)が確実に効く形に
* 固定するルール。
*
* 何をログへ載せてよいかは各パッケージ logger の `LogEvents` 型が決める。
* sensitive な値そのものの検出は名前ベースの heuristic になるため lint では
* 行わず、この lint は AST で確実に判定できる形の制約だけを強制する。
*
* - 第1引数は object literal に限定する(変数渡しでは TypeScript の
* excess property check が効かず、未知 field の混入を検出できない)
* - `meta` の値も object literal に限定する(同上)
* - 引数オブジェクト / meta 内での spread を禁止する(何が載るか静的に追えない)
* - computed key を禁止する(key 名を静的に追えない)
*
* テストファイルは対象外。runtime の最終防衛(`redactMeta()`)を検証するために、
* 型検査をすり抜けた値を意図的に logger へ渡す必要があるため。
*/
import type { CallExpressionNode, ObjectExpressionNode, Rule } from '../types.ts'

const LOGGER_OBJECT = 'logger'
const LOGGER_METHODS = new Set(['log', 'error', 'debug'])

/** 他のルールと同じ判定式。テストファイル・テスト補助ファイルを対象外にする。 */
const TEST_FILE = /(\.test\.(ts|tsx)$|(^|\/)test\/)/

const ARGUMENT_LITERAL_MESSAGE =
'logger の第1引数は object literal で渡してください。変数渡しでは closed event schema の excess property check が効きません(agents/logging.md 参照)'
const META_LITERAL_MESSAGE =
'meta は object literal で渡してください。変数渡しでは closed event schema の excess property check が効きません(agents/logging.md 参照)'
const SPREAD_MESSAGE =
'logger へ渡すオブジェクトを spread しないでください。何がログに載るか静的に追えなくなります(agents/logging.md 参照)'
const COMPUTED_KEY_MESSAGE =
'logger へ渡すオブジェクトで computed key を使わないでください。key 名を静的に追えなくなります(agents/logging.md 参照)'

function isLoggerCall(node: CallExpressionNode): boolean {
const callee = node.callee
return (
callee.type === 'MemberExpression' &&
!callee.computed &&
callee.object.type === 'Identifier' &&
callee.object.name === LOGGER_OBJECT &&
callee.property.type === 'Identifier' &&
LOGGER_METHODS.has(callee.property.name)
)
}

type KeyNode = ObjectExpressionNode['properties'][number]

function getStaticKeyName(property: KeyNode): string | null {
if (property.type !== 'Property' || property.computed) {
return null
}
if (property.key.type === 'Identifier') {
return property.key.name
}
if (property.key.type === 'Literal' && typeof property.key.value === 'string') {
return property.key.value
}
return null
}

export const enforceLoggerLiteralRule: Rule = {
meta: {
docs: {
description:
'logger の引数を、closed event schema の型検査が効く object literal に限定する'
}
},
create(context) {
if (TEST_FILE.test(context.filename)) {
return {}
}
function checkStaticShape(node: ObjectExpressionNode): void {
for (const property of node.properties) {
if (property.type === 'SpreadElement') {
context.report({ node: property, message: SPREAD_MESSAGE })
continue
}
if (property.type === 'Property' && property.computed) {
context.report({ node: property, message: COMPUTED_KEY_MESSAGE })
}
}
}

return {
CallExpression(node) {
if (!isLoggerCall(node)) {
return
}
const [argument] = node.arguments
if (!argument) {
return
}
if (argument.type !== 'ObjectExpression') {
context.report({ node: argument, message: ARGUMENT_LITERAL_MESSAGE })
return
}
checkStaticShape(argument)
for (const property of argument.properties) {
if (property.type !== 'Property' || getStaticKeyName(property) !== 'meta') {
continue
}
const meta = property.value
if (meta.type !== 'ObjectExpression') {
context.report({ node: property, message: META_LITERAL_MESSAGE })
continue
}
checkStaticShape(meta)
}
}
}
}
}
68 changes: 67 additions & 1 deletion lint-rules/run-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { requireHttpExceptionResRule } from './rules/require-httpexception-res.t
import { requireValidatorForParamQueryRule } from './rules/require-validator-for-param-query.ts'
import { noProcessEnvOutsideConfigRule } from './rules/no-process-env-outside-config.ts'
import { enforceZodEntrypointRule } from './rules/enforce-zod-entrypoint.ts'
import { enforceLoggerLiteralRule } from './rules/enforce-logger-literal.ts'

const handlerFile = 'packages/api/src/handlers/user/index.ts'
const tester = new RuleTester()
Expand Down Expand Up @@ -84,7 +85,10 @@ tester.run('no-process-env-outside-config', noProcessEnvOutsideConfigRule, {
valid: [
// 準拠: config ファイル内の参照は許可
{ code: 'export const PORT = process.env.PORT', filename: 'packages/api/src/config.ts' },
{ code: 'export const API_URI = process.env.API_URI', filename: 'packages/client/src/config.server.ts' },
{
code: 'export const API_URI = process.env.API_URI',
filename: 'packages/client/src/config.server.ts'
},
// 対象外: テストファイル
{ code: 'const db = getTestDbClient(process.env)', filename: 'packages/api/src/app.test.ts' }
],
Expand Down Expand Up @@ -124,4 +128,66 @@ tester.run('enforce-zod-entrypoint', enforceZodEntrypointRule, {
]
})

const loggerFile = 'packages/api/src/lib/middleware.ts'

tester.run('enforce-logger-literal', enforceLoggerLiteralRule, {
valid: [
// 準拠: 第1引数・meta とも object literal(値の型は closed event schema が検査する)
{
code: "logger.log({ label: 'access', requestId, body: 'GET /api/user', meta: { method: c.req.method, path: c.req.path, status: c.res.status, duration: 12 } })",
filename: loggerFile
},
// 準拠: error は logger 側の serializeError で安全な shape になる
{
code: "logger.error({ label: 'handleError', body: 'failed', error })",
filename: loggerFile
},
// 対象外: logger 以外の呼び出し
{ code: 'client(url, { ...options })', filename: loggerFile },
// 対象外: テストファイルは runtime の最終防衛を検証するため変数渡しを許す
{
code: 'logger.log(leakageFixture)',
filename: 'packages/api/src/lib/logger/index.test.ts'
}
],
invalid: [
// 第1引数の変数渡しは excess property check が効かない
{
code: 'logger.log(options)',
filename: loggerFile,
errors: 1
},
// 第1引数オブジェクトでの spread
{
code: "logger.log({ ...base, body: 'x' })",
filename: loggerFile,
errors: 1
},
// meta の変数渡し
{
code: "logger.debug({ body: 'x', meta: metaValues })",
filename: loggerFile,
errors: 1
},
// meta 内での spread
{
code: "logger.log({ body: 'x', meta: { ...payload } })",
filename: loggerFile,
errors: 1
},
// 第1引数オブジェクトでの computed key
{
code: "logger.log({ body: 'x', [key]: value })",
filename: loggerFile,
errors: 1
},
// meta 内での computed key
{
code: "logger.log({ body: 'x', meta: { [key]: value } })",
filename: loggerFile,
errors: 1
}
]
})

console.log('lint-rules: all rule tests passed')
2 changes: 1 addition & 1 deletion packages/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { serve } from '@hono/node-server'
import { createApp } from './app.js'
import { ENV, LOCAL_PROXY_CONFIG_DIR, PORT } from './config.js'
import { logger } from './lib/logger.js'
import { logger } from './lib/logger/index.js'

let server: ReturnType<typeof serve> | null = null

Expand Down
39 changes: 0 additions & 39 deletions packages/api/src/lib/logger.test.ts

This file was deleted.

45 changes: 0 additions & 45 deletions packages/api/src/lib/logger.ts

This file was deleted.

Loading
Loading