feat: add domain core and verification harness
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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.*
|
||||
@@ -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.
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -2,7 +2,7 @@
|
||||
|
||||
> 文档状态:方案基线
|
||||
> 最后核验:2026-08-04
|
||||
> 当前阶段:P-1 核心原型已确认;首页、问卦簿与默认本机保存 v0.3 已实现并待视觉复核;Android 工程尚未初始化
|
||||
> 当前阶段:P-1 v0.3 待视觉复核;P1 纯 Kotlin 领域核心已完成;P0 仓库门禁与 P2 内容契约已部分完成;可发布 Android 壳仍等待正式身份与 `minSdk` 决策
|
||||
|
||||
本目录是 Brainwave 的项目知识事实源。产品决策、领域算法、架构边界、验收标准和已知失败模式必须写入仓库;聊天记录、口头约定和临时提示不构成项目规范。
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
## 新增决策模板
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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. 发布门禁
|
||||
|
||||
发布候选必须满足:
|
||||
|
||||
@@ -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" }
|
||||
Vendored
BIN
Binary file not shown.
+8
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, "/");
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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)}…).`,
|
||||
);
|
||||
@@ -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).`);
|
||||
}
|
||||
@@ -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).`);
|
||||
}
|
||||
@@ -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).`);
|
||||
}
|
||||
@@ -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).`);
|
||||
}
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user