feat: add domain core and verification harness

This commit is contained in:
QiuSW
2026-08-04 23:28:10 +08:00
parent a82af6939f
commit bad67fa5a8
38 changed files with 2054 additions and 43 deletions
+14
View File
@@ -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
+29
View File
@@ -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
+14
View File
@@ -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.*
+48
View File
@@ -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.
+27
View File
@@ -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
}
}
@@ -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<String>,
val linePlainBottomUp: List<String>,
val specialUsageText: String?,
val sourceRefs: List<String>,
) {
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"
}
}
}
@@ -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)
@@ -0,0 +1,80 @@
package brainwave.domain.casting
data class CastComputation internal constructor(
val roundsBottomUp: List<CastRound>,
val lineValuesBottomUp: List<LineValue>,
val primaryPatternBottomUp: HexagramPattern,
val movingLinePositions: List<Int>,
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<CastRound>): 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<CastRound>,
val lineValuesBottomUp: List<LineValue>,
val primaryPatternBottomUp: HexagramPattern,
val movingLinePositions: List<Int>,
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,
)
}
}
@@ -0,0 +1,75 @@
package brainwave.domain.casting
data class CastRecordDto(
val schemaVersion: Int,
val methodVersion: String,
val coinConvention: String,
val roundsBottomUp: List<List<Int>>,
val lineValuesBottomUp: List<Int>,
val primaryPatternBottomUp: String,
val movingLinePositions: List<Int>,
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,
)
}
}
@@ -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<CoinSide>) {
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<CoinSide>): CastRound = CastRound(coins.toList())
fun of(first: CoinSide, second: CoinSide, third: CoinSide): CastRound =
of(listOf(first, second, third))
fun fromScores(scores: List<Int>): 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<Polarity>) {
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<Polarity>): 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" }
}
}
@@ -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)))
}
}
@@ -0,0 +1,31 @@
package brainwave.data.content
import brainwave.core.model.HexagramContent
class FakeHexagramContentRepository(
override val contentVersion: String = "fixture-only-v1",
entries: List<HexagramContent>,
) : HexagramContentRepository {
private val entriesById: Map<Int, HexagramContent>
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")
}
}
@@ -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))
}
}
}
@@ -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<LineValue, Int>()
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<Int>()
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<Int>,
primary: Int,
transformed: Int,
moving: List<Int>,
) {
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),
)
}
}
}
+70
View File
@@ -0,0 +1,70 @@
import org.gradle.api.tasks.Exec
plugins {
base
}
fun registerNodeVerificationTask(
name: String,
description: String,
script: String,
) = tasks.register<Exec>(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")
}
+117
View File
@@ -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" }
}
}
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
> 文档状态:方案基线
> 最后核验:2026-08-04
> 当前阶段:P-1 核心原型已确认;首页、问卦簿与默认本机保存 v0.3 已实现并待视觉复核;Android 工程尚未初始化
> 当前阶段:P-1 v0.3 待视觉复核;P1 纯 Kotlin 领域核心已完成;P0 仓库门禁与 P2 内容契约已部分完成;可发布 Android 壳仍等待正式身份与 `minSdk` 决策
本目录是 Brainwave 的项目知识事实源。产品决策、领域算法、架构边界、验收标准和已知失败模式必须写入仓库;聊天记录、口头约定和临时提示不构成项目规范。
+1 -1
View File
@@ -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`
+6
View File
@@ -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. 内容完整性门禁
内容包进入应用前必须自动验证:
+10 -1
View File
@@ -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 |
## 新增决策模板
+3 -1
View File
@@ -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. 读取内容的产品规则
+9 -6
View File
@@ -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 <task>
```
不要要求用户安装全局 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)处理。环境缺口不是跳过验证的理由;无法运行的门禁必须在交付报告中准确说明。
+24 -22
View File
@@ -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。
退出条件:
+16 -11
View File
@@ -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. 发布门禁
发布候选必须满足:
+9
View File
@@ -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" }
Binary file not shown.
+8
View File
@@ -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
Vendored Executable
+248
View File
@@ -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" "$@"
Vendored
+92
View File
@@ -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
+203
View File
@@ -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;
}
+41
View File
@@ -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, "/");
}
+47
View File
@@ -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.");
}
+104
View File
@@ -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>alert(1)</script>"; }, "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)}…).`,
);
+67
View File
@@ -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).`);
}
+47
View File
@@ -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).`);
}
+54
View File
@@ -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).`);
}
+23
View File
@@ -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).`);
}
+19
View File
@@ -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")