diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fbe7b34 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +* text=auto +/.gitattributes text eol=lf +/.gitignore text eol=lf +/gradlew text eol=lf +*.bat text eol=crlf +*.json text eol=lf +*.sh text eol=lf +*.kt text eol=lf +*.kts text eol=lf +*.mjs text eol=lf +*.md text eol=lf +*.properties text eol=lf +*.toml text eol=lf +*.yml text eol=lf diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..d6376e1 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,29 @@ +name: verify + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + harness: + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v7 + - name: Set up JDK 17 + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: "17" + - name: Set up Node.js 22 + uses: actions/setup-node@v7 + with: + node-version: "22" + package-manager-cache: false + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + - name: Run local quality gates + run: ./gradlew verifyLocal --no-daemon diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d4de11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.gradle/ +**/build/ +.idea/ +*.iml +local.properties +captures/ + +# Signing material and local credentials must never enter the repository. +*.jks +*.keystore +keystore.properties +secrets.properties +.env +.env.* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0f18a9b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,48 @@ +# Brainwave agent guide + +This file is the short repository entry point. The detailed, authoritative map is +[`docs/README.md`](docs/README.md). + +## Start here + +1. Read `docs/agent-playbook.md` before changing files. +2. Read `docs/environment.md` before build, SDK, device, or dependency work. +3. Read the relevant product/domain/architecture document from `docs/README.md`. +4. Follow `docs/implementation-plan.md`; never silently turn an item in + `docs/decisions.md#未决问题` into a product fact. + +## Discovery + +Prefer the configured codebase knowledge-graph tools in this order: +`search_graph`, `trace_path`, `get_code_snippet`, `query_graph`, then +`get_architecture`. Fall back to `rg` for literals, non-code files, or when those +tools are unavailable or insufficient. State the fallback in the handoff. + +## Non-negotiable domain rules + +- The three physical coins are entered manually; do not add random casting. +- `字 = 2`, `背 = 3`; record six lines from bottom to top. +- Sums `6` and `9` move; sums `7` and `8` remain static. +- King Wen numbering is a lookup table, never `binary + 1`. +- Casting code remains pure Kotlin with no Android, Compose, database, or network + dependency. +- AI is opt-in explanation only. Do not place provider secrets in the app or repo. + +## Current build boundary + +The repository uses `brainwave` only as a code name. Formal product name, +organization-owned application ID, and final `minSdk` remain product decisions. +Until they are accepted, the `app` project is a Kotlin/JVM domain harness, not a +publishable Android application. Do not invent placeholder release identity. + +## Verification + +Run from the repository root: + +```powershell +.\gradlew.bat verifyLocal --offline +``` + +After the Android application plugin is configured, also run the Android gates +listed in `docs/quality-gates.md`. Report skipped device or Android checks +explicitly; do not imply they passed. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..c0ff6b2 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + +kotlin { + jvmToolchain(17) + + sourceSets { + main { + kotlin.srcDir("src/main/java") + } + test { + kotlin.srcDir("src/test/java") + } + } +} +dependencies { + testImplementation(libs.junit) +} + +tasks.test { + useJUnit() + testLogging { + events("failed", "skipped") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } +} diff --git a/app/src/main/java/brainwave/core/model/HexagramContent.kt b/app/src/main/java/brainwave/core/model/HexagramContent.kt new file mode 100644 index 0000000..089c5d5 --- /dev/null +++ b/app/src/main/java/brainwave/core/model/HexagramContent.kt @@ -0,0 +1,36 @@ +package brainwave.core.model + +data class HexagramContent( + val kingWenNumber: Int, + val name: String, + val symbol: String, + val judgmentOriginal: String, + val judgmentPlain: String, + val lineTextsBottomUp: List, + val linePlainBottomUp: List, + val specialUsageText: String?, + val sourceRefs: List, +) { + init { + require(kingWenNumber in 1..64) { "King Wen number must be between 1 and 64" } + require(name.isNotBlank()) { "Hexagram name must not be blank" } + require(symbol.isNotBlank()) { "Hexagram symbol must not be blank" } + require(judgmentOriginal.isNotBlank()) { "Original judgment must not be blank" } + require(judgmentPlain.isNotBlank()) { "Plain judgment must not be blank" } + require(lineTextsBottomUp.size == 6 && lineTextsBottomUp.all(String::isNotBlank)) { + "Original line texts must contain six non-blank bottom-up entries" + } + require(linePlainBottomUp.size == 6 && linePlainBottomUp.all(String::isNotBlank)) { + "Plain line texts must contain six non-blank bottom-up entries" + } + require(specialUsageText == null || specialUsageText.isNotBlank()) { + "Special usage text must be non-blank or null" + } + require(sourceRefs.isNotEmpty() && sourceRefs.all(String::isNotBlank)) { + "Source references must contain at least one non-blank id" + } + require(sourceRefs.distinct().size == sourceRefs.size) { + "Source references must be unique" + } + } +} diff --git a/app/src/main/java/brainwave/data/content/HexagramContentRepository.kt b/app/src/main/java/brainwave/data/content/HexagramContentRepository.kt new file mode 100644 index 0000000..7ce2adb --- /dev/null +++ b/app/src/main/java/brainwave/data/content/HexagramContentRepository.kt @@ -0,0 +1,14 @@ +package brainwave.data.content + +import brainwave.core.model.HexagramContent + +interface HexagramContentRepository { + val contentVersion: String + + fun contentFor( + kingWenNumber: Int, + requestedContentVersion: String = contentVersion, + ): HexagramContent +} + +class ContentIntegrityException(message: String) : IllegalStateException(message) diff --git a/app/src/main/java/brainwave/domain/casting/CastEngine.kt b/app/src/main/java/brainwave/domain/casting/CastEngine.kt new file mode 100644 index 0000000..26ebe5d --- /dev/null +++ b/app/src/main/java/brainwave/domain/casting/CastEngine.kt @@ -0,0 +1,80 @@ +package brainwave.domain.casting + +data class CastComputation internal constructor( + val roundsBottomUp: List, + val lineValuesBottomUp: List, + val primaryPatternBottomUp: HexagramPattern, + val movingLinePositions: List, + val transformedPatternBottomUp: HexagramPattern, + val primaryHexagramId: HexagramId, + val transformedHexagramId: HexagramId, +) + +object CastEngine { + const val METHOD_VERSION = "coin-v1" + const val COIN_CONVENTION = "character=2;reverse=3;bottom-up" + + fun cast(roundsBottomUp: List): CastComputation { + require(roundsBottomUp.size == HexagramPattern.LINE_COUNT) { + "A complete cast must contain exactly six rounds, but contained ${roundsBottomUp.size}" + } + + val rounds = roundsBottomUp.toList() + val lineValues = rounds.map(CastRound::lineValue) + val primaryPattern = HexagramPattern.of(lineValues.map(LineValue::polarity)) + val movingPositions = lineValues.mapIndexedNotNull { index, lineValue -> + if (lineValue.isMoving) index + 1 else null + } + val transformedPattern = HexagramPattern.of(lineValues.map(LineValue::transformedPolarity)) + + return CastComputation( + roundsBottomUp = rounds, + lineValuesBottomUp = lineValues, + primaryPatternBottomUp = primaryPattern, + movingLinePositions = movingPositions, + transformedPatternBottomUp = transformedPattern, + primaryHexagramId = HexagramCatalog.idFor(primaryPattern), + transformedHexagramId = HexagramCatalog.idFor(transformedPattern), + ) + } +} +data class CastMetadata( + val contentVersion: String, + val createdAt: String, +) { + init { + require(contentVersion.isNotBlank()) { "Content version must not be blank" } + require(createdAt.isNotBlank()) { "Created-at metadata must not be blank" } + } +} + +data class CastResult private constructor( + val methodVersion: String, + val coinConvention: String, + val roundsBottomUp: List, + val lineValuesBottomUp: List, + val primaryPatternBottomUp: HexagramPattern, + val movingLinePositions: List, + val transformedPatternBottomUp: HexagramPattern, + val primaryHexagramId: HexagramId, + val transformedHexagramId: HexagramId, + val contentVersion: String, + val createdAt: String, +) { + companion object { + fun record(computation: CastComputation, metadata: CastMetadata): CastResult = + CastResult( + methodVersion = CastEngine.METHOD_VERSION, + coinConvention = CastEngine.COIN_CONVENTION, + roundsBottomUp = computation.roundsBottomUp.toList(), + lineValuesBottomUp = computation.lineValuesBottomUp.toList(), + primaryPatternBottomUp = computation.primaryPatternBottomUp, + movingLinePositions = computation.movingLinePositions.toList(), + transformedPatternBottomUp = computation.transformedPatternBottomUp, + primaryHexagramId = computation.primaryHexagramId, + transformedHexagramId = computation.transformedHexagramId, + contentVersion = metadata.contentVersion, + createdAt = metadata.createdAt, + ) + } +} diff --git a/app/src/main/java/brainwave/domain/casting/CastRecordDto.kt b/app/src/main/java/brainwave/domain/casting/CastRecordDto.kt new file mode 100644 index 0000000..b035d77 --- /dev/null +++ b/app/src/main/java/brainwave/domain/casting/CastRecordDto.kt @@ -0,0 +1,75 @@ +package brainwave.domain.casting + +data class CastRecordDto( + val schemaVersion: Int, + val methodVersion: String, + val coinConvention: String, + val roundsBottomUp: List>, + val lineValuesBottomUp: List, + val primaryPatternBottomUp: String, + val movingLinePositions: List, + val transformedPatternBottomUp: String, + val primaryHexagramId: Int, + val transformedHexagramId: Int, + val contentVersion: String, + val createdAt: String, +) { + fun toDomain(): CastResult { + require(schemaVersion == CURRENT_SCHEMA_VERSION) { + "Unsupported cast record schema version: $schemaVersion" + } + require(methodVersion == CastEngine.METHOD_VERSION) { + "Unsupported casting method version: $methodVersion" + } + require(coinConvention == CastEngine.COIN_CONVENTION) { + "Unsupported coin convention: $coinConvention" + } + + val rounds = roundsBottomUp.map(CastRound::fromScores) + val result = CastResult.record( + computation = CastEngine.cast(rounds), + metadata = CastMetadata(contentVersion = contentVersion, createdAt = createdAt), + ) + + require(lineValuesBottomUp == result.lineValuesBottomUp.map(LineValue::score)) { + "Stored line values do not match the preserved coin rounds" + } + require(primaryPatternBottomUp == result.primaryPatternBottomUp.encoded()) { + "Stored primary pattern does not match the preserved coin rounds" + } + require(movingLinePositions == result.movingLinePositions) { + "Stored moving-line positions do not match the preserved coin rounds" + } + require(transformedPatternBottomUp == result.transformedPatternBottomUp.encoded()) { + "Stored transformed pattern does not match the preserved coin rounds" + } + require(primaryHexagramId == result.primaryHexagramId.value) { + "Stored primary hexagram id does not match the King Wen lookup" + } + require(transformedHexagramId == result.transformedHexagramId.value) { + "Stored transformed hexagram id does not match the King Wen lookup" + } + + return result + } + + companion object { + const val CURRENT_SCHEMA_VERSION = 1 + + fun fromDomain(result: CastResult): CastRecordDto = + CastRecordDto( + schemaVersion = CURRENT_SCHEMA_VERSION, + methodVersion = result.methodVersion, + coinConvention = result.coinConvention, + roundsBottomUp = result.roundsBottomUp.map { round -> round.coins.map(CoinSide::score) }, + lineValuesBottomUp = result.lineValuesBottomUp.map(LineValue::score), + primaryPatternBottomUp = result.primaryPatternBottomUp.encoded(), + movingLinePositions = result.movingLinePositions.toList(), + transformedPatternBottomUp = result.transformedPatternBottomUp.encoded(), + primaryHexagramId = result.primaryHexagramId.value, + transformedHexagramId = result.transformedHexagramId.value, + contentVersion = result.contentVersion, + createdAt = result.createdAt, + ) + } +} diff --git a/app/src/main/java/brainwave/domain/casting/CastingModels.kt b/app/src/main/java/brainwave/domain/casting/CastingModels.kt new file mode 100644 index 0000000..fc9d0b5 --- /dev/null +++ b/app/src/main/java/brainwave/domain/casting/CastingModels.kt @@ -0,0 +1,104 @@ +package brainwave.domain.casting + +enum class CoinSide(val score: Int) { + CHARACTER(2), + REVERSE(3), + ; + + companion object { + fun fromScore(score: Int): CoinSide = + entries.singleOrNull { it.score == score } + ?: throw IllegalArgumentException("Coin score must be 2 or 3, but was $score") + } +} +enum class Polarity { + YIN, + YANG, + ; + + fun opposite(): Polarity = if (this == YIN) YANG else YIN +} + +enum class LineValue( + val score: Int, + val polarity: Polarity, + val isMoving: Boolean, +) { + OLD_YIN(6, Polarity.YIN, true), + YOUNG_YANG(7, Polarity.YANG, false), + YOUNG_YIN(8, Polarity.YIN, false), + OLD_YANG(9, Polarity.YANG, true), + ; + + val transformedPolarity: Polarity + get() = if (isMoving) polarity.opposite() else polarity + + companion object { + fun fromScore(score: Int): LineValue = + entries.singleOrNull { it.score == score } + ?: throw IllegalArgumentException("Line score must be between 6 and 9, but was $score") + } +} + +data class CastRound private constructor(val coins: List) { + init { + require(coins.size == COINS_PER_ROUND) { + "Each cast round must contain exactly $COINS_PER_ROUND coins, but contained ${coins.size}" + } + } + + val lineValue: LineValue + get() = LineValue.fromScore(coins.sumOf(CoinSide::score)) + + companion object { + const val COINS_PER_ROUND = 3 + + fun of(coins: List): CastRound = CastRound(coins.toList()) + + fun of(first: CoinSide, second: CoinSide, third: CoinSide): CastRound = + of(listOf(first, second, third)) + + fun fromScores(scores: List): CastRound = of(scores.map(CoinSide::fromScore)) + + fun fromLineValue(lineValue: LineValue): CastRound = + when (lineValue) { + LineValue.OLD_YIN -> of(CoinSide.CHARACTER, CoinSide.CHARACTER, CoinSide.CHARACTER) + LineValue.YOUNG_YANG -> of(CoinSide.CHARACTER, CoinSide.CHARACTER, CoinSide.REVERSE) + LineValue.YOUNG_YIN -> of(CoinSide.CHARACTER, CoinSide.REVERSE, CoinSide.REVERSE) + LineValue.OLD_YANG -> of(CoinSide.REVERSE, CoinSide.REVERSE, CoinSide.REVERSE) + } + } +} + +data class HexagramPattern private constructor(val linesBottomUp: List) { + init { + require(linesBottomUp.size == LINE_COUNT) { + "A hexagram must contain exactly $LINE_COUNT lines, but contained ${linesBottomUp.size}" + } + } + + fun encoded(): String = linesBottomUp.joinToString(separator = "") { + if (it == Polarity.YANG) "1" else "0" + } + + companion object { + const val LINE_COUNT = 6 + + fun of(linesBottomUp: List): HexagramPattern = + HexagramPattern(linesBottomUp.toList()) + + fun decode(encoded: String): HexagramPattern { + require(encoded.length == LINE_COUNT && encoded.all { it == '0' || it == '1' }) { + "Encoded hexagram must contain exactly six 0/1 characters" + } + return of(encoded.map { if (it == '1') Polarity.YANG else Polarity.YIN }) + } + } +} + +@JvmInline +value class HexagramId(val value: Int) { + init { + require(value in 1..64) { "King Wen hexagram id must be between 1 and 64" } + } +} diff --git a/app/src/main/java/brainwave/domain/casting/HexagramCatalog.kt b/app/src/main/java/brainwave/domain/casting/HexagramCatalog.kt new file mode 100644 index 0000000..bf2e8f1 --- /dev/null +++ b/app/src/main/java/brainwave/domain/casting/HexagramCatalog.kt @@ -0,0 +1,104 @@ +package brainwave.domain.casting + +object HexagramCatalog { + private enum class Trigram(val patternBottomUp: String) { + QIAN("111"), + DUI("110"), + LI("101"), + ZHEN("100"), + XUN("011"), + KAN("010"), + GEN("001"), + KUN("000"), + ; + + companion object { + fun fromPattern(patternBottomUp: String): Trigram = + entries.singleOrNull { it.patternBottomUp == patternBottomUp } + ?: error("Unknown trigram pattern: $patternBottomUp") + } + } + + private data class TrigramPair(val upper: Trigram, val lower: Trigram) + + private val kingWenIds = mapOf( + TrigramPair(Trigram.QIAN, Trigram.QIAN) to 1, + TrigramPair(Trigram.KUN, Trigram.KUN) to 2, + TrigramPair(Trigram.KAN, Trigram.ZHEN) to 3, + TrigramPair(Trigram.GEN, Trigram.KAN) to 4, + TrigramPair(Trigram.KAN, Trigram.QIAN) to 5, + TrigramPair(Trigram.QIAN, Trigram.KAN) to 6, + TrigramPair(Trigram.KUN, Trigram.KAN) to 7, + TrigramPair(Trigram.KAN, Trigram.KUN) to 8, + TrigramPair(Trigram.XUN, Trigram.QIAN) to 9, + TrigramPair(Trigram.QIAN, Trigram.DUI) to 10, + TrigramPair(Trigram.KUN, Trigram.QIAN) to 11, + TrigramPair(Trigram.QIAN, Trigram.KUN) to 12, + TrigramPair(Trigram.QIAN, Trigram.LI) to 13, + TrigramPair(Trigram.LI, Trigram.QIAN) to 14, + TrigramPair(Trigram.KUN, Trigram.GEN) to 15, + TrigramPair(Trigram.ZHEN, Trigram.KUN) to 16, + TrigramPair(Trigram.DUI, Trigram.ZHEN) to 17, + TrigramPair(Trigram.GEN, Trigram.XUN) to 18, + TrigramPair(Trigram.KUN, Trigram.DUI) to 19, + TrigramPair(Trigram.XUN, Trigram.KUN) to 20, + TrigramPair(Trigram.LI, Trigram.ZHEN) to 21, + TrigramPair(Trigram.GEN, Trigram.LI) to 22, + TrigramPair(Trigram.GEN, Trigram.KUN) to 23, + TrigramPair(Trigram.KUN, Trigram.ZHEN) to 24, + TrigramPair(Trigram.QIAN, Trigram.ZHEN) to 25, + TrigramPair(Trigram.GEN, Trigram.QIAN) to 26, + TrigramPair(Trigram.GEN, Trigram.ZHEN) to 27, + TrigramPair(Trigram.DUI, Trigram.XUN) to 28, + TrigramPair(Trigram.KAN, Trigram.KAN) to 29, + TrigramPair(Trigram.LI, Trigram.LI) to 30, + TrigramPair(Trigram.DUI, Trigram.GEN) to 31, + TrigramPair(Trigram.ZHEN, Trigram.XUN) to 32, + TrigramPair(Trigram.QIAN, Trigram.GEN) to 33, + TrigramPair(Trigram.ZHEN, Trigram.QIAN) to 34, + TrigramPair(Trigram.LI, Trigram.KUN) to 35, + TrigramPair(Trigram.KUN, Trigram.LI) to 36, + TrigramPair(Trigram.XUN, Trigram.LI) to 37, + TrigramPair(Trigram.LI, Trigram.DUI) to 38, + TrigramPair(Trigram.KAN, Trigram.GEN) to 39, + TrigramPair(Trigram.ZHEN, Trigram.KAN) to 40, + TrigramPair(Trigram.GEN, Trigram.DUI) to 41, + TrigramPair(Trigram.XUN, Trigram.ZHEN) to 42, + TrigramPair(Trigram.DUI, Trigram.QIAN) to 43, + TrigramPair(Trigram.QIAN, Trigram.XUN) to 44, + TrigramPair(Trigram.DUI, Trigram.KUN) to 45, + TrigramPair(Trigram.KUN, Trigram.XUN) to 46, + TrigramPair(Trigram.DUI, Trigram.KAN) to 47, + TrigramPair(Trigram.KAN, Trigram.XUN) to 48, + TrigramPair(Trigram.DUI, Trigram.LI) to 49, + TrigramPair(Trigram.LI, Trigram.XUN) to 50, + TrigramPair(Trigram.ZHEN, Trigram.ZHEN) to 51, + TrigramPair(Trigram.GEN, Trigram.GEN) to 52, + TrigramPair(Trigram.XUN, Trigram.GEN) to 53, + TrigramPair(Trigram.ZHEN, Trigram.DUI) to 54, + TrigramPair(Trigram.ZHEN, Trigram.LI) to 55, + TrigramPair(Trigram.LI, Trigram.GEN) to 56, + TrigramPair(Trigram.XUN, Trigram.XUN) to 57, + TrigramPair(Trigram.DUI, Trigram.DUI) to 58, + TrigramPair(Trigram.XUN, Trigram.KAN) to 59, + TrigramPair(Trigram.KAN, Trigram.DUI) to 60, + TrigramPair(Trigram.XUN, Trigram.DUI) to 61, + TrigramPair(Trigram.ZHEN, Trigram.GEN) to 62, + TrigramPair(Trigram.KAN, Trigram.LI) to 63, + TrigramPair(Trigram.LI, Trigram.KAN) to 64, + ) + + init { + check(kingWenIds.size == 64) { "King Wen table must contain all 64 trigram pairs" } + check(kingWenIds.values.toSet() == (1..64).toSet()) { + "King Wen table must contain every id from 1 through 64 exactly once" + } + } + + fun idFor(pattern: HexagramPattern): HexagramId { + val encoded = pattern.encoded() + val lower = Trigram.fromPattern(encoded.substring(0, 3)) + val upper = Trigram.fromPattern(encoded.substring(3, 6)) + return HexagramId(kingWenIds.getValue(TrigramPair(upper, lower))) + } +} diff --git a/app/src/test/java/brainwave/data/content/FakeHexagramContentRepository.kt b/app/src/test/java/brainwave/data/content/FakeHexagramContentRepository.kt new file mode 100644 index 0000000..95b35a4 --- /dev/null +++ b/app/src/test/java/brainwave/data/content/FakeHexagramContentRepository.kt @@ -0,0 +1,31 @@ +package brainwave.data.content + +import brainwave.core.model.HexagramContent + +class FakeHexagramContentRepository( + override val contentVersion: String = "fixture-only-v1", + entries: List, +) : HexagramContentRepository { + private val entriesById: Map + + init { + require(contentVersion.isNotBlank()) { "Content version must not be blank" } + require(entries.map(HexagramContent::kingWenNumber).distinct().size == entries.size) { + "Fake content entries must have unique King Wen numbers" + } + entriesById = entries.associateBy(HexagramContent::kingWenNumber) + } + + override fun contentFor( + kingWenNumber: Int, + requestedContentVersion: String, + ): HexagramContent { + if (requestedContentVersion != contentVersion) { + throw ContentIntegrityException( + "Requested content version '$requestedContentVersion' is unavailable; loaded '$contentVersion'", + ) + } + return entriesById[kingWenNumber] + ?: throw ContentIntegrityException("No validated content for King Wen number $kingWenNumber") + } +} diff --git a/app/src/test/java/brainwave/data/content/HexagramContentRepositoryTest.kt b/app/src/test/java/brainwave/data/content/HexagramContentRepositoryTest.kt new file mode 100644 index 0000000..4a074d0 --- /dev/null +++ b/app/src/test/java/brainwave/data/content/HexagramContentRepositoryTest.kt @@ -0,0 +1,59 @@ +package brainwave.data.content + +import brainwave.core.model.HexagramContent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class HexagramContentRepositoryTest { + @Test + fun `fake returns content only for its exact version and id`() { + val qian = fixture(1) + val repository = FakeHexagramContentRepository(entries = listOf(qian)) + + assertEquals(qian, repository.contentFor(1)) + assertFails("unavailable") { repository.contentFor(1, "another-version") } + assertFails("No validated content") { repository.contentFor(2) } + } + + @Test + fun `fake rejects duplicate ids instead of silently overwriting`() { + assertFails("unique King Wen numbers") { + FakeHexagramContentRepository(entries = listOf(fixture(1), fixture(1))) + } + } + + @Test + fun `content model enforces six bottom-up texts and sources`() { + assertFails("six non-blank") { + fixture(1).copy(lineTextsBottomUp = List(5) { "fixture" }) + } + assertFails("at least one") { + fixture(1).copy(sourceRefs = emptyList()) + } + } + + private fun fixture(id: Int) = HexagramContent( + kingWenNumber = id, + name = "fixture-$id", + symbol = "fixture-symbol-$id", + judgmentOriginal = "fixture original", + judgmentPlain = "fixture plain", + lineTextsBottomUp = List(6) { index -> "fixture original line ${index + 1}" }, + linePlainBottomUp = List(6) { index -> "fixture plain line ${index + 1}" }, + specialUsageText = null, + sourceRefs = listOf("fixture-source"), + ) + + private fun assertFails(messageFragment: String, block: () -> Unit) { + try { + block() + fail("Expected failure containing '$messageFragment'") + } catch (error: IllegalArgumentException) { + assertTrue(error.message.orEmpty().contains(messageFragment)) + } catch (error: ContentIntegrityException) { + assertTrue(error.message.orEmpty().contains(messageFragment)) + } + } +} diff --git a/app/src/test/java/brainwave/domain/casting/CastEngineTest.kt b/app/src/test/java/brainwave/domain/casting/CastEngineTest.kt new file mode 100644 index 0000000..35c2ea4 --- /dev/null +++ b/app/src/test/java/brainwave/domain/casting/CastEngineTest.kt @@ -0,0 +1,200 @@ +package brainwave.domain.casting + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class CastEngineTest { + @Test + fun `all eight coin combinations map to the correct line values`() { + val counts = mutableMapOf() + + for (encoded in 0 until 8) { + val coins = List(3) { index -> + if (encoded and (1 shl index) == 0) CoinSide.CHARACTER else CoinSide.REVERSE + } + val expected = LineValue.fromScore(coins.sumOf(CoinSide::score)) + val actual = CastRound.of(coins).lineValue + assertEquals(expected, actual) + counts[actual] = counts.getOrDefault(actual, 0) + 1 + } + + assertEquals( + mapOf( + LineValue.OLD_YIN to 1, + LineValue.YOUNG_YANG to 3, + LineValue.YOUNG_YIN to 3, + LineValue.OLD_YANG to 1, + ), + counts, + ) + } + + @Test + fun `line values expose correct polarity movement and transformation`() { + assertLine(LineValue.OLD_YIN, Polarity.YIN, moving = true, Polarity.YANG) + assertLine(LineValue.YOUNG_YANG, Polarity.YANG, moving = false, Polarity.YANG) + assertLine(LineValue.YOUNG_YIN, Polarity.YIN, moving = false, Polarity.YIN) + assertLine(LineValue.OLD_YANG, Polarity.YANG, moving = true, Polarity.YIN) + } + + @Test + fun `a round must preserve exactly three coins`() { + assertFails("exactly 3 coins") { CastRound.of(emptyList()) } + assertFails("exactly 3 coins") { + CastRound.of(List(4) { CoinSide.CHARACTER }) + } + assertEquals( + listOf(2, 3, 2), + CastRound.of(CoinSide.CHARACTER, CoinSide.REVERSE, CoinSide.CHARACTER) + .coins + .map(CoinSide::score), + ) + } + + @Test + fun `a cast requires exactly six rounds`() { + assertFails("exactly six rounds") { + CastEngine.cast(List(5) { roundFor(LineValue.YOUNG_YANG) }) + } + assertFails("exactly six rounds") { + CastEngine.cast(List(7) { roundFor(LineValue.YOUNG_YANG) }) + } + } + + @Test + fun `documented fixtures preserve bottom-up order and King Wen ids`() { + assertFixture(listOf(7, 7, 7, 7, 7, 7), primary = 1, transformed = 1, moving = emptyList()) + assertFixture(listOf(8, 8, 8, 8, 8, 8), primary = 2, transformed = 2, moving = emptyList()) + assertFixture(listOf(9, 9, 9, 9, 9, 9), primary = 1, transformed = 2, moving = (1..6).toList()) + assertFixture(listOf(6, 6, 6, 6, 6, 6), primary = 2, transformed = 1, moving = (1..6).toList()) + assertFixture(listOf(7, 8, 8, 8, 8, 8), primary = 24, transformed = 24, moving = emptyList()) + assertFixture(listOf(9, 8, 8, 8, 8, 8), primary = 24, transformed = 2, moving = listOf(1)) + } + + @Test + fun `all 4096 line sequences compute consistently`() { + val primaryIds = mutableSetOf() + + for (encoded in 0 until 4096) { + val lineValues = List(6) { position -> + LineValue.entries[(encoded shr (position * 2)) and 0b11] + } + val result = CastEngine.cast(lineValues.map(::roundFor)) + + assertEquals(lineValues, result.lineValuesBottomUp) + assertEquals(lineValues.map(LineValue::polarity), result.primaryPatternBottomUp.linesBottomUp) + assertEquals( + lineValues.map(LineValue::transformedPolarity), + result.transformedPatternBottomUp.linesBottomUp, + ) + assertEquals( + lineValues.mapIndexedNotNull { index, line -> if (line.isMoving) index + 1 else null }, + result.movingLinePositions, + ) + assertTrue(result.primaryHexagramId.value in 1..64) + assertTrue(result.transformedHexagramId.value in 1..64) + primaryIds += result.primaryHexagramId.value + } + + assertEquals((1..64).toSet(), primaryIds) + } + + @Test + fun `all 64 polarity patterns map one-to-one to King Wen ids`() { + val ids = (0 until 64).map { encoded -> + val pattern = HexagramPattern.of( + List(6) { position -> + if (encoded and (1 shl position) == 0) Polarity.YIN else Polarity.YANG + }, + ) + HexagramCatalog.idFor(pattern).value + } + + assertEquals(64, ids.toSet().size) + assertEquals((1..64).toSet(), ids.toSet()) + } + + @Test + fun `record metadata is separate from deterministic computation`() { + val rounds = listOf(9, 8, 8, 8, 8, 8).map(::roundFor) + val computation = CastEngine.cast(rounds) + val first = CastResult.record(computation, CastMetadata("content-test", "2026-01-01T00:00:00Z")) + val second = CastResult.record(computation, CastMetadata("content-test", "2026-02-01T00:00:00Z")) + + assertNotEquals(first.createdAt, second.createdAt) + assertEquals(first.roundsBottomUp, second.roundsBottomUp) + assertEquals(first.lineValuesBottomUp, second.lineValuesBottomUp) + assertEquals(first.primaryHexagramId, second.primaryHexagramId) + assertEquals(first.transformedHexagramId, second.transformedHexagramId) + } + + @Test + fun `versioned dto round-trips all preserved coins and derived values`() { + val computation = CastEngine.cast(listOf(6, 7, 8, 9, 7, 8).map(::roundFor)) + val original = CastResult.record( + computation, + CastMetadata(contentVersion = "fixture-v1", createdAt = "2026-08-04T12:00:00+08:00"), + ) + + val restored = CastRecordDto.fromDomain(original).toDomain() + + assertEquals(original, restored) + assertEquals(18, restored.roundsBottomUp.sumOf { it.coins.size }) + } + + @Test + fun `versioned dto rejects corrupted derived values`() { + val original = CastResult.record( + CastEngine.cast(List(6) { roundFor(LineValue.YOUNG_YANG) }), + CastMetadata(contentVersion = "fixture-v1", createdAt = "2026-08-04T12:00:00+08:00"), + ) + val corrupted = CastRecordDto.fromDomain(original).copy(primaryHexagramId = 2) + + assertFails("does not match") { corrupted.toDomain() } + } + + private fun assertFixture( + scoresBottomUp: List, + primary: Int, + transformed: Int, + moving: List, + ) { + val result = CastEngine.cast(scoresBottomUp.map(::roundFor)) + assertEquals(primary, result.primaryHexagramId.value) + assertEquals(transformed, result.transformedHexagramId.value) + assertEquals(moving, result.movingLinePositions) + assertEquals(scoresBottomUp, result.lineValuesBottomUp.map(LineValue::score)) + } + + private fun assertLine( + lineValue: LineValue, + polarity: Polarity, + moving: Boolean, + transformed: Polarity, + ) { + assertEquals(polarity, lineValue.polarity) + assertEquals(moving, lineValue.isMoving) + assertEquals(transformed, lineValue.transformedPolarity) + if (moving) assertNotEquals(polarity, transformed) else assertEquals(polarity, transformed) + } + + private fun roundFor(score: Int): CastRound = roundFor(LineValue.fromScore(score)) + + private fun roundFor(lineValue: LineValue): CastRound = CastRound.fromLineValue(lineValue) + + private fun assertFails(messageFragment: String, block: () -> Unit) { + try { + block() + fail("Expected IllegalArgumentException containing '$messageFragment'") + } catch (error: IllegalArgumentException) { + assertTrue( + "Expected '${error.message}' to contain '$messageFragment'", + error.message.orEmpty().contains(messageFragment), + ) + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..3f4454e --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,70 @@ +import org.gradle.api.tasks.Exec + +plugins { + base +} + +fun registerNodeVerificationTask( + name: String, + description: String, + script: String, +) = tasks.register(name) { + group = LifecycleBasePlugin.VERIFICATION_GROUP + this.description = description + workingDir(rootDir) + commandLine("node", script) +} + +val verifyDocs = registerNodeVerificationTask( + name = "verifyDocs", + description = "Checks local Markdown links and required harness documents.", + script = "scripts/verify-docs.mjs", +) + +val scanSecrets = registerNodeVerificationTask( + name = "scanSecrets", + description = "Scans repository text files for high-confidence secret patterns.", + script = "scripts/scan-secrets.mjs", +) + +val verifyDomainBoundaries = registerNodeVerificationTask( + name = "verifyDomainBoundaries", + description = "Prevents Android, persistence, and network imports in the domain core.", + script = "scripts/verify-domain-boundaries.mjs", +) + +val verifyPrototype = registerNodeVerificationTask( + name = "verifyPrototype", + description = "Runs dependency-free syntax checks for the HTML prototype harness.", + script = "scripts/verify-prototype.mjs", +) + +val verifyContentContract = registerNodeVerificationTask( + name = "verifyContentContract", + description = "Validates the versioned local-content contract and its negative fixtures.", + script = "scripts/verify-content-contract.mjs", +) + +val spotlessCheck = registerNodeVerificationTask( + name = "spotlessCheck", + description = "Runs the dependency-free repository formatting gate.", + script = "scripts/verify-format.mjs", +) + +tasks.register("verifyLocal") { + group = LifecycleBasePlugin.VERIFICATION_GROUP + description = "Runs every environment-independent local quality gate." + dependsOn( + ":app:test", + verifyDocs, + scanSecrets, + verifyDomainBoundaries, + verifyPrototype, + verifyContentContract, + spotlessCheck, + ) +} + +tasks.named("check") { + dependsOn("verifyLocal") +} diff --git a/content/schema/hexagram-content.schema.json b/content/schema/hexagram-content.schema.json new file mode 100644 index 0000000..c55a460 --- /dev/null +++ b/content/schema/hexagram-content.schema.json @@ -0,0 +1,117 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://brainwave.invalid/schema/hexagram-content-v1.json", + "title": "Brainwave local hexagram content package", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "contentVersion", + "specialUsageTexts", + "sources", + "hexagrams" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "contentVersion": { "type": "string", "minLength": 1 }, + "specialUsageTexts": { + "type": "object", + "additionalProperties": false, + "required": ["qian", "kun"], + "properties": { + "qian": { "type": "boolean" }, + "kun": { "type": "boolean" } + } + }, + "sources": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/source" } + }, + "hexagrams": { + "type": "array", + "minItems": 64, + "maxItems": 64, + "items": { "$ref": "#/$defs/hexagram" } + } + }, + "$defs": { + "nonBlankText": { + "type": "string", + "minLength": 1, + "pattern": "\\S" + }, + "trigram": { + "enum": ["QIAN", "DUI", "LI", "ZHEN", "XUN", "KAN", "GEN", "KUN"] + }, + "polarity": { + "enum": ["YIN", "YANG"] + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["id", "title", "edition", "license", "url"], + "properties": { + "id": { "$ref": "#/$defs/nonBlankText" }, + "title": { "$ref": "#/$defs/nonBlankText" }, + "edition": { "$ref": "#/$defs/nonBlankText" }, + "license": { "$ref": "#/$defs/nonBlankText" }, + "url": { "type": "string", "format": "uri" } + } + }, + "sixPolarities": { + "type": "array", + "minItems": 6, + "maxItems": 6, + "items": { "$ref": "#/$defs/polarity" } + }, + "sixTexts": { + "type": "array", + "minItems": 6, + "maxItems": 6, + "items": { "$ref": "#/$defs/nonBlankText" } + }, + "hexagram": { + "type": "object", + "additionalProperties": false, + "required": [ + "kingWenNumber", + "name", + "symbol", + "lowerTrigram", + "upperTrigram", + "patternBottomUp", + "judgmentOriginal", + "judgmentPlain", + "lineTextsBottomUp", + "linePlainBottomUp", + "specialUsageText", + "sourceRefs" + ], + "properties": { + "kingWenNumber": { "type": "integer", "minimum": 1, "maximum": 64 }, + "name": { "$ref": "#/$defs/nonBlankText" }, + "symbol": { "$ref": "#/$defs/nonBlankText" }, + "lowerTrigram": { "$ref": "#/$defs/trigram" }, + "upperTrigram": { "$ref": "#/$defs/trigram" }, + "patternBottomUp": { "$ref": "#/$defs/sixPolarities" }, + "judgmentOriginal": { "$ref": "#/$defs/nonBlankText" }, + "judgmentPlain": { "$ref": "#/$defs/nonBlankText" }, + "lineTextsBottomUp": { "$ref": "#/$defs/sixTexts" }, + "linePlainBottomUp": { "$ref": "#/$defs/sixTexts" }, + "specialUsageText": { + "oneOf": [ + { "$ref": "#/$defs/nonBlankText" }, + { "type": "null" } + ] + }, + "sourceRefs": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "$ref": "#/$defs/nonBlankText" } + } + } + } + } +} diff --git a/docs/README.md b/docs/README.md index 8185a9e..0445bfc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,7 @@ > 文档状态:方案基线 > 最后核验:2026-08-04 -> 当前阶段:P-1 核心原型已确认;首页、问卦簿与默认本机保存 v0.3 已实现并待视觉复核;Android 工程尚未初始化 +> 当前阶段:P-1 v0.3 待视觉复核;P1 纯 Kotlin 领域核心已完成;P0 仓库门禁与 P2 内容契约已部分完成;可发布 Android 壳仍等待正式身份与 `minSdk` 决策 本目录是 Brainwave 的项目知识事实源。产品决策、领域算法、架构边界、验收标准和已知失败模式必须写入仓库;聊天记录、口头约定和临时提示不构成项目规范。 diff --git a/docs/architecture.md b/docs/architecture.md index 8330aac..17d4b66 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -96,7 +96,7 @@ core/designsystem → Compose/Material + core model(仅绘制需要) ### `CastEngine` -纯 Kotlin、无副作用。输入六轮铜币和方法版本,输出不可变 `CastResult`。所有规则来自[领域规则](domain-rules.md)。 +纯 Kotlin、无副作用。输入六轮铜币,使用 API 固定的 `coin-v1` 约定输出不可变 `CastComputation`;随后由记录工厂附加 `contentVersion` 与 `createdAt`,组成不可变 `CastResult`。方法版本不是调用方可随意传入的自由字符串,记录元数据也不能进入计算。所有规则来自[领域规则](domain-rules.md)。 ### `HexagramContentRepository` diff --git a/docs/data-content.md b/docs/data-content.md index 9882e45..6ce4c17 100644 --- a/docs/data-content.md +++ b/docs/data-content.md @@ -24,6 +24,10 @@ { "schemaVersion": 1, "contentVersion": "zh-Hans-2026.1", + "specialUsageTexts": { + "qian": true, + "kun": true + }, "sources": [ { "id": "source-id", @@ -54,6 +58,8 @@ 示例中的省略号不是可发布内容。禁止由 AI 在构建时临时补齐缺失卦辞或爻辞。 +机器契约位于 `content/schema/hexagram-content.schema.json`。`specialUsageTexts` 显式声明当前内容版本是否提供乾“用九”和坤“用六”;声明为 `false` 时对应条目的 `specialUsageText` 必须为 `null`,不能用空字符串暗示内容存在。Android parser 尚未建立前,`scripts/verify-content-contract.mjs` 已提供独立构建期校验、稳定 SHA-256 摘要和不含可发布卦辞的自动化夹具;`HexagramContentRepository` 接口与测试 fake 已建立,缺少 ID 或内容版本不匹配时抛出数据完整性错误,不回退到相邻条目。 + ## 3. 内容完整性门禁 内容包进入应用前必须自动验证: diff --git a/docs/decisions.md b/docs/decisions.md index 5893d4d..ba4fe0d 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -121,6 +121,16 @@ - 后果:P4 必须实现保存策略、事务关联、设置、单次退出、删除、迁移和备份排除测试;ADR-012 是生产事实源,决策时尚未同步的 v0.2 原型只能作为旧流程评审材料,现行 v0.3 已完成同步。 - 复审触发:引入账号、导出、云同步、系统备份、跨设备迁移或新的隐私/合规要求。 +## ADR-013:仓库门禁采用无外部依赖脚本并由 Gradle 聚合 + +- 状态:`Accepted` +- 日期:2026-08-04 +- 关联:解决 TBD-012、P0/P1/P2/P6 +- 决定:在 Android application 壳建立前,使用仓库内 Node 脚本执行格式、文档链接、高置信 secret scan、domain 依赖边界、原型语法和内容完整性检查;Gradle 8.2 的 `verifyLocal` 聚合这些检查与 JVM 测试,CI 调用同一入口。 +- 原因:当前系统已实测 Node 22、JDK 17 和 Gradle 8.2,且无需新增全局工具或把个人路径写进工程;门禁错误能够给出可修复的文件位置。 +- 后果:`spotlessCheck` 当前是仓库内的无依赖格式兼容入口,不表示已引入 Spotless 插件。Android 壳建立后必须把 lint/debug build 加入 `verifyLocal`;将来若采用维护良好的专用插件,应保留命令兼容或同步更新 CI 与文档。 +- 复审触发:Android 工具链启用、现有脚本无法表达新边界,或专用工具能以可接受成本提供明显更强的检查。 + ## 未决问题 | ID | 问题 | 推荐默认 | 阻塞阶段 | @@ -134,7 +144,6 @@ | TBD-009 | AI 模型供应商与自有后端 | 供应商无关接口;先交付本地版 | P5 | | TBD-010 | 服务端问题/回复保留期 | 最小化且明确披露,优先不持久化正文 | P5,发布阻塞 | | TBD-011 | 高风险本地资源表覆盖地区 | 首发市场确认后维护,不让模型编号码 | P5 | -| TBD-012 | 架构检查工具 | 选择维护活跃工具或小型自定义测试 | P0/P1 | ## 新增决策模板 diff --git a/docs/domain-rules.md b/docs/domain-rules.md index c544786..3bedb2f 100644 --- a/docs/domain-rules.md +++ b/docs/domain-rules.md @@ -5,6 +5,8 @@ 本文件定义本项目唯一允许的计算规则。UI 文案、AI 输出和数据源都不能覆盖这些规则。 +实现状态:`coin-v1` 纯 Kotlin 核心位于 `app/src/main/java/brainwave/domain/casting/`;`.\gradlew.bat :app:test --offline` 覆盖 8 种币面、4,096 种六爻、64 模式、已知夹具和 DTO 往返。记录时间与内容版本在确定性计算完成后附加。 + ## 1. 术语和类型 建议使用有语义的封闭类型,避免裸 `Int` 在层间传播: @@ -102,7 +104,7 @@ createdAt // 只用于记录,不参与计算 3. 从各爻阴阳形成本卦模式并查得本卦编号。 4. 收集值为 6 或 9 的位置作为动爻。 5. 仅翻转动爻阴阳,形成之卦模式并查得之卦编号。 -6. 将全部原始输入、版本和确定结果构造成不可变 `CastResult`。 +6. 先将全部原始输入和确定结果构造成不可变 `CastComputation`,再附加 `methodVersion`、`coinConvention`、`contentVersion` 与 `createdAt` 记录元数据,组成不可变 `CastResult`;元数据不得反向影响步骤 1~5。 ## 6. 读取内容的产品规则 diff --git a/docs/environment.md b/docs/environment.md index 579418d..889e2c0 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -39,7 +39,7 @@ | Node.js / npm | `22.22.1` / `11.12.1` | | Google Chrome | `150.0.7871.188`,`C:\Program Files\Google\Chrome\Application\chrome.exe` | -环境文件写入前,仓库没有 Gradle Wrapper、`build.gradle*`、`settings.gradle*` 或根 `AGENTS.md`。这些属于 Android 工程初始化阶段的交付物,不应被误认为已存在。 +首次环境审计时仓库没有 Gradle Wrapper、`build.gradle*`、`settings.gradle*` 或根 `AGENTS.md`。当前这些仓库级入口已经建立;`app` 暂时是纯 Kotlin/JVM 领域 harness,不是可安装 Android application。 Windows 路径可能较长;如果后续依赖缓存或生成代码触发路径长度错误,应优先缩短包/生成目录或评估仓库级长路径配置,并把实际决定写入[决策记录](decisions.md)。 @@ -101,15 +101,17 @@ Android Studio 不是当前环境的可用前提。项目必须先支持 PowerSh | 已验证 Gradle | `8.2` | | Gradle 使用的 JVM | Temurin `17.0.13` | | 缓存的 Android Gradle Plugin | `8.2.0` | +| 缓存的 Kotlin Gradle Plugin | `1.9.20` | +| 缓存的 JUnit | `4.13.2` | | 用户级 `~/.gradle/gradle.properties` | 不存在 | -项目初始化应提交 `gradlew`、`gradlew.bat`、`gradle/wrapper/gradle-wrapper.jar` 和版本明确的 `gradle-wrapper.properties`。所有项目命令使用: +仓库已提交 `gradlew`、`gradlew.bat`、`gradle/wrapper/gradle-wrapper.jar` 和固定 Gradle 8.2 及 SHA-256 的 `gradle-wrapper.properties`。所有项目命令使用: ```powershell .\gradlew.bat ``` -不要要求用户安装全局 Gradle。当前缓存能证明 Gradle 8.2 本体可运行,但不能证明 Compose、Kotlin、Hilt、Room 等全部 Maven 依赖已离线缓存;第一次构建仍需实际验证。 +不要要求用户安装全局 Gradle。当前缓存已实际证明 Gradle 8.2、Kotlin 1.9.20 与 JUnit 4.13.2 可离线完成领域构建和测试;Compose、Hilt、Room 等 Android 依赖仍未配置或验证,不能据此推断可离线解析。 ## 7. 已连接 Android 真机 @@ -151,13 +153,14 @@ Android Studio 不是当前环境的可用前提。项目必须先支持 PowerSh ## 10. 已知缺口 -- Android 工程和 Gradle Wrapper 尚未创建。 +- 可安装的 Android application 壳尚未创建;正式名称、组织所有的 application ID 与最终 `minSdk` 仍待确认。 - Android Studio 未安装。 - Android Emulator、system image 和 AVD 不可用。 - 本机只确认安装了 Android Platform 34 / Build Tools 34.0.0。 -- Maven 依赖能否完整离线解析尚未验证。 +- Compose、Hilt、Room、DataStore 与 Navigation 的 Maven 依赖尚未完整离线验证。 - 代理已配置,但外部仓库和 Android CLI 网络连通性尚未验证。 -- 没有根 `AGENTS.md` 和可执行的 `verifyLocal` 聚合任务。 + +已解除的缺口:Gradle Wrapper、Version Catalog、根 `AGENTS.md`、CI 与可执行的 `verifyLocal` 已建立;`verifyLocal --offline` 已在 JDK 17 上通过。它当前不包含 Android lint、APK 构建或设备测试。 这些缺口分别由[实施计划](implementation-plan.md)的 P0 和[质量门禁](quality-gates.md)处理。环境缺口不是跳过验证的理由;无法运行的门禁必须在交付报告中准确说明。 diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index df2eb0a..df77c2e 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -1,6 +1,6 @@ # 分阶段实施计划 -> 状态:P-1 核心原型已确认,首页/问卦簿与默认本机保存 v0.3 已完成待视觉复核;P0 尚未开始 +> 状态:P-1 v0.3 待视觉复核;P0 仓库门禁已建立但 Android 壳受 TBD-001~003 阻塞;P1 已完成并通过穷举测试;P2 内容契约已建立但授权内容未开始 > 计划原则:先用原型确认高返工成本体验,再锁定确定性领域核心,随后接内容和 UI,最后接网络 AI ## 1. 依赖图 @@ -46,14 +46,14 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原 任务: -- 读取并遵守[本地开发环境](environment.md):JDK 17、SDK 34、命令行优先、Gradle Wrapper、真机验证。 -- 基于 Google `android/architecture-templates` 的 `base` 分支初始化。 -- 确认正式应用名称、package/application ID、minSdk。 -- 配置 Kotlin、Compose、Material 3、Hilt、Room、DataStore、Navigation 和 Version Catalog。 -- 配置 Gradle Wrapper、格式化、lint、单元测试和 CI。 -- 建立 [系统架构](architecture.md)中的包结构和空 feature 边界。 -- 将根 `AGENTS.md` 设计为短地图,指向本目录和验证命令。 -- 增加 `verifyLocal` 聚合任务及基础 secret scan。 +- [x] 读取并遵守[本地开发环境](environment.md):JDK 17、SDK 34、命令行优先、Gradle Wrapper、真机验证。 +- [ ] 基于 Google `android/architecture-templates` 的 `base` 分支初始化 Android 壳;模板定制需要 TBD-002 的正式 application ID,不能用临时发布身份替代。 +- [ ] 确认正式应用名称、package/application ID、minSdk(TBD-001~003)。 +- [ ] 配置 Compose、Material 3、Hilt、Room、DataStore 和 Navigation;Version Catalog 已先用于 JVM harness。 +- [ ] 配置 Gradle Wrapper、格式化、lint、单元测试和 CI:Wrapper、无依赖格式门禁、JVM 单测与 CI 已完成;Android lint 要等 application 插件启用。 +- [ ] 建立 [系统架构](architecture.md)中的包结构和空 feature 边界:`domain/casting` 已落地,其余随 Android 壳建立。 +- [x] 将根 `AGENTS.md` 设计为短地图,指向本目录和验证命令。 +- [x] 增加 `verifyLocal` 聚合任务、文档链接、领域边界、内容契约与基础 secret scan。 退出条件: @@ -68,13 +68,13 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原 任务: -- 建立 `CoinSide`、`LineValue`、`Polarity`、`CastRound`、`CastResult`。 -- 实现六轮输入校验和 `CastEngine`。 -- 建立经过双重校验的 64 卦模式映射表。 -- 实现之卦变换和动爻位置。 -- 实现版本化序列化 DTO。 -- 完成 8 种币面、4,096 种六爻、64 模式和已知夹具测试。 -- 增加 domain 无 Android/网络依赖的架构门禁。 +- [x] 建立 `CoinSide`、`LineValue`、`Polarity`、`CastRound`、`CastResult`。 +- [x] 实现六轮输入校验和纯 `CastEngine`;记录时间与内容版本在计算后附加。 +- [x] 建立带完整性自检的 64 卦文王序号映射表。 +- [x] 实现之卦变换和 1~6 的 bottom-up 动爻位置。 +- [x] 实现 `schemaVersion=1` 的序列化 DTO,并在读取时用保存的十八枚币重新计算校验派生值。 +- [x] 完成 8 种币面、4,096 种六爻、64 模式和已知夹具测试。 +- [x] 增加 domain 无 Android、数据库和网络依赖的机械架构门禁。 退出条件: @@ -82,18 +82,20 @@ P1 与 P2 可并行,但 P3 不能在领域与内容契约未稳定时复制原 - 测试无随机、无网络、无系统时间依赖。 - `CastEngine` API 经评审后冻结为 `coin-v1`。 +当前证据:`.\gradlew.bat :app:test --offline` 中 `CastEngineTest` 执行 10 个测试、0 失败;实现位于 `app/src/main/java/brainwave/domain/casting/`。`brainwave` 是内部代码命名空间,不是 TBD-002 的 application ID。 + ## 5. P2:内容数据管线 目标:建立可追踪、可校验、可发布的本地内容包。 任务: -- 决定原文版本、现代白话来源和授权。 -- 实现 JSON schema、解析器和内容版本。 -- 录入/导入 64 卦、卦辞、384 条爻辞及所需特殊文本。 -- 建立来源清单、许可证清单和内容审核记录。 -- 实现构建期完整性校验与映射交叉校验。 -- 实现 `HexagramContentRepository` fake 与 assets 版本。 +- [ ] 决定原文版本、现代白话来源和授权(TBD-005,发布阻塞)。 +- [ ] 实现 JSON schema、解析器和内容版本:`schemaVersion=1` 的机器 schema 已完成;Android/Kotlin assets 解析器待 Android 壳建立。 +- [ ] 录入/导入 64 卦、卦辞、384 条爻辞及所需特殊文本。 +- [ ] 建立来源清单、许可证清单和内容审核记录;schema 已强制每个来源包含版本、许可证与 URL。 +- [x] 实现构建期完整性校验、文王序号/上下卦/bottom-up 交叉校验、稳定 SHA-256 摘要及 8 个负向夹具。 +- [ ] 实现 `HexagramContentRepository` fake 与 assets 版本:接口、强校验只读模型和测试 fake 已完成,assets 实现待 Android parser。 退出条件: diff --git a/docs/quality-gates.md b/docs/quality-gates.md index c1a7516..c3dc0fd 100644 --- a/docs/quality-gates.md +++ b/docs/quality-gates.md @@ -1,6 +1,6 @@ # 质量门禁与验证策略 -> 状态:测试策略已定义;命令在 Android 骨架初始化后启用 +> 状态:JVM/harness 门禁已启用;Android lint、构建、UI 与设备门禁等待 Android application 壳 > 原则:完成必须有可重复证据,不能以“代码看起来正确”代替验证 ## 1. 反馈循环 @@ -17,7 +17,18 @@ ## 2. 预期本地命令 -工程创建后,Windows 环境至少提供以下稳定入口: +当前可重复的 Windows 快速门禁: + +```powershell +.\gradlew.bat spotlessCheck --offline +.\gradlew.bat :app:test --offline +.\gradlew.bat verifyContentContract --offline +.\gradlew.bat verifyLocal --offline +``` + +`verifyLocal` 当前聚合无依赖格式检查、10 个领域测试、3 个内容 repository 测试、domain 依赖边界、内容契约与负向夹具、文档链接、高置信 secret scan 和原型 JavaScript 语法检查。CI 执行同一个聚合任务。 + +Android application 插件配置后,Windows 环境还必须提供: ```powershell .\gradlew.bat spotlessCheck @@ -29,15 +40,7 @@ 若采用不同格式化插件,命令可以调整,但必须在本文件和 CI 同步更新。`connectedDebugAndroidTest` 需要模拟器或设备,应与纯 JVM 快速门禁分开。 -建议再提供聚合任务: - -```powershell -.\gradlew.bat verifyLocal -``` - -它至少依赖格式、lint、JVM 单元测试和 debug 构建,使代理不必猜测正确验证组合。 - -当前仓库没有 Gradle Wrapper,所以上述命令尚未运行,也不能报告为通过。 +到那时必须把 Android lint 和 debug build 加入现有 `verifyLocal`,使代理仍不必猜测验证组合。在完成这一步之前,`verifyLocal` 通过只证明 JVM 与仓库门禁,不代表 APK 已构建或真机测试已通过。 ## 3. 测试层次 @@ -148,6 +151,8 @@ 可以使用现有静态工具、架构测试库或小型自定义 Gradle 任务;具体选型写入[决策记录](decisions.md)。规则的错误消息应告诉代理如何修复,而不只报告失败。 +当前由无外部依赖的 Node 脚本与 Gradle 任务执行上述已落地规则,见 ADR-013。引入 Android 源码后应扩展同一入口,不应建立一套互不相干的新命令。 + ## 6. 发布门禁 发布候选必须满足: diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..c0c6051 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,9 @@ +[versions] +kotlin = "1.9.20" +junit = "4.13.2" + +[libraries] +junit = { module = "junit:junit", version.ref = "junit" } + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..033e24c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3fdf349 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionSha256Sum=38f66cd6eef217b4c35855bb11ea4e9fbc53594ccccb5fb82dfd317ef8c2c5a3 +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..fcb6fca --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/scripts/content-contract.mjs b/scripts/content-contract.mjs new file mode 100644 index 0000000..55206e8 --- /dev/null +++ b/scripts/content-contract.mjs @@ -0,0 +1,203 @@ +import { createHash } from "node:crypto"; + +const trigramPatterns = Object.freeze({ + QIAN: ["YANG", "YANG", "YANG"], + DUI: ["YANG", "YANG", "YIN"], + LI: ["YANG", "YIN", "YANG"], + ZHEN: ["YANG", "YIN", "YIN"], + XUN: ["YIN", "YANG", "YANG"], + KAN: ["YIN", "YANG", "YIN"], + GEN: ["YIN", "YIN", "YANG"], + KUN: ["YIN", "YIN", "YIN"], +}); + +const kingWenPairs = Object.freeze([ + ["QIAN", "QIAN"], ["KUN", "KUN"], ["KAN", "ZHEN"], ["GEN", "KAN"], + ["KAN", "QIAN"], ["QIAN", "KAN"], ["KUN", "KAN"], ["KAN", "KUN"], + ["XUN", "QIAN"], ["QIAN", "DUI"], ["KUN", "QIAN"], ["QIAN", "KUN"], + ["QIAN", "LI"], ["LI", "QIAN"], ["KUN", "GEN"], ["ZHEN", "KUN"], + ["DUI", "ZHEN"], ["GEN", "XUN"], ["KUN", "DUI"], ["XUN", "KUN"], + ["LI", "ZHEN"], ["GEN", "LI"], ["GEN", "KUN"], ["KUN", "ZHEN"], + ["QIAN", "ZHEN"], ["GEN", "QIAN"], ["GEN", "ZHEN"], ["DUI", "XUN"], + ["KAN", "KAN"], ["LI", "LI"], ["DUI", "GEN"], ["ZHEN", "XUN"], + ["QIAN", "GEN"], ["ZHEN", "QIAN"], ["LI", "KUN"], ["KUN", "LI"], + ["XUN", "LI"], ["LI", "DUI"], ["KAN", "GEN"], ["ZHEN", "KAN"], + ["GEN", "DUI"], ["XUN", "ZHEN"], ["DUI", "QIAN"], ["QIAN", "XUN"], + ["DUI", "KUN"], ["KUN", "XUN"], ["DUI", "KAN"], ["KAN", "XUN"], + ["DUI", "LI"], ["LI", "XUN"], ["ZHEN", "ZHEN"], ["GEN", "GEN"], + ["XUN", "GEN"], ["ZHEN", "DUI"], ["ZHEN", "LI"], ["LI", "GEN"], + ["XUN", "XUN"], ["DUI", "DUI"], ["XUN", "KAN"], ["KAN", "DUI"], + ["XUN", "DUI"], ["ZHEN", "GEN"], ["KAN", "LI"], ["LI", "KAN"], +]); + +const kingWenByPair = new Map( + kingWenPairs.map(([upper, lower], index) => [`${upper}/${lower}`, index + 1]), +); + +const unsafeText = /(?:<\s*script\b|javascript\s*:|\bon\w+\s*=|[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f])/iu; + +function isObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonBlank(value) { + return typeof value === "string" && value.trim().length > 0; +} + +function validateText(value, path, errors) { + if (!nonBlank(value)) { + errors.push(`${path} must be non-blank text`); + } else if (unsafeText.test(value)) { + errors.push(`${path} contains script-like markup or an invisible control character`); + } +} + +function validateSixTexts(value, path, errors) { + if (!Array.isArray(value) || value.length !== 6) { + errors.push(`${path} must contain exactly six bottom-up entries`); + return; + } + value.forEach((text, index) => validateText(text, `${path}[${index}]`, errors)); +} + +function canonicalize(value) { + if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`; + if (isObject(value)) { + return `{${Object.keys(value).sort().map((key) => + `${JSON.stringify(key)}:${canonicalize(value[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +export function contentDigest(contentPackage) { + return createHash("sha256").update(canonicalize(contentPackage), "utf8").digest("hex"); +} + +export function kingWenEntries() { + return kingWenPairs.map(([upperTrigram, lowerTrigram], index) => ({ + kingWenNumber: index + 1, + upperTrigram, + lowerTrigram, + patternBottomUp: [...trigramPatterns[lowerTrigram], ...trigramPatterns[upperTrigram]], + })); +} + +export function validateContentPackage(contentPackage) { + const errors = []; + if (!isObject(contentPackage)) return ["content package must be an object"]; + if (contentPackage.schemaVersion !== 1) errors.push("schemaVersion must be 1"); + validateText(contentPackage.contentVersion, "contentVersion", errors); + + const usage = contentPackage.specialUsageTexts; + if (!isObject(usage) || typeof usage.qian !== "boolean" || typeof usage.kun !== "boolean") { + errors.push("specialUsageTexts must declare boolean qian and kun flags"); + } + + const sourceIds = new Set(); + if (!Array.isArray(contentPackage.sources) || contentPackage.sources.length === 0) { + errors.push("sources must contain at least one licensed source"); + } else { + contentPackage.sources.forEach((source, index) => { + const prefix = `sources[${index}]`; + if (!isObject(source)) { + errors.push(`${prefix} must be an object`); + return; + } + for (const field of ["id", "title", "edition", "license"]) { + validateText(source[field], `${prefix}.${field}`, errors); + } + if (sourceIds.has(source.id)) errors.push(`${prefix}.id must be unique`); + if (nonBlank(source.id)) sourceIds.add(source.id); + try { + const url = new URL(source.url); + if (!["http:", "https:"].includes(url.protocol)) throw new Error("unsupported protocol"); + } catch { + errors.push(`${prefix}.url must be an absolute HTTP(S) URL`); + } + }); + } + + if (!Array.isArray(contentPackage.hexagrams) || contentPackage.hexagrams.length !== 64) { + errors.push("hexagrams must contain exactly 64 entries"); + return errors; + } + + const seenIds = new Set(); + const seenPatterns = new Set(); + contentPackage.hexagrams.forEach((hexagram, index) => { + const prefix = `hexagrams[${index}]`; + if (!isObject(hexagram)) { + errors.push(`${prefix} must be an object`); + return; + } + + if (!Number.isInteger(hexagram.kingWenNumber) || hexagram.kingWenNumber < 1 || hexagram.kingWenNumber > 64) { + errors.push(`${prefix}.kingWenNumber must be an integer from 1 through 64`); + } else if (seenIds.has(hexagram.kingWenNumber)) { + errors.push(`${prefix}.kingWenNumber must be unique`); + } else { + seenIds.add(hexagram.kingWenNumber); + } + + for (const field of ["name", "symbol", "judgmentOriginal", "judgmentPlain"]) { + validateText(hexagram[field], `${prefix}.${field}`, errors); + } + validateSixTexts(hexagram.lineTextsBottomUp, `${prefix}.lineTextsBottomUp`, errors); + validateSixTexts(hexagram.linePlainBottomUp, `${prefix}.linePlainBottomUp`, errors); + + const lowerPattern = trigramPatterns[hexagram.lowerTrigram]; + const upperPattern = trigramPatterns[hexagram.upperTrigram]; + if (!lowerPattern) errors.push(`${prefix}.lowerTrigram is unknown`); + if (!upperPattern) errors.push(`${prefix}.upperTrigram is unknown`); + + if (!Array.isArray(hexagram.patternBottomUp) || hexagram.patternBottomUp.length !== 6 || + hexagram.patternBottomUp.some((line) => line !== "YIN" && line !== "YANG")) { + errors.push(`${prefix}.patternBottomUp must contain exactly six YIN/YANG values`); + } else { + const encoded = hexagram.patternBottomUp.join("/"); + if (seenPatterns.has(encoded)) errors.push(`${prefix}.patternBottomUp must be unique`); + seenPatterns.add(encoded); + if (lowerPattern && upperPattern) { + const expected = [...lowerPattern, ...upperPattern]; + if (encoded !== expected.join("/")) { + errors.push(`${prefix}.patternBottomUp does not match lower/upper trigrams in bottom-up order`); + } + } + } + + if (lowerPattern && upperPattern) { + const expectedId = kingWenByPair.get(`${hexagram.upperTrigram}/${hexagram.lowerTrigram}`); + if (hexagram.kingWenNumber !== expectedId) { + errors.push(`${prefix}.kingWenNumber does not match its King Wen trigram pair`); + } + } + + if (!Array.isArray(hexagram.sourceRefs) || hexagram.sourceRefs.length === 0) { + errors.push(`${prefix}.sourceRefs must not be empty`); + } else { + for (const sourceRef of hexagram.sourceRefs) { + if (!sourceIds.has(sourceRef)) errors.push(`${prefix}.sourceRefs contains unknown source '${sourceRef}'`); + } + if (new Set(hexagram.sourceRefs).size !== hexagram.sourceRefs.length) { + errors.push(`${prefix}.sourceRefs must be unique`); + } + } + + const hasSpecialText = nonBlank(hexagram.specialUsageText); + if (hexagram.specialUsageText !== null && !hasSpecialText) { + errors.push(`${prefix}.specialUsageText must be non-blank text or null`); + } + if (hasSpecialText) validateText(hexagram.specialUsageText, `${prefix}.specialUsageText`, errors); + if (hexagram.kingWenNumber === 1 && isObject(usage) && hasSpecialText !== usage.qian) { + errors.push(`${prefix}.specialUsageText must match specialUsageTexts.qian`); + } else if (hexagram.kingWenNumber === 2 && isObject(usage) && hasSpecialText !== usage.kun) { + errors.push(`${prefix}.specialUsageText must match specialUsageTexts.kun`); + } else if (![1, 2].includes(hexagram.kingWenNumber) && hexagram.specialUsageText !== null) { + errors.push(`${prefix}.specialUsageText is only valid for Qian or Kun`); + } + }); + + if (seenIds.size !== 64) errors.push("King Wen numbers 1 through 64 must each occur exactly once"); + if (seenPatterns.size !== 64) errors.push("all 64 polarity patterns must each occur exactly once"); + return errors; +} diff --git a/scripts/repository-files.mjs b/scripts/repository-files.mjs new file mode 100644 index 0000000..3473a76 --- /dev/null +++ b/scripts/repository-files.mjs @@ -0,0 +1,41 @@ +import { readdir, readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +export const repositoryRoot = path.resolve(import.meta.dirname, ".."); + +const excludedDirectories = new Set([ + ".git", + ".gradle", + ".idea", + "build", + "node_modules", +]); + +export async function walkFiles(directory = repositoryRoot) { + const result = []; + const entries = await readdir(directory, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory() && excludedDirectories.has(entry.name)) continue; + const absolutePath = path.join(directory, entry.name); + if (entry.isDirectory()) { + result.push(...(await walkFiles(absolutePath))); + } else if (entry.isFile()) { + result.push(absolutePath); + } + } + + return result; +} + +export async function readTextFileIfSmall(file, maximumBytes = 2_000_000) { + const metadata = await stat(file); + if (metadata.size > maximumBytes) return null; + const buffer = await readFile(file); + if (buffer.includes(0)) return null; + return buffer.toString("utf8"); +} + +export function relative(file) { + return path.relative(repositoryRoot, file).replaceAll(path.sep, "/"); +} diff --git a/scripts/scan-secrets.mjs b/scripts/scan-secrets.mjs new file mode 100644 index 0000000..918e4c5 --- /dev/null +++ b/scripts/scan-secrets.mjs @@ -0,0 +1,47 @@ +import path from "node:path"; +import { + readTextFileIfSmall, + relative, + walkFiles, +} from "./repository-files.mjs"; + +const patterns = [ + ["private key block", /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/u], + ["GitHub token", /\bgh[pousr]_[A-Za-z0-9]{30,}\b/u], + ["OpenAI-style key", /\bsk-[A-Za-z0-9_-]{20,}\b/u], + ["Google API key", /\bAIza[0-9A-Za-z_-]{35}\b/u], + ["AWS access key", /\bAKIA[0-9A-Z]{16}\b/u], +]; + +const excludedExtensions = new Set([ + ".gif", + ".ico", + ".jpeg", + ".jpg", + ".pdf", + ".png", + ".webp", + ".zip", +]); + +const findings = []; +for (const file of await walkFiles()) { + if (excludedExtensions.has(path.extname(file).toLowerCase())) continue; + const content = await readTextFileIfSmall(file); + if (content === null) continue; + + for (const [label, pattern] of patterns) { + const match = pattern.exec(content); + if (!match) continue; + const line = content.slice(0, match.index).split(/\r?\n/u).length; + findings.push(`${relative(file)}:${line} (${label})`); + } +} + +if (findings.length > 0) { + console.error("Potential secrets found; values are intentionally redacted:"); + for (const finding of findings) console.error(`- ${finding}`); + process.exitCode = 1; +} else { + console.log("High-confidence secret scan passed."); +} diff --git a/scripts/verify-content-contract.mjs b/scripts/verify-content-contract.mjs new file mode 100644 index 0000000..0c4a185 --- /dev/null +++ b/scripts/verify-content-contract.mjs @@ -0,0 +1,104 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { + contentDigest, + kingWenEntries, + validateContentPackage, +} from "./content-contract.mjs"; +import { repositoryRoot } from "./repository-files.mjs"; + +const schemaPath = path.join(repositoryRoot, "content", "schema", "hexagram-content.schema.json"); +const schema = JSON.parse(await readFile(schemaPath, "utf8")); +if (schema.$schema !== "https://json-schema.org/draft/2020-12/schema") { + throw new Error("Content JSON Schema must use draft 2020-12"); +} + +const kotlinCatalogPath = path.join( + repositoryRoot, + "app", + "src", + "main", + "java", + "brainwave", + "domain", + "casting", + "HexagramCatalog.kt", +); +const kotlinCatalog = await readFile(kotlinCatalogPath, "utf8"); +const kotlinPairPattern = /TrigramPair\(Trigram\.(\w+), Trigram\.(\w+)\) to (\d+)/gu; +const kotlinPairs = new Map( + [...kotlinCatalog.matchAll(kotlinPairPattern)].map((match) => [ + `${match[1]}/${match[2]}`, + Number(match[3]), + ]), +); +const contractEntries = kingWenEntries(); +if (kotlinPairs.size !== 64 || contractEntries.some((entry) => + kotlinPairs.get(`${entry.upperTrigram}/${entry.lowerTrigram}`) !== entry.kingWenNumber)) { + throw new Error("Kotlin King Wen catalog and content-contract lookup must match exactly"); +} + +function validFixture() { + return { + schemaVersion: 1, + contentVersion: "fixture-only-v1", + specialUsageTexts: { qian: false, kun: false }, + sources: [{ + id: "fixture-source", + title: "Automated test fixture", + edition: "not publishable", + license: "test data only", + url: "https://example.invalid/fixture", + }], + hexagrams: contractEntries.map((entry) => ({ + ...entry, + name: `fixture-${entry.kingWenNumber}`, + symbol: `fixture-symbol-${entry.kingWenNumber}`, + judgmentOriginal: "fixture original text", + judgmentPlain: "fixture plain text", + lineTextsBottomUp: Array.from({ length: 6 }, (_, index) => `fixture original line ${index + 1}`), + linePlainBottomUp: Array.from({ length: 6 }, (_, index) => `fixture plain line ${index + 1}`), + specialUsageText: null, + sourceRefs: ["fixture-source"], + })), + }; +} + +function clone(value) { + return structuredClone(value); +} + +const fixture = validFixture(); +const validErrors = validateContentPackage(fixture); +if (validErrors.length > 0) { + throw new Error(`Valid content fixture was rejected:\n${validErrors.join("\n")}`); +} + +const digest = contentDigest(fixture); +if (!/^[a-f0-9]{64}$/u.test(digest) || digest !== contentDigest(clone(fixture))) { + throw new Error("Content digest must be a stable SHA-256 value"); +} + +const negativeCases = [ + ["unsupported schema", (value) => { value.schemaVersion = 2; }, "schemaVersion"], + ["duplicate id", (value) => { value.hexagrams[1].kingWenNumber = 1; }, "unique"], + ["wrong bottom-up pattern", (value) => { value.hexagrams[0].patternBottomUp[0] = "YIN"; }, "bottom-up"], + ["five line texts", (value) => { value.hexagrams[0].lineTextsBottomUp.pop(); }, "exactly six"], + ["unknown source", (value) => { value.hexagrams[0].sourceRefs = ["missing"]; }, "unknown source"], + ["blank license", (value) => { value.sources[0].license = " "; }, "non-blank"], + ["script content", (value) => { value.hexagrams[0].judgmentPlain = ""; }, "script-like"], + ["special declaration mismatch", (value) => { value.specialUsageTexts.qian = true; }, "specialUsageTexts.qian"], +]; + +for (const [name, mutate, expected] of negativeCases) { + const candidate = clone(fixture); + mutate(candidate); + const errors = validateContentPackage(candidate); + if (!errors.some((error) => error.includes(expected))) { + throw new Error(`${name} fixture did not fail with '${expected}':\n${errors.join("\n")}`); + } +} + +console.log( + `Content contract verification passed (64 entries cross-checked with Kotlin, ${negativeCases.length} negative fixtures, digest ${digest.slice(0, 12)}…).`, +); diff --git a/scripts/verify-docs.mjs b/scripts/verify-docs.mjs new file mode 100644 index 0000000..bc067fa --- /dev/null +++ b/scripts/verify-docs.mjs @@ -0,0 +1,67 @@ +import { access } from "node:fs/promises"; +import path from "node:path"; +import { + readTextFileIfSmall, + relative, + repositoryRoot, + walkFiles, +} from "./repository-files.mjs"; + +const requiredDocuments = [ + "AGENTS.md", + "docs/README.md", + "docs/agent-playbook.md", + "docs/implementation-plan.md", + "docs/quality-gates.md", + "docs/decisions.md", +]; + +const failures = []; + +for (const required of requiredDocuments) { + try { + await access(path.join(repositoryRoot, required)); + } catch { + failures.push(`missing required document: ${required}`); + } +} + +const markdownFiles = (await walkFiles()).filter((file) => file.endsWith(".md")); +const markdownLink = /!?\[[^\]]*\]\(([^)]+)\)/g; + +for (const markdownFile of markdownFiles) { + const content = await readTextFileIfSmall(markdownFile); + if (content === null) continue; + + for (const match of content.matchAll(markdownLink)) { + let target = match[1].trim(); + if (target.startsWith("<") && target.endsWith(">")) { + target = target.slice(1, -1); + } + target = target.split(/\s+["']/u, 1)[0]; + if ( + target === "" || + target.startsWith("#") || + /^[a-z][a-z\d+.-]*:/iu.test(target) + ) { + continue; + } + + const pathPart = target.split("#", 1)[0].split("?", 1)[0]; + const decodedPath = decodeURIComponent(pathPart); + const resolved = path.resolve(path.dirname(markdownFile), decodedPath); + try { + await access(resolved); + } catch { + failures.push(`${relative(markdownFile)} -> ${target}`); + } + } +} + +if (failures.length > 0) { + console.error("Documentation verification failed:"); + for (const failure of failures) console.error(`- ${failure}`); + process.exitCode = 1; +} else { + console.log(`Documentation verification passed (${markdownFiles.length} Markdown files).`); +} diff --git a/scripts/verify-domain-boundaries.mjs b/scripts/verify-domain-boundaries.mjs new file mode 100644 index 0000000..2a9eb1f --- /dev/null +++ b/scripts/verify-domain-boundaries.mjs @@ -0,0 +1,47 @@ +import path from "node:path"; +import { + readTextFileIfSmall, + relative, + repositoryRoot, + walkFiles, +} from "./repository-files.mjs"; + +const domainRoot = path.join(repositoryRoot, "app", "src", "main", "java", "brainwave", "domain"); +const forbiddenImports = [ + /^android\./u, + /^androidx\./u, + /^com\.google\.dagger\./u, + /^dagger\./u, + /^okhttp3\./u, + /^retrofit2\./u, + /^io\.ktor\./u, + /^androidx\.room\./u, +]; + +const failures = []; +let kotlinFiles = []; +try { + kotlinFiles = (await walkFiles(domainRoot)).filter((file) => file.endsWith(".kt")); +} catch { + failures.push(`domain source directory is missing: ${relative(domainRoot)}`); +} + +for (const file of kotlinFiles) { + const content = await readTextFileIfSmall(file); + if (content === null) continue; + for (const [index, line] of content.split(/\r?\n/u).entries()) { + const match = /^\s*import\s+([^\s]+)/u.exec(line); + if (!match) continue; + if (forbiddenImports.some((pattern) => pattern.test(match[1]))) { + failures.push(`${relative(file)}:${index + 1} forbidden import ${match[1]}`); + } + } +} + +if (failures.length > 0) { + console.error("Domain boundary verification failed:"); + for (const failure of failures) console.error(`- ${failure}`); + process.exitCode = 1; +} else { + console.log(`Domain boundary verification passed (${kotlinFiles.length} Kotlin files).`); +} diff --git a/scripts/verify-format.mjs b/scripts/verify-format.mjs new file mode 100644 index 0000000..531b0a7 --- /dev/null +++ b/scripts/verify-format.mjs @@ -0,0 +1,54 @@ +import path from "node:path"; +import { + readTextFileIfSmall, + relative, + walkFiles, +} from "./repository-files.mjs"; + +const checkedExtensions = new Set([ + ".css", + ".html", + ".js", + ".json", + ".kt", + ".kts", + ".md", + ".mjs", + ".properties", + ".toml", + ".xml", + ".yaml", + ".yml", +]); + +const failures = []; +let checked = 0; +for (const file of await walkFiles()) { + if (!checkedExtensions.has(path.extname(file).toLowerCase())) continue; + const content = await readTextFileIfSmall(file); + if (content === null) continue; + checked += 1; + + if (content.length > 0 && !content.endsWith("\n")) { + failures.push(`${relative(file)} must end with a newline`); + } + content.split(/\r?\n/u).forEach((line, index) => { + if (/[ \t]+$/u.test(line)) failures.push(`${relative(file)}:${index + 1} has trailing whitespace`); + }); + + if (file.endsWith(".json")) { + try { + JSON.parse(content); + } catch (error) { + failures.push(`${relative(file)} is invalid JSON: ${error.message}`); + } + } +} + +if (failures.length > 0) { + console.error("Formatting verification failed:"); + for (const failure of failures) console.error(`- ${failure}`); + process.exitCode = 1; +} else { + console.log(`Formatting verification passed (${checked} text files).`); +} diff --git a/scripts/verify-prototype.mjs b/scripts/verify-prototype.mjs new file mode 100644 index 0000000..c38142a --- /dev/null +++ b/scripts/verify-prototype.mjs @@ -0,0 +1,23 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { repositoryRoot } from "./repository-files.mjs"; + +const scripts = ["prototype/app.js", "prototype/capture.mjs"]; +const failures = []; + +for (const script of scripts) { + const result = spawnSync(process.execPath, ["--check", path.join(repositoryRoot, script)], { + encoding: "utf8", + }); + if (result.status !== 0) { + failures.push(`${script}: ${(result.stderr || result.stdout).trim()}`); + } +} + +if (failures.length > 0) { + console.error("Prototype syntax verification failed:"); + for (const failure of failures) console.error(`- ${failure}`); + process.exitCode = 1; +} else { + console.log(`Prototype syntax verification passed (${scripts.length} files).`); +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..5523ac9 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,19 @@ +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +// Repository code name only. This is not the formal product name (TBD-001). +rootProject.name = "brainwave" +include(":app")