create-cmp-cli 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -9
- package/bin/create-cmp.mjs +3 -0
- package/package.json +1 -1
- package/src/commands/upgrade.mjs +287 -0
- package/src/lib/harness-upgrade.mjs +364 -0
- package/src/lib/package-name.mjs +72 -0
- package/src/scaffold.mjs +7 -2
- package/template/.claude/settings.json +30 -0
- package/template/CLAUDE.md +51 -6
- package/template/README.md +4 -0
- package/template/composeApp/build.gradle.kts +44 -0
- package/template/composeApp/src/androidDebug/AndroidManifest.xml +9 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/PlatformBehaviorSeamTest.kt +277 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/RuntimeStateSeamTest.kt +308 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/AlarmAsserts.kt +152 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ConfigControl.kt +124 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/DozeControl.kt +113 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NetworkControl.kt +137 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NotificationAsserts.kt +163 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/PermissionControl.kt +132 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ProcessControl.kt +217 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt +79 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/SystemState.kt +113 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt +114 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +5 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +1 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +1 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +1 -1
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +58 -0
- package/template/docs/ARCHITECTURE.md +41 -2
- package/template/docs/TESTING.md +165 -0
- package/template/gradle/libs.versions.toml +15 -0
- package/template/manifest.json +1 -0
- package/template/qa/evidence/schema.json +20 -2
- package/template/qa/lib/affected-tests.mjs +147 -0
- package/template/qa/lib/audit-cadence.mjs +290 -0
- package/template/qa/lib/determinism.mjs +179 -0
- package/template/qa/lib/device-lease.mjs +249 -0
- package/template/qa/lib/evidence-badge.mjs +158 -0
- package/template/qa/lib/evidence-level.mjs +117 -0
- package/template/qa/lib/flight-recorder.mjs +332 -0
- package/template/qa/lib/inputs-hash.mjs +16 -1
- package/template/qa/lib/spec-coverage.mjs +54 -3
- package/template/qa/lib/step-cache.mjs +221 -0
- package/template/qa/receipt-check.mjs +22 -2
- package/template/qa/record-audit.mjs +83 -0
- package/template/qa/retrospective.mjs +51 -0
- package/template/qa/scaffold-feature.mjs +20 -1
- package/template/qa/verify.mjs +934 -57
- package/template/qa/watch.mjs +622 -0
- package/template/specs/app-base.spec.md +11 -0
package/template/manifest.json
CHANGED
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
"required": ["schema", "profile", "verdict", "commit", "steps", "artifacts", "toolVersions", "generatedAt"],
|
|
7
7
|
"properties": {
|
|
8
8
|
"schema": { "const": "cmp-evidence/1" },
|
|
9
|
-
"profile": { "enum": ["scaffold", "local", "ci"] },
|
|
9
|
+
"profile": { "enum": ["scaffold", "local", "ci", "release"] },
|
|
10
|
+
"mode": {
|
|
11
|
+
"enum": ["full", "fast"],
|
|
12
|
+
"description": "How the lane was run. \"full\" is the done-gate. \"fast\" (verify --fast) excluded the device/release tier (releaseBuild, tokenDrift, e2eSmoke, androidChecks, releaseSmoke): an inner-loop signal whose receipt derives no evidence rung and is REFUSED by qa/receipt-check.mjs — it can never satisfy done. Absent on receipts predating the flag — treated as full."
|
|
13
|
+
},
|
|
10
14
|
"verdict": { "enum": ["PASS", "FAIL"] },
|
|
11
15
|
"commit": {
|
|
12
16
|
"type": "object",
|
|
@@ -32,13 +36,27 @@
|
|
|
32
36
|
"required": ["name", "verdict", "durationMs"],
|
|
33
37
|
"properties": {
|
|
34
38
|
"name": { "type": "string" },
|
|
35
|
-
"verdict": {
|
|
39
|
+
"verdict": {
|
|
40
|
+
"enum": ["PASS", "FAIL", "SKIP", "CACHED"],
|
|
41
|
+
"description": "CACHED appears ONLY on mode:\"fast\" receipts: a pure-Node step's last PASS reused because its content-hashed input set is unchanged (qa/lib/step-cache.mjs). Counts as PASS for the lane verdict but stays visibly distinct — the full lane never consults the cache, so a full receipt never carries it."
|
|
42
|
+
},
|
|
36
43
|
"reason": { "type": "string" },
|
|
44
|
+
"note": { "type": "string", "description": "Honest fine print on a non-FAIL step (fast mode): which unit-test filter ran, or when a CACHED verdict was originally earned." },
|
|
37
45
|
"durationMs": { "type": "number" },
|
|
38
46
|
"details": { "type": "object" }
|
|
39
47
|
}
|
|
40
48
|
}
|
|
41
49
|
},
|
|
50
|
+
"evidenceLevel": {
|
|
51
|
+
"type": ["object", "null"],
|
|
52
|
+
"description": "The evidence ladder rung (qa/lib/evidence-level.mjs), DERIVED from which steps actually ran and PASSed — never declared. L0 scaffold / L1 desktop / L2 device / L3 release; a SKIPped step never upgrades a rung. null when the lane FAILed (a failed lane has no rung). Absent on receipts predating the ladder.",
|
|
53
|
+
"required": ["rung", "name", "satisfiedBy"],
|
|
54
|
+
"properties": {
|
|
55
|
+
"rung": { "enum": ["L0", "L1", "L2", "L3"] },
|
|
56
|
+
"name": { "enum": ["scaffold", "desktop", "device", "release"] },
|
|
57
|
+
"satisfiedBy": { "type": "array", "items": { "type": "string" }, "description": "The PASSed step names the rung counts as its evidence, in lane order" }
|
|
58
|
+
}
|
|
59
|
+
},
|
|
42
60
|
"artifacts": {
|
|
43
61
|
"type": "array",
|
|
44
62
|
"items": {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// affected-tests.mjs — FAST-MODE-ONLY scoping of the unit-test suite to the
|
|
2
|
+
// tests plausibly affected by the working-tree change.
|
|
3
|
+
//
|
|
4
|
+
// The full lane always runs the whole suite; this module exists so the inner
|
|
5
|
+
// loop (`verify --fast`) doesn't pay for every test on a one-file edit. Its
|
|
6
|
+
// honesty contract:
|
|
7
|
+
//
|
|
8
|
+
// - FALSE NEGATIVES ARE ACCEPTABLE HERE — AND ONLY HERE. A filtered fast
|
|
9
|
+
// run can miss a cross-feature regression; that is tolerable purely
|
|
10
|
+
// because the full, unfiltered suite runs at the checkpoint (the full
|
|
11
|
+
// lane), where done is actually decided. No other gate gets this license.
|
|
12
|
+
// - FAIL OPEN, NEVER FAIL SILENT. No git, a failed git command, an unmapped
|
|
13
|
+
// change, a broad-impact change — every uncertain case runs EVERYTHING,
|
|
14
|
+
// and the caller reports which case it was in the step's output and the
|
|
15
|
+
// receipt, so a filtered run can never be mistaken for the full suite.
|
|
16
|
+
// - The BLAST-RADIUS ESCAPE HATCH is mandatory: some paths fan out too
|
|
17
|
+
// widely to subset safely (build files rewire compilation, DI rewires
|
|
18
|
+
// object graphs, theme/tokens and shared components render into every
|
|
19
|
+
// screen, qa/ is the harness judging itself, and anything outside
|
|
20
|
+
// composeApp/src is by definition not a scoped source edit). Any one such
|
|
21
|
+
// change disables filtering for the run.
|
|
22
|
+
//
|
|
23
|
+
// Pure functions over path lists — git access is injected/separate so the
|
|
24
|
+
// engine suite can test every branch with no repo state.
|
|
25
|
+
|
|
26
|
+
import { execSync } from "node:child_process";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Lane OUTPUTS, excluded from the changed-set before any classification.
|
|
31
|
+
* The receipt (qa/evidence/) and hashed artifacts (qa-artifacts/) change on
|
|
32
|
+
* every lane run by design; counting them as "changes" would make the qa/**
|
|
33
|
+
* escape hatch self-triggering forever — run N's receipt forcing run N+1 to
|
|
34
|
+
* the full suite, permanently. They cannot affect a test outcome (the same
|
|
35
|
+
* principle as inputs-hash.mjs's EXCLUDED_PREFIXES: lane outputs are not
|
|
36
|
+
* verdict inputs).
|
|
37
|
+
*/
|
|
38
|
+
export const LANE_OUTPUT_PREFIXES = ["qa/evidence", "qa-artifacts"];
|
|
39
|
+
|
|
40
|
+
function isLaneOutput(p) {
|
|
41
|
+
return LANE_OUTPUT_PREFIXES.some((prefix) => p === prefix || p.startsWith(`${prefix}/`));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The mandatory blast-radius escape hatch: paths whose change fans out too
|
|
46
|
+
* widely to subset the suite safely. Returns the human-readable category when
|
|
47
|
+
* `p` is broad-impact, else null. Checked in order; the first match names the
|
|
48
|
+
* reason.
|
|
49
|
+
* @param {string} p POSIX relpath from the project root
|
|
50
|
+
* @returns {string|null}
|
|
51
|
+
*/
|
|
52
|
+
export function broadImpactReason(p) {
|
|
53
|
+
if (p.endsWith(".gradle.kts") || p === "gradle.properties" || p === "gradle/libs.versions.toml") {
|
|
54
|
+
return "build files rewire compilation";
|
|
55
|
+
}
|
|
56
|
+
if (/(^|\/)di\//.test(p)) return "DI rewires the object graph";
|
|
57
|
+
if (/(^|\/)theme\//.test(p)) return "theme/tokens render into every screen";
|
|
58
|
+
if (p.includes("presentation/components/")) return "shared components render into every screen";
|
|
59
|
+
if (p === "qa" || p.startsWith("qa/")) return "qa/ is the harness itself";
|
|
60
|
+
if (!p.startsWith("composeApp/src/")) return "outside composeApp/src";
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Derive the fast-mode unit-test filter from a list of changed paths.
|
|
66
|
+
*
|
|
67
|
+
* Mapping (deliberately simple and defensible): each changed `.kt` file under
|
|
68
|
+
* composeApp/src contributes its package's last segment — the parent
|
|
69
|
+
* directory name (`…/presentation/home/HomeViewModel.kt` → `home`, which the
|
|
70
|
+
* template's package-mirrors-path conformance makes a package segment) — and
|
|
71
|
+
* the union becomes Gradle `--tests "*<seg>*"` patterns matched against test
|
|
72
|
+
* class FQNs. Coarse on purpose: `*home*` runs every test whose FQN mentions
|
|
73
|
+
* the feature, which over-selects a little and under-maintains nothing.
|
|
74
|
+
*
|
|
75
|
+
* @param {string[]} changedPaths relpaths (either separator style) — tracked
|
|
76
|
+
* diffs plus untracked files, as from changedWorkingTreePaths()
|
|
77
|
+
* @returns {{mode: "filtered", patterns: string[], sourcePaths: string[]} |
|
|
78
|
+
* {mode: "all", reason: string, patterns: [], sourcePaths: string[]}}
|
|
79
|
+
* mode "all" ALWAYS carries the honest reason to report.
|
|
80
|
+
*/
|
|
81
|
+
export function deriveAffectedFilter(changedPaths) {
|
|
82
|
+
const paths = [...new Set((changedPaths ?? [])
|
|
83
|
+
.filter((p) => typeof p === "string" && p.length > 0)
|
|
84
|
+
.map((p) => p.split(path.sep).join("/")))]
|
|
85
|
+
.filter((p) => !isLaneOutput(p))
|
|
86
|
+
.sort();
|
|
87
|
+
|
|
88
|
+
if (paths.length === 0) {
|
|
89
|
+
return { mode: "all", reason: "no working-tree changes to scope by", patterns: [], sourcePaths: [] };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const p of paths) {
|
|
93
|
+
const broad = broadImpactReason(p);
|
|
94
|
+
if (broad) {
|
|
95
|
+
return { mode: "all", reason: `broad-impact change — ${broad} (${p})`, patterns: [], sourcePaths: paths };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Every remaining path is a scoped file under composeApp/src. Only .kt
|
|
100
|
+
// files map to test patterns; a change that maps to nothing (resources,
|
|
101
|
+
// manifests) falls open to the full suite below.
|
|
102
|
+
const ktPaths = paths.filter((p) => p.endsWith(".kt"));
|
|
103
|
+
const segments = new Set();
|
|
104
|
+
for (const p of ktPaths) {
|
|
105
|
+
const seg = path.posix.basename(path.posix.dirname(p));
|
|
106
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(seg)) segments.add(seg);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (segments.size === 0) {
|
|
110
|
+
return { mode: "all", reason: "changed files map to no test filter", patterns: [], sourcePaths: paths };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
mode: "filtered",
|
|
115
|
+
patterns: [...segments].sort().map((s) => `*${s}*`),
|
|
116
|
+
sourcePaths: ktPaths,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function defaultRunGit(args, root) {
|
|
121
|
+
try {
|
|
122
|
+
return execSync(`git ${args}`, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The working-tree change: tracked files differing from HEAD (staged or not)
|
|
130
|
+
* plus untracked-but-not-ignored files — the same "what will this commit
|
|
131
|
+
* touch" surface inputs-hash.mjs hashes.
|
|
132
|
+
*
|
|
133
|
+
* Returns null when git is unavailable or either command fails — the caller
|
|
134
|
+
* MUST treat null as "run everything" (fail open) and say so (never fail
|
|
135
|
+
* silent).
|
|
136
|
+
*
|
|
137
|
+
* @param {string} root project root
|
|
138
|
+
* @param {(args: string, root: string) => string|null} [runGit] injectable for tests
|
|
139
|
+
* @returns {string[]|null}
|
|
140
|
+
*/
|
|
141
|
+
export function changedWorkingTreePaths(root, runGit = defaultRunGit) {
|
|
142
|
+
const diff = runGit("diff --name-only HEAD", root);
|
|
143
|
+
const untracked = runGit("ls-files --others --exclude-standard", root);
|
|
144
|
+
if (diff === null || untracked === null) return null;
|
|
145
|
+
const lines = (out) => out.replace(/\n+$/, "").split("\n").filter(Boolean);
|
|
146
|
+
return [...new Set([...lines(diff), ...lines(untracked)])];
|
|
147
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// audit-cadence.mjs — the mechanical nudge that keeps cmp-audit from
|
|
2
|
+
// depending on someone remembering.
|
|
3
|
+
//
|
|
4
|
+
// The adversarial platform-semantics audit (the cmp-audit skill) found six
|
|
5
|
+
// latent defects on its first real outing — and it only ran because a human
|
|
6
|
+
// happened to ask. This module is the cheapest honest replacement for that
|
|
7
|
+
// memory: the release profile's receipt lists which androidMain subsystems
|
|
8
|
+
// changed since their last RECORDED audit, so the ship-time surface itself
|
|
9
|
+
// says "these platform seams moved and nobody has interrogated them since".
|
|
10
|
+
//
|
|
11
|
+
// It is a REPORT, never a gate. Audit debt is a judgment call (a one-line
|
|
12
|
+
// rename is not six latent defects), so this file computes facts and the
|
|
13
|
+
// human decides — a FAIL here would train people to game the ledger, which
|
|
14
|
+
// would destroy the only thing it has: honesty.
|
|
15
|
+
//
|
|
16
|
+
// The ledger (qa/audits.jsonl) is append-only, one JSON object per line:
|
|
17
|
+
// subsystem, the commit sha the audit ran against, an ISO timestamp, and who
|
|
18
|
+
// or what recorded it. Recording is a CLAIM — "this subsystem, as of this
|
|
19
|
+
// commit, was audited" — so recordAudit() derives the sha from HEAD itself
|
|
20
|
+
// and refuses to record when the subsystem's files differ from HEAD: a
|
|
21
|
+
// record claiming a commit the audited bytes did not match would be the
|
|
22
|
+
// exact dishonesty the whole harness exists to prevent.
|
|
23
|
+
//
|
|
24
|
+
// "Subsystem" is DERIVED, never configured: the immediate package directory
|
|
25
|
+
// under the app's androidMain Kotlin source root (the root is resolved from
|
|
26
|
+
// the android namespace in composeApp/build.gradle.kts). This template is
|
|
27
|
+
// stamped into apps whose package names it cannot know; deriving from the
|
|
28
|
+
// tree is the only definition that survives that. Kotlin files sitting
|
|
29
|
+
// directly at the package root belong to no package directory and are
|
|
30
|
+
// reported under the literal name "(root)" rather than invented into one.
|
|
31
|
+
|
|
32
|
+
import { execSync } from "node:child_process";
|
|
33
|
+
import fs from "node:fs";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
|
|
36
|
+
export const AUDITS_REL_PATH = "qa/audits.jsonl";
|
|
37
|
+
export const AUDIT_RECORD_SCHEMA = "cmp-audit-record/1";
|
|
38
|
+
|
|
39
|
+
/** The pseudo-subsystem for Kotlin files directly at the androidMain package root. */
|
|
40
|
+
export const ROOT_SUBSYSTEM = "(root)";
|
|
41
|
+
|
|
42
|
+
function tryGit(root, cmd) {
|
|
43
|
+
try {
|
|
44
|
+
return execSync(`git ${cmd}`, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function tryGitLines(root, cmd) {
|
|
51
|
+
try {
|
|
52
|
+
const out = execSync(`git ${cmd}`, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
53
|
+
return out.replace(/\n+$/, "").split("\n").filter(Boolean);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the androidMain Kotlin package root for this app, relative to the
|
|
61
|
+
* project root — derived from the android `namespace` (falling back to
|
|
62
|
+
* `applicationId`) in composeApp/build.gradle.kts, never hardcoded.
|
|
63
|
+
* @param {string} root project root (absolute)
|
|
64
|
+
* @returns {{ok: true, rel: string}|{ok: false, reason: string}}
|
|
65
|
+
*/
|
|
66
|
+
export function androidMainPackageRoot(root) {
|
|
67
|
+
let gradle;
|
|
68
|
+
try {
|
|
69
|
+
gradle = fs.readFileSync(path.join(root, "composeApp", "build.gradle.kts"), "utf8");
|
|
70
|
+
} catch {
|
|
71
|
+
return { ok: false, reason: "composeApp/build.gradle.kts not readable — cannot derive the app package" };
|
|
72
|
+
}
|
|
73
|
+
const pkg = gradle.match(/namespace\s*=\s*"([^"]+)"/)?.[1] ?? gradle.match(/applicationId\s*=\s*"([^"]+)"/)?.[1];
|
|
74
|
+
if (!pkg) {
|
|
75
|
+
return { ok: false, reason: "no android namespace/applicationId in composeApp/build.gradle.kts — cannot derive the app package" };
|
|
76
|
+
}
|
|
77
|
+
const rel = path.posix.join("composeApp/src/androidMain/kotlin", ...pkg.split("."));
|
|
78
|
+
if (!fs.existsSync(path.join(root, rel))) {
|
|
79
|
+
return { ok: false, reason: `androidMain has no Kotlin sources under the app package (${rel} absent)` };
|
|
80
|
+
}
|
|
81
|
+
return { ok: true, rel };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* List this app's androidMain subsystems: the immediate directories under
|
|
86
|
+
* the package root (sorted), plus ROOT_SUBSYSTEM when Kotlin files sit
|
|
87
|
+
* directly at the root.
|
|
88
|
+
* @param {string} root project root (absolute)
|
|
89
|
+
* @param {string} pkgRootRel from androidMainPackageRoot()
|
|
90
|
+
* @returns {string[]}
|
|
91
|
+
*/
|
|
92
|
+
export function listSubsystems(root, pkgRootRel) {
|
|
93
|
+
const abs = path.join(root, pkgRootRel);
|
|
94
|
+
let entries;
|
|
95
|
+
try {
|
|
96
|
+
entries = fs.readdirSync(abs, { withFileTypes: true });
|
|
97
|
+
} catch {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
const names = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
101
|
+
if (entries.some((e) => e.isFile() && e.name.endsWith(".kt"))) names.push(ROOT_SUBSYSTEM);
|
|
102
|
+
return names;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Read the audit ledger. Absent is the honest "no audit ever recorded"
|
|
107
|
+
* state; malformed lines are counted, never silently dropped — the report
|
|
108
|
+
* says how many records it could not read instead of under-counting audits.
|
|
109
|
+
* @param {string} root project root (absolute)
|
|
110
|
+
* @returns {{entries: Array<{subsystem: string, sha: string, at: string, by: string}>, malformed: number}}
|
|
111
|
+
*/
|
|
112
|
+
export function readAuditLedger(root) {
|
|
113
|
+
const p = path.join(root, AUDITS_REL_PATH);
|
|
114
|
+
if (!fs.existsSync(p)) return { entries: [], malformed: 0 };
|
|
115
|
+
let raw;
|
|
116
|
+
try {
|
|
117
|
+
raw = fs.readFileSync(p, "utf8");
|
|
118
|
+
} catch {
|
|
119
|
+
return { entries: [], malformed: 0 };
|
|
120
|
+
}
|
|
121
|
+
const entries = [];
|
|
122
|
+
let malformed = 0;
|
|
123
|
+
for (const line of raw.split("\n")) {
|
|
124
|
+
if (!line.trim()) continue;
|
|
125
|
+
try {
|
|
126
|
+
const e = JSON.parse(line);
|
|
127
|
+
if (e && typeof e === "object" && typeof e.subsystem === "string" && typeof e.sha === "string") entries.push(e);
|
|
128
|
+
else malformed += 1;
|
|
129
|
+
} catch {
|
|
130
|
+
malformed += 1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { entries, malformed };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Record an audit claim: append {subsystem, sha: HEAD, at, by} to the
|
|
138
|
+
* ledger. Honesty guards, each a refusal rather than a fabrication:
|
|
139
|
+
* - no git HEAD → refused (a claim about no commit is not a claim);
|
|
140
|
+
* - unknown subsystem → refused, naming the derived ones;
|
|
141
|
+
* - the subsystem's files differ from HEAD → refused (the record would
|
|
142
|
+
* claim HEAD while the audited bytes are something else — commit first).
|
|
143
|
+
* @param {string} root project root (absolute)
|
|
144
|
+
* @param {{subsystem: string, by?: string}} claim
|
|
145
|
+
* @returns {{ok: true, sha: string, entry: object}|{ok: false, reason: string}}
|
|
146
|
+
*/
|
|
147
|
+
export function recordAudit(root, { subsystem, by }) {
|
|
148
|
+
const sha = tryGit(root, "rev-parse HEAD");
|
|
149
|
+
if (!sha) {
|
|
150
|
+
return { ok: false, reason: "no git history — an audit record is a claim about a specific commit, and there is none to claim against. Commit first." };
|
|
151
|
+
}
|
|
152
|
+
const pkgRoot = androidMainPackageRoot(root);
|
|
153
|
+
if (!pkgRoot.ok) return { ok: false, reason: pkgRoot.reason };
|
|
154
|
+
const known = listSubsystems(root, pkgRoot.rel);
|
|
155
|
+
if (!known.includes(subsystem)) {
|
|
156
|
+
return { ok: false, reason: `unknown subsystem "${subsystem}" — derived subsystems under ${pkgRoot.rel}: ${known.join(", ") || "(none)"}` };
|
|
157
|
+
}
|
|
158
|
+
const scope = subsystem === ROOT_SUBSYSTEM ? pkgRoot.rel : path.posix.join(pkgRoot.rel, subsystem);
|
|
159
|
+
const dirty = tryGitLines(root, `status --porcelain -- "${scope}"`) ?? [];
|
|
160
|
+
// For "(root)" the porcelain scope is the whole package root; narrow to
|
|
161
|
+
// files directly at the root so a dirty subsystem dir doesn't block a
|
|
162
|
+
// root-level record it has nothing to do with.
|
|
163
|
+
const relevantDirty =
|
|
164
|
+
subsystem === ROOT_SUBSYSTEM
|
|
165
|
+
? dirty.filter((l) => {
|
|
166
|
+
const rel = l.slice(3).trim();
|
|
167
|
+
return path.posix.dirname(rel) === pkgRoot.rel;
|
|
168
|
+
})
|
|
169
|
+
: dirty;
|
|
170
|
+
if (relevantDirty.length > 0) {
|
|
171
|
+
return {
|
|
172
|
+
ok: false,
|
|
173
|
+
reason: `uncommitted changes under ${scope} — the record would claim commit ${sha.slice(0, 7)} but the audited files are not that commit. Commit (or revert) first, then record.`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const entry = {
|
|
177
|
+
schema: AUDIT_RECORD_SCHEMA,
|
|
178
|
+
subsystem,
|
|
179
|
+
sha,
|
|
180
|
+
at: new Date().toISOString(),
|
|
181
|
+
by: by || tryGit(root, "config user.name") || "unknown",
|
|
182
|
+
};
|
|
183
|
+
const p = path.join(root, AUDITS_REL_PATH);
|
|
184
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
185
|
+
fs.appendFileSync(p, `${JSON.stringify(entry)}\n`);
|
|
186
|
+
return { ok: true, sha, entry };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The report itself: for every derived subsystem, what the ledger claims
|
|
191
|
+
* and what git says moved since that claim.
|
|
192
|
+
*
|
|
193
|
+
* Statuses, each phrased so the receipt can print the line verbatim:
|
|
194
|
+
* never-audited no ledger entry — says exactly that, implies no staleness
|
|
195
|
+
* changed androidMain files under it changed between the audited
|
|
196
|
+
* sha and HEAD (committed changes only — sha vs HEAD is
|
|
197
|
+
* the honest comparison; the working tree is not history)
|
|
198
|
+
* unchanged no committed change since the audited sha
|
|
199
|
+
* unknown-commit the ledger names a sha this repo's history does not
|
|
200
|
+
* contain — drift cannot be measured, and the report says
|
|
201
|
+
* so instead of guessing
|
|
202
|
+
*
|
|
203
|
+
* @param {string} root project root (absolute)
|
|
204
|
+
* @returns {{ok: false, reason: string}|{ok: true, packageRoot: string,
|
|
205
|
+
* subsystems: Array<{name: string, status: string, audit: object|null, changedFiles: number}>,
|
|
206
|
+
* lines: string[], summary: string, malformed: number}}
|
|
207
|
+
*/
|
|
208
|
+
export function evaluateAuditCadence(root) {
|
|
209
|
+
if (!tryGit(root, "rev-parse HEAD")) {
|
|
210
|
+
// No git history: "changed since the last audit" has no meaning yet.
|
|
211
|
+
// Report NOTHING rather than guessing — an invented staleness signal
|
|
212
|
+
// would be worse than none.
|
|
213
|
+
return { ok: false, reason: "no git history — changed-since-audit cannot be measured" };
|
|
214
|
+
}
|
|
215
|
+
const pkgRoot = androidMainPackageRoot(root);
|
|
216
|
+
if (!pkgRoot.ok) return { ok: false, reason: pkgRoot.reason };
|
|
217
|
+
const subsystems = listSubsystems(root, pkgRoot.rel);
|
|
218
|
+
if (subsystems.length === 0) {
|
|
219
|
+
return { ok: false, reason: `no subsystems under ${pkgRoot.rel} — nothing to report` };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const { entries, malformed } = readAuditLedger(root);
|
|
223
|
+
// Last entry per subsystem wins: the ledger is append-only, so file order
|
|
224
|
+
// IS chronological order — trusted over the `at` timestamps, which are
|
|
225
|
+
// claims a machine's clock made, not facts git can vouch for.
|
|
226
|
+
const latest = new Map();
|
|
227
|
+
for (const e of entries) latest.set(e.subsystem, e);
|
|
228
|
+
|
|
229
|
+
const gitTop = tryGit(root, "rev-parse --show-toplevel");
|
|
230
|
+
// Realpath both sides before re-anchoring diff paths: git reports the
|
|
231
|
+
// toplevel with symlinks resolved (macOS: /var/… vs /private/var/…), and a
|
|
232
|
+
// mismatch here would silently mis-attribute every changed file.
|
|
233
|
+
let rootReal = root;
|
|
234
|
+
try {
|
|
235
|
+
rootReal = fs.realpathSync(root);
|
|
236
|
+
} catch {
|
|
237
|
+
rootReal = root;
|
|
238
|
+
}
|
|
239
|
+
const results = [];
|
|
240
|
+
const lines = [];
|
|
241
|
+
for (const name of subsystems) {
|
|
242
|
+
const audit = latest.get(name) ?? null;
|
|
243
|
+
if (!audit) {
|
|
244
|
+
results.push({ name, status: "never-audited", audit: null, changedFiles: 0 });
|
|
245
|
+
lines.push(`no audit recorded for ${name} — when it gets one (cmp-audit ${name}), record it: node qa/record-audit.mjs ${JSON.stringify(name)}`);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
// A ledger sha is a CLAIM read from a file — validate its shape before it
|
|
249
|
+
// touches a shell, and resolve it against history before trusting it.
|
|
250
|
+
const shaShapeOk = typeof audit.sha === "string" && /^[0-9a-f]{4,40}$/i.test(audit.sha);
|
|
251
|
+
const shaKnown = shaShapeOk && Boolean(tryGit(root, `rev-parse --verify --quiet "${audit.sha}^{commit}"`));
|
|
252
|
+
if (!shaKnown) {
|
|
253
|
+
results.push({ name, status: "unknown-commit", audit, changedFiles: 0 });
|
|
254
|
+
lines.push(`${name}: last audit (${fmtWhen(audit)}) was recorded against ${audit.sha.slice(0, 12)}, which is not in this repo's history — drift since it cannot be measured`);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const scope = name === ROOT_SUBSYSTEM ? pkgRoot.rel : path.posix.join(pkgRoot.rel, name);
|
|
258
|
+
const changedRaw = tryGitLines(root, `diff --name-only ${audit.sha} HEAD -- "${scope}"`) ?? [];
|
|
259
|
+
// Diff paths come back relative to the git toplevel, which may sit above
|
|
260
|
+
// the project root; re-anchor before subsystem attribution.
|
|
261
|
+
const changed = changedRaw
|
|
262
|
+
.map((rel) => (gitTop ? path.relative(rootReal, path.resolve(gitTop, rel)).split(path.sep).join("/") : rel))
|
|
263
|
+
.filter((rel) => (name === ROOT_SUBSYSTEM ? path.posix.dirname(rel) === pkgRoot.rel : true));
|
|
264
|
+
if (changed.length > 0) {
|
|
265
|
+
results.push({ name, status: "changed", audit, changedFiles: changed.length });
|
|
266
|
+
lines.push(
|
|
267
|
+
`${name}: ${changed.length} androidMain file(s) changed since its last recorded audit (${audit.sha.slice(0, 7)}, ${fmtWhen(audit)}) — audit it (cmp-audit ${name}), then record: node qa/record-audit.mjs ${JSON.stringify(name)}`,
|
|
268
|
+
);
|
|
269
|
+
} else {
|
|
270
|
+
results.push({ name, status: "unchanged", audit, changedFiles: 0 });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const changedCount = results.filter((r) => r.status === "changed").length;
|
|
275
|
+
const neverCount = results.filter((r) => r.status === "never-audited").length;
|
|
276
|
+
const unchangedCount = results.filter((r) => r.status === "unchanged").length;
|
|
277
|
+
if (unchangedCount > 0) {
|
|
278
|
+
lines.push(`${unchangedCount} subsystem(s) unchanged since their last recorded audit: ${results.filter((r) => r.status === "unchanged").map((r) => r.name).join(", ")}`);
|
|
279
|
+
}
|
|
280
|
+
if (malformed > 0) {
|
|
281
|
+
lines.push(`${malformed} ledger line(s) in ${AUDITS_REL_PATH} could not be parsed and are not counted`);
|
|
282
|
+
}
|
|
283
|
+
const summary = `${changedCount} changed since audit · ${neverCount} never audited · ${unchangedCount} unchanged`;
|
|
284
|
+
|
|
285
|
+
return { ok: true, packageRoot: pkgRoot.rel, subsystems: results, lines, summary, malformed };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function fmtWhen(audit) {
|
|
289
|
+
return typeof audit.at === "string" ? audit.at.slice(0, 10) : "undated";
|
|
290
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// determinism.mjs — the comparison half of the lane's determinism probe.
|
|
2
|
+
//
|
|
3
|
+
// ARCH-13 statically bans ambient time reads (Clock.System, LocalDate.now,
|
|
4
|
+
// TimeZone.currentSystemDefault) in APP code — but a library the app calls
|
|
5
|
+
// can still read the wall clock, and a golden test can still depend on the
|
|
6
|
+
// machine's timezone through a seam the static net cannot see. This project
|
|
7
|
+
// family has already been bitten: a golden tree green at 23:00 and red by
|
|
8
|
+
// morning, because a ViewModel was constructed without its injected clock.
|
|
9
|
+
//
|
|
10
|
+
// The probe (verify.mjs stepDeterminism) runs the JVM test tier TWICE under
|
|
11
|
+
// maximally-shifted timezones and fails iff the two runs' OUTCOMES differ.
|
|
12
|
+
// This module owns the two judgments that make that comparison honest:
|
|
13
|
+
//
|
|
14
|
+
// - WHAT COUNTS AS AN OUTCOME: a test's verdict (pass/fail/error/skip)
|
|
15
|
+
// and its failure output — never its duration. Durations are not parsed
|
|
16
|
+
// at all, so a timing wobble is structurally incapable of tripping the
|
|
17
|
+
// probe (the brief-level rule "duration is not a difference" is enforced
|
|
18
|
+
// by construction, not by filtering).
|
|
19
|
+
//
|
|
20
|
+
// - WHAT THE FAILURE MESSAGE MUST SAY: which test, which lane step owns
|
|
21
|
+
// it, and the observable difference between the two runs — never a bare
|
|
22
|
+
// "nondeterministic". A probe whose red is unactionable just teaches
|
|
23
|
+
// people to turn it off.
|
|
24
|
+
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The two probe timezones — chosen so the two legs NEVER share a calendar
|
|
30
|
+
* date, at any instant:
|
|
31
|
+
*
|
|
32
|
+
* Etc/GMT+12 = UTC-12 (POSIX sign convention: Etc/GMT+N means UTC-N)
|
|
33
|
+
* Etc/GMT-14 = UTC+14 (the highest real-world offset, Line Islands)
|
|
34
|
+
*
|
|
35
|
+
* The offsets are 26 hours apart — more than a full day — so the two legs'
|
|
36
|
+
* local dates differ at EVERY moment of every day, and any date-derived
|
|
37
|
+
* value (a "today" default, a day-boundary bucket, a formatted date in a
|
|
38
|
+
* golden tree) is guaranteed to differ between the legs. A UTC-vs-UTC+14
|
|
39
|
+
* pair would NOT have this property: those legs share a date for ten hours
|
|
40
|
+
* of every day, so the probe's power would depend on what time you ran it —
|
|
41
|
+
* the exact class of flakiness it exists to hunt.
|
|
42
|
+
*/
|
|
43
|
+
export const DETERMINISM_TIMEZONES = [
|
|
44
|
+
{ tz: "Etc/GMT+12", label: "UTC-12" },
|
|
45
|
+
{ tz: "Etc/GMT-14", label: "UTC+14" },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const XML_ENTITIES = { "<": "<", ">": ">", """: '"', "'": "'", "&": "&" };
|
|
49
|
+
|
|
50
|
+
function unescapeXml(s) {
|
|
51
|
+
return s
|
|
52
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
|
|
53
|
+
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec)))
|
|
54
|
+
.replace(/&(lt|gt|quot|apos|amp);/g, (m) => XML_ENTITIES[m]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function attr(attrs, name) {
|
|
58
|
+
const m = attrs.match(new RegExp(`${name}="([^"]*)"`));
|
|
59
|
+
return m ? unescapeXml(m[1]) : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parse one Gradle JUnit results directory into per-test outcomes.
|
|
64
|
+
* DELIBERATELY parses only verdict-bearing content: testcase identity,
|
|
65
|
+
* status, and failure/error text. `time="…"` attributes are never read, so
|
|
66
|
+
* two runs that differ only in duration produce identical outcome maps.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} dir a test-results directory (TEST-*.xml files, flat)
|
|
69
|
+
* @returns {Record<string, {status: "pass"|"fail"|"error"|"skip", messages: string[]}>}
|
|
70
|
+
* keyed by `classname.name`; empty object when the directory is absent
|
|
71
|
+
* (the caller decides what an empty leg means — this parser never guesses)
|
|
72
|
+
*/
|
|
73
|
+
export function parseJUnitOutcomes(dir) {
|
|
74
|
+
const outcomes = {};
|
|
75
|
+
if (!fs.existsSync(dir)) return outcomes;
|
|
76
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
77
|
+
if (!entry.startsWith("TEST-") || !entry.endsWith(".xml")) continue;
|
|
78
|
+
const xml = fs.readFileSync(path.join(dir, entry), "utf8");
|
|
79
|
+
const caseRe = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g;
|
|
80
|
+
for (const m of xml.matchAll(caseRe)) {
|
|
81
|
+
const attrs = m[1];
|
|
82
|
+
const body = m[2] ?? "";
|
|
83
|
+
const classname = attr(attrs, "classname") ?? "";
|
|
84
|
+
const name = attr(attrs, "name") ?? "";
|
|
85
|
+
if (!classname && !name) continue;
|
|
86
|
+
let status = "pass";
|
|
87
|
+
const messages = [];
|
|
88
|
+
const childRe = /<(failure|error)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1>)/g;
|
|
89
|
+
for (const c of body.matchAll(childRe)) {
|
|
90
|
+
status = c[1] === "error" ? "error" : "fail";
|
|
91
|
+
const message = attr(c[2], "message");
|
|
92
|
+
const text = c[3] ? unescapeXml(c[3]).trim() : "";
|
|
93
|
+
messages.push(message ?? text.split("\n")[0] ?? "");
|
|
94
|
+
}
|
|
95
|
+
if (status === "pass" && /<skipped\b/.test(body)) status = "skip";
|
|
96
|
+
outcomes[`${classname}.${name}`] = { status, messages };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return outcomes;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Which lane step owns a test class — so the probe's failure message names
|
|
104
|
+
* the step a reader would re-run, not just a class name. The patterns are
|
|
105
|
+
* the same filters the lane's own gradleTestStep calls use.
|
|
106
|
+
* @param {string} classname fully-qualified test class
|
|
107
|
+
* @returns {"goldenTrees"|"conformance"|"a11y"|"unitTests"}
|
|
108
|
+
*/
|
|
109
|
+
export function laneStepForTestClass(classname) {
|
|
110
|
+
if (/GoldenTreeTest$/.test(classname)) return "goldenTrees";
|
|
111
|
+
if (/ArchitectureConformanceTest$/.test(classname)) return "conformance";
|
|
112
|
+
if (/A11yConformanceTest$/.test(classname)) return "a11y";
|
|
113
|
+
return "unitTests";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function classnameOf(testId) {
|
|
117
|
+
// testId is `classname.name`; the class is everything before the last dot
|
|
118
|
+
// segment that starts the (possibly backticked, space-bearing) test name.
|
|
119
|
+
// Kotlin test names contain dots rarely but spaces often — the classname
|
|
120
|
+
// never contains a space, so split at the first segment containing one,
|
|
121
|
+
// falling back to the last dot.
|
|
122
|
+
const spaceIdx = testId.indexOf(" ");
|
|
123
|
+
const scope = spaceIdx === -1 ? testId : testId.slice(0, spaceIdx);
|
|
124
|
+
const lastDot = scope.lastIndexOf(".");
|
|
125
|
+
return lastDot === -1 ? testId : testId.slice(0, lastDot);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Compare two legs' outcomes. Returns one entry per observable difference,
|
|
130
|
+
* each carrying everything the failure message must name: the test, the
|
|
131
|
+
* owning lane step, and what differed between the legs.
|
|
132
|
+
*
|
|
133
|
+
* Kinds:
|
|
134
|
+
* verdict-flip different status (pass/fail/error/skip)
|
|
135
|
+
* only-in-one-leg the test executed in one leg only
|
|
136
|
+
* failure-text-changed failed in BOTH legs, but with different output —
|
|
137
|
+
* a date-dependent assertion message is still a
|
|
138
|
+
* timezone leak even when both legs are red
|
|
139
|
+
*
|
|
140
|
+
* @param {Record<string, {status: string, messages: string[]}>} a leg A outcomes
|
|
141
|
+
* @param {Record<string, {status: string, messages: string[]}>} b leg B outcomes
|
|
142
|
+
* @param {string} labelA human label for leg A (e.g. "TZ=Etc/GMT+12 (UTC-12)")
|
|
143
|
+
* @param {string} labelB human label for leg B
|
|
144
|
+
* @returns {Array<{test: string, step: string, kind: string, detail: string}>}
|
|
145
|
+
*/
|
|
146
|
+
export function compareOutcomes(a, b, labelA, labelB) {
|
|
147
|
+
const diffs = [];
|
|
148
|
+
const ids = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
|
|
149
|
+
for (const id of ids) {
|
|
150
|
+
const step = laneStepForTestClass(classnameOf(id));
|
|
151
|
+
const inA = a[id];
|
|
152
|
+
const inB = b[id];
|
|
153
|
+
if (!inA || !inB) {
|
|
154
|
+
const where = inA ? labelA : labelB;
|
|
155
|
+
const missing = inA ? labelB : labelA;
|
|
156
|
+
diffs.push({ test: id, step, kind: "only-in-one-leg", detail: `executed under ${where} but produced no result under ${missing}` });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (inA.status !== inB.status) {
|
|
160
|
+
const firstLine = (inA.status === "pass" ? inB : inA).messages[0]?.split("\n")[0] ?? "";
|
|
161
|
+
diffs.push({
|
|
162
|
+
test: id,
|
|
163
|
+
step,
|
|
164
|
+
kind: "verdict-flip",
|
|
165
|
+
detail: `${inA.status.toUpperCase()} under ${labelA}, ${inB.status.toUpperCase()} under ${labelB}${firstLine ? `: ${firstLine}` : ""}`,
|
|
166
|
+
});
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (inA.status !== "pass" && inA.messages.join("\n") !== inB.messages.join("\n")) {
|
|
170
|
+
diffs.push({
|
|
171
|
+
test: id,
|
|
172
|
+
step,
|
|
173
|
+
kind: "failure-text-changed",
|
|
174
|
+
detail: `failed under both, with different output — ${labelA}: "${inA.messages[0]?.split("\n")[0] ?? ""}" vs ${labelB}: "${inB.messages[0]?.split("\n")[0] ?? ""}"`,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return diffs;
|
|
179
|
+
}
|