taphound 0.2.0-dev.12 → 0.2.0-dev.13

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.
@@ -53,11 +53,13 @@ Use `node <skill>/scripts/ledger.mjs help` for the helper contract.
53
53
 
54
54
  ## Durable suite directory
55
55
 
56
- The caller chooses an explicit project-relative suite directory. A normal
57
- layout is:
56
+ A Suite always lives in `.taphound/suites/<suite-id>/` under the project
57
+ root; the helper derives that directory from the Suite ID and rejects any
58
+ other location (and a Brief anywhere but its Case directory) with
59
+ `CASE_SUITE_LOCATION`:
58
60
 
59
61
  ```text
60
- doc/development/<suite-id>/
62
+ .taphound/suites/<suite-id>/
61
63
  ├── cases.json # frozen user-approved Case catalog
62
64
  ├── case-ledger.json # mutable, revisioned orchestration state
63
65
  ├── STATUS.md # generated human view; never the Source of Truth
@@ -95,8 +97,7 @@ Create an input from `templates/suite-input.example.json`, then:
95
97
 
96
98
  ```bash
97
99
  node <skill>/scripts/ledger.mjs init \
98
- --input /tmp/taphound-suite-input.json \
99
- --out <project>/doc/development/<suite-id>
100
+ --input /tmp/taphound-suite-input.json
100
101
  ```
101
102
 
102
103
  Initialization canonicalizes `projectRoot`, validates dependency cycles and
@@ -215,7 +216,8 @@ Dispatch exactly one Case to `taphound-journey-brief-author` with:
215
216
  - exact catalog `sourceText` as `caseGoal`;
216
217
  - explicit context paths only;
217
218
  - output
218
- `<suite>/briefs/<case-id>/taphound-journey-brief.md`.
219
+ `.taphound/suites/<suite-id>/briefs/<case-id>/taphound-journey-brief.md`
220
+ (the only path `briefReady` accepts).
219
221
 
220
222
  After authoring, compute its exact hash and transition:
221
223
 
@@ -7,7 +7,11 @@ import {
7
7
  import {
8
8
  dirname, isAbsolute, join, relative, resolve, sep
9
9
  } from "node:path";
10
+ import { fileURLToPath } from "node:url";
10
11
 
12
+ // Suites are committed TapHound project material; the layout mirrors
13
+ // SUITES_DIR in src/domain/workspace.ts.
14
+ const SUITES_DIR = ".taphound/suites";
11
15
  const CATALOG = "cases.json";
12
16
  const LEDGER = "case-ledger.json";
13
17
  const STATUS = "STATUS.md";
@@ -82,23 +86,36 @@ function canonicalHash(value) {
82
86
  return digest(JSON.stringify(canonical(value)));
83
87
  }
84
88
 
85
- function exactKeys(value, required, optional = []) {
89
+ // `contract` names the object and, for command inputs, the shipped template
90
+ // so a rejected field points at the shape the command accepts.
91
+ function exactKeys(value, required, optional = [], contract = undefined) {
92
+ const where = contract === undefined ? "" : ` in ${contract.name}`;
93
+ const hint = () => {
94
+ const fields = [...required, ...optional.map((key) => `${key}?`)];
95
+ return `; allowed fields: ${fields.join(", ")}${
96
+ contract?.template === undefined
97
+ ? ""
98
+ : `; see ${contract.template}`
99
+ }`;
100
+ };
86
101
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
87
- fail("CASE_SUITE_INVALID", "Expected a JSON object");
102
+ fail("CASE_SUITE_INVALID", `Expected a JSON object${where}`);
88
103
  }
89
104
  const allowed = new Set([...required, ...optional]);
90
105
  for (const key of Object.keys(value)) {
91
106
  if (!allowed.has(key)) {
92
- fail("CASE_SUITE_INVALID", `Unknown field "${key}"`);
107
+ fail("CASE_SUITE_INVALID", `Unknown field "${key}"${where}${hint()}`);
93
108
  }
94
109
  }
95
110
  for (const key of required) {
96
111
  if (!(key in value)) {
97
- fail("CASE_SUITE_INVALID", `Missing required field "${key}"`);
112
+ fail("CASE_SUITE_INVALID", `Missing required field "${key}"${where}${hint()}`);
98
113
  }
99
114
  }
100
115
  }
101
116
 
117
+ const TEMPLATES = join(dirname(fileURLToPath(import.meta.url)), "..", "templates");
118
+
102
119
  function nonempty(value, label, max = 1000) {
103
120
  if (typeof value !== "string") {
104
121
  fail("CASE_SUITE_INVALID", `${label} must be a string`);
@@ -125,6 +142,14 @@ function assertSha(value, label) {
125
142
  }
126
143
  }
127
144
 
145
+ function suiteDirectory(projectRoot, suiteId) {
146
+ return join(projectRoot, ...SUITES_DIR.split("/"), suiteId);
147
+ }
148
+
149
+ function suiteBriefPath(suiteId, caseId) {
150
+ return `${SUITES_DIR}/${suiteId}/briefs/${caseId}/taphound-journey-brief.md`;
151
+ }
152
+
128
153
  function inside(root, path) {
129
154
  const rel = relative(root, path);
130
155
  return rel === "" || (
@@ -459,6 +484,13 @@ async function loadSuite(suitePath) {
459
484
  if (projectRoot !== catalog.projectRoot || !inside(projectRoot, root)) {
460
485
  fail("CASE_SUITE_INVALID", "Suite must stay beneath canonical projectRoot");
461
486
  }
487
+ if (root !== suiteDirectory(projectRoot, catalog.suiteId)) {
488
+ fail(
489
+ "CASE_SUITE_LOCATION",
490
+ `Suite ${catalog.suiteId} must live in ${SUITES_DIR}/${catalog.suiteId}, `
491
+ + `found ${relative(projectRoot, root)}; initialize it there`
492
+ );
493
+ }
462
494
  const ledger = parseLedger(await json(ledgerPath, LEDGER), catalog);
463
495
  const catalogSha256 = digest(catalogBytes);
464
496
  if (ledger.catalog.sha256 !== catalogSha256) {
@@ -599,7 +631,8 @@ function parseSuiteInput(value) {
599
631
  exactKeys(
600
632
  value,
601
633
  ["version", "suiteId", "title", "projectRoot", "cases"],
602
- ["deviceSerial", "contextPath"]
634
+ ["deviceSerial", "contextPath"],
635
+ { name: "init input", template: join(TEMPLATES, "suite-input.example.json") }
603
636
  );
604
637
  if (value.version !== 1 || !suiteIdPattern.test(value.suiteId ?? "")
605
638
  || !isAbsolute(value.projectRoot ?? "")
@@ -629,9 +662,13 @@ function parseSuiteInput(value) {
629
662
  async function init(inputPath, outputPath) {
630
663
  const input = parseSuiteInput(await json(resolve(inputPath), "Suite input"));
631
664
  const projectRoot = await realpath(input.projectRoot);
632
- const output = resolve(outputPath);
633
- if (!inside(projectRoot, output) || output === projectRoot) {
634
- fail("CASE_SUITE_INVALID", "Suite output must be a new directory inside projectRoot");
665
+ const output = suiteDirectory(projectRoot, input.suiteId);
666
+ if (outputPath !== undefined && resolve(outputPath) !== output) {
667
+ fail(
668
+ "CASE_SUITE_LOCATION",
669
+ `Suite directory must be ${SUITES_DIR}/${input.suiteId} under projectRoot `
670
+ + `(${output}); omit --out to use it: ${outputPath}`
671
+ );
635
672
  }
636
673
  await access(output).then(
637
674
  () => fail("CASE_SUITE_EXISTS", "Suite output already exists"),
@@ -708,7 +745,8 @@ function parseTransition(value) {
708
745
  exactKeys(
709
746
  value,
710
747
  ["version", "expectedRevision", "caseId", "from", "to", "reason"],
711
- ["brief", "generation", "failure", "nextAction", "completion"]
748
+ ["brief", "generation", "failure", "nextAction", "completion"],
749
+ { name: "transition input", template: join(TEMPLATES, "transition.example.json") }
712
750
  );
713
751
  if (value.version !== 1 || !caseIdPattern.test(value.caseId ?? "")
714
752
  || !statuses.has(value.from) || !statuses.has(value.to)) {
@@ -861,10 +899,12 @@ async function transition(suitePath, inputPath) {
861
899
  const briefFile = await regularProjectFile(
862
900
  suite.projectRoot, request.brief, `${entry.id}.brief`
863
901
  );
864
- if (!briefFile.relativePath.endsWith(
865
- `/briefs/${entry.id}/taphound-journey-brief.md`
866
- )) {
867
- fail("CASE_SUITE_INVALID", "Brief path must be Case-specific");
902
+ const expectedBrief = suiteBriefPath(suite.catalog.suiteId, entry.id);
903
+ if (briefFile.relativePath !== expectedBrief) {
904
+ fail(
905
+ "CASE_SUITE_LOCATION",
906
+ `Brief for ${entry.id} must be ${expectedBrief}: ${briefFile.relativePath}`
907
+ );
868
908
  }
869
909
  }
870
910
  if (request.to === "generating") {
@@ -958,10 +998,18 @@ async function transition(suitePath, inputPath) {
958
998
  }
959
999
 
960
1000
  function parseFlowRecord(value) {
961
- exactKeys(value, [
962
- "version", "expectedRevision", "name", "path", "sha256",
963
- "exitActivity", "journey", "resolutionManifest", "report"
964
- ]);
1001
+ exactKeys(
1002
+ value,
1003
+ [
1004
+ "version", "expectedRevision", "name", "path", "sha256",
1005
+ "exitActivity", "journey", "resolutionManifest", "report"
1006
+ ],
1007
+ [],
1008
+ {
1009
+ name: "record-flow input",
1010
+ template: join(TEMPLATES, "base-flow-record.example.json")
1011
+ }
1012
+ );
965
1013
  if (value.version !== 1 || !flowNamePattern.test(value.name ?? "")) {
966
1014
  fail("CASE_SUITE_INVALID", "Invalid Base Flow record");
967
1015
  }
@@ -1154,12 +1202,26 @@ function help() {
1154
1202
  "TapHound Case Suite Ledger",
1155
1203
  "",
1156
1204
  "Commands:",
1157
- " init --input <json> --out <suite-directory>",
1205
+ " init --input <json> [--out <project>/.taphound/suites/<suite-id>]",
1158
1206
  " validate --suite <suite-directory>",
1159
1207
  " status --suite <suite-directory> [--case <case-id>]",
1160
1208
  " transition --suite <suite-directory> --input <json>",
1161
1209
  " record-flow --suite <suite-directory> --input <json>",
1162
- " recover-lock --suite <suite-directory>"
1210
+ " recover-lock --suite <suite-directory>",
1211
+ "",
1212
+ `Input templates live in ${TEMPLATES}: suite-input.example.json (init),`,
1213
+ "transition.example.json (transition), base-flow-record.example.json",
1214
+ "(record-flow). Unknown or missing fields are rejected with the allowed list.",
1215
+ "",
1216
+ `Layout: a Suite lives in <projectRoot>/${SUITES_DIR}/<suite-id>/ and each`,
1217
+ "Case Brief in its briefs/<case-id>/taphound-journey-brief.md. Other",
1218
+ "locations fail with CASE_SUITE_LOCATION.",
1219
+ "",
1220
+ "Ledger revision: every successful transition or record-flow increments",
1221
+ "ledger.revision by exactly 1. Pass the current value as expectedRevision",
1222
+ "(read it from `status`); a stale value fails without writing. It is",
1223
+ "unrelated to TapHound generation session revisions (observe +1, step +3",
1224
+ "with its post-action observation), which the ledger never stores."
1163
1225
  ].join("\n");
1164
1226
  }
1165
1227
 
@@ -1172,6 +1234,9 @@ async function main() {
1172
1234
  let output;
1173
1235
  if (command === "init") {
1174
1236
  const input = options(argv, ["--input", "--out"]);
1237
+ if (input.input === undefined) {
1238
+ fail("CASE_SUITE_USAGE", "init requires --input");
1239
+ }
1175
1240
  output = await init(input.input, input.out);
1176
1241
  } else if (command === "validate") {
1177
1242
  const input = options(argv, ["--suite"]);
@@ -104,7 +104,7 @@ Orchestrator (lean context)
104
104
  | contextOnly | no | `false` | Run only the Context lifecycle (ensure/refresh/regenerate); skip Brief authoring |
105
105
  | observeSnapshot | no | — | Pre-captured `taphound observe --json` result |
106
106
  | device | no | doctor auto-selects | Device serial |
107
- | output | no | `.taphound/journeys/taphound-journey-brief.md` | Brief output path (relative to project) |
107
+ | output | no | `.taphound/briefs/<caseId>/taphound-journey-brief.md` | Brief output path (relative to project); must be under `.taphound/briefs/` or `.taphound/suites/<suite-id>/briefs/` |
108
108
 
109
109
  **Hard rule**: NEVER search for or assume files named `plan.md`,
110
110
  `requirement.md`, or any convention. Read ONLY files the caller explicitly
@@ -114,13 +114,18 @@ passes via `contextPaths`. If no `contextPaths` are supplied, work from
114
114
  ## Output
115
115
 
116
116
  For a Brief run, the Skill writes a `taphound-journey-brief.md` at the
117
- `output` path and returns a structured JSON summary:
117
+ `output` path and returns a structured JSON summary. Briefs are committed
118
+ TapHound material: write them only under `.taphound/briefs/<caseId>/`
119
+ (standalone) or `.taphound/suites/<suite-id>/briefs/<caseId>/` (Case Suite),
120
+ never elsewhere in the project (for example `doc/` or the project root).
121
+ `generation start --brief` rejects any other location with `BRIEF_INVALID`.
122
+ Without a `caseId`, use a short kebab-case name of the Goal as the directory.
118
123
 
119
124
  ```json
120
125
  {
121
126
  "status": "authored",
122
127
  "caseId": "CASE-002",
123
- "path": ".taphound/journeys/taphound-journey-brief.md",
128
+ "path": ".taphound/briefs/CASE-002/taphound-journey-brief.md",
124
129
  "sha256": "<exact-byte-hash>",
125
130
  "edgesVerified": 2,
126
131
  "edgesNeedsObservation": 1
@@ -43,7 +43,9 @@ The orchestrator dispatches your task with:
43
43
  - **observeSnapshot** (optional): Pre-captured `taphound observe --json`
44
44
  result. Use it directly; do NOT call `taphound observe` when provided.
45
45
  - **output** (optional): Brief output path, defaults to
46
- `.taphound/journeys/taphound-journey-brief.md` (relative to project).
46
+ `.taphound/briefs/<caseId>/taphound-journey-brief.md` (relative to
47
+ project). It must stay under `.taphound/briefs/` or
48
+ `.taphound/suites/<suite-id>/briefs/`; refuse any other location.
47
49
 
48
50
  ## Output
49
51
 
@@ -31,7 +31,7 @@ generate`/`refresh`/`rehash`/`validate`/`status`/`list`、`taphound observe`。
31
31
  | contextPaths | 否 | 显式文档路径数组,只读这些 |
32
32
  | contextOnly | 否 | 为 `true` 时只运行 Context 生命周期(Phase 0),返回 Context 摘要 JSON,不写 Brief |
33
33
  | observeSnapshot | 否 | 预采集的 `taphound observe --json` 结果;提供则直接用,不再调 observe |
34
- | output | 否 | Brief 输出路径,默认 `.taphound/journeys/taphound-journey-brief.md` |
34
+ | output | 否 | Brief 输出路径,默认 `.taphound/briefs/<caseId>/taphound-journey-brief.md`;只能位于 `.taphound/briefs/` 或 `.taphound/suites/<suite-id>/briefs/` 下,拒绝其他位置 |
35
35
 
36
36
  ## 输出
37
37
 
@@ -86,7 +86,8 @@ risk confirmation, recovery, or final Replay rules.
86
86
 
87
87
  `journeyBrief` is the Skill-level handoff for one Journey Case. When present,
88
88
  it carries `{path, sha256}` pointing to a project-relative
89
- `taphound-journey-brief.md`. Bind the same path into Core with
89
+ `taphound-journey-brief.md` under `.taphound/briefs/` or
90
+ `.taphound/suites/<suite-id>/briefs/`. Bind the same path into Core with
90
91
  `generation start --brief <path>`: Core reads the file itself, computes the
91
92
  SHA-256 (never trust an agent-supplied hash), and persists `sourceBrief` in
92
93
  the session and the exported meta sidecar, so `journey check` reports
@@ -264,12 +265,18 @@ them with `MANUAL_STEP_REQUIRED`.
264
265
  node <skill>/scripts/envelope.mjs bind \
265
266
  --input <draft-envelope-path> \
266
267
  --from <previous-observe-or-step-output-path> \
267
- --out <envelope-path>
268
+ --out <envelope-path> \
269
+ --project <project>
268
270
  ```
269
271
  The draft envelope needs only `version` and `proposal` (binding may be
270
272
  omitted or stale); `bind` fills `proposal.binding` from the preceding
271
273
  observe output, step output, or raw binding, adds `snapshotRef` when
272
- absent, and validates the result offline. The helper contract:
274
+ absent, and validates the result offline. It also compares
275
+ `proposal.activity.before` with the bound snapshot's Activity (read
276
+ from `snapshotRef` under `--project`) and fails with
277
+ `ENVELOPE_ACTIVITY_MISMATCH`, naming the snapshot Activity, before any
278
+ device work. `activityCheck: "unverified"` in its output means no
279
+ snapshot was readable; Core still enforces the check on `step`. The helper contract:
273
280
  `node <skill>/scripts/envelope.mjs help`. The resulting shape:
274
281
  ```json
275
282
  {
@@ -375,10 +382,12 @@ the index with `taphound knowledge rehash --project <project> --json` and
375
382
  taphound journey check \
376
383
  --project <project> \
377
384
  --context .taphound/context/project-context.json \
385
+ --journey .taphound/journeys/<name>.json \
378
386
  --json
379
387
  ```
380
- The newly published Journey must classify as `fresh`. `journey check`
381
- audits every committed Journey under `.taphound/journeys` by comparing
388
+ The newly published Journey must classify as `fresh`. `--journey`
389
+ limits the audit to that Journey; without it `journey check` audits every
390
+ committed Journey under `.taphound/journeys` by comparing
382
391
  its sidecar bindings (project, config, and `contextSelection` module
383
392
  hashes, plus the Brief content hash when `sourceBrief` is bound) against
384
393
  the live project. `--strict` exits `1` when any Journey
@@ -11,7 +11,9 @@ Use this prompt only when the caller supplied `journeyBrief`.
11
11
  ## Validation
12
12
 
13
13
  1. Resolve the path beneath the project root without following a symlink
14
- outside it. Its basename must be `taphound-journey-brief.md`.
14
+ outside it. Its basename must be `taphound-journey-brief.md`, and it must
15
+ live under `.taphound/briefs/` or `.taphound/suites/<suite-id>/briefs/`
16
+ (Core rejects other locations with `BRIEF_INVALID`).
15
17
  2. Compute SHA-256 over the exact file bytes and compare it with the binding.
16
18
  3. Require YAML frontmatter values:
17
19
  - `schemaVersion: 2`
@@ -102,6 +102,15 @@ the observe result).
102
102
  varying middle can identify the event, use `match: "regex"` and anchor
103
103
  the stable words instead of the varying text.
104
104
  - `activity`: a specific Activity should be foregrounded.
105
+ - **Screens that load asynchronously**: idle detection only proves the
106
+ layout tree stopped changing. A full-screen spinner or skeleton is a
107
+ static tree, so the post-action snapshot can be the loading state. When
108
+ an action opens a screen that loads, prefer an `element` expect on a
109
+ control of the loaded screen: Core polls until it appears and returns
110
+ that settled layout as the next snapshot. When the step needs a `logcat`
111
+ expect instead, follow it with a `wait` step whose `element` expect names
112
+ the loaded screen's control, rather than proposing the next action
113
+ against a loading snapshot.
105
114
  - Do not add expectations you cannot verify from source code or Context.
106
115
  - Do not invent log patterns that don't exist in the source.
107
116
 
@@ -9,7 +9,7 @@
9
9
  // schema changes, update this script and its test together.
10
10
  import process from "node:process";
11
11
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
12
- import { dirname, resolve } from "node:path";
12
+ import { dirname, join, resolve } from "node:path";
13
13
 
14
14
  const sha256Pattern = /^[a-f\d]{64}$/;
15
15
  const generationIdPattern = /^[A-Za-z\d](?:[A-Za-z\d._-]*[A-Za-z\d])?$/;
@@ -456,7 +456,36 @@ async function readJsonFile(path, label) {
456
456
  }
457
457
  }
458
458
 
459
- async function bind(inputPath, fromPath, outPath) {
459
+ // The Activity the bound snapshot was captured on, taken from the bind source
460
+ // (full observe/step output), the inline envelope snapshot, or the
461
+ // Store-owned snapshot file under the project root. Undefined when none of
462
+ // them is readable; Core still enforces the check on `generation step`.
463
+ async function boundSnapshotActivity(source, envelope, snapshotRef, projectRoot) {
464
+ const inline = [
465
+ ["bind source snapshot", source.snapshot],
466
+ ["bind source nextSnapshot", source.nextSnapshot],
467
+ ["envelope.snapshot", envelope.snapshot]
468
+ ];
469
+ for (const [from, snapshot] of inline) {
470
+ if (isPlainObject(snapshot) && typeof snapshot.activity === "string") {
471
+ return { activity: snapshot.activity, from };
472
+ }
473
+ }
474
+ const ref = envelope.snapshotRef ?? snapshotRef;
475
+ if (typeof ref !== "string" || !snapshotRefPattern.test(ref)) {
476
+ return undefined;
477
+ }
478
+ try {
479
+ const snapshot = JSON.parse(await readFile(join(projectRoot, ref), "utf8"));
480
+ return isPlainObject(snapshot) && typeof snapshot.activity === "string"
481
+ ? { activity: snapshot.activity, from: ref }
482
+ : undefined;
483
+ } catch {
484
+ return undefined;
485
+ }
486
+ }
487
+
488
+ async function bind(inputPath, fromPath, outPath, projectRoot) {
460
489
  const envelope = await readJsonFile(inputPath, "envelope input");
461
490
  exactKeys(
462
491
  "envelope input",
@@ -494,6 +523,23 @@ async function bind(inputPath, fromPath, outPath) {
494
523
  : { snapshot: envelope.snapshot })
495
524
  };
496
525
  validateEnvelope(bound);
526
+ const snapshotActivity = await boundSnapshotActivity(
527
+ source,
528
+ bound,
529
+ snapshotRef,
530
+ projectRoot
531
+ );
532
+ const before = proposal.activity?.before;
533
+ if (snapshotActivity !== undefined && before !== snapshotActivity.activity) {
534
+ fail(
535
+ "ENVELOPE_ACTIVITY_MISMATCH",
536
+ `proposal.activity.before ${String(before)} does not match the bound snapshot Activity ${
537
+ snapshotActivity.activity
538
+ } (from ${snapshotActivity.from}); set activity.before to ${
539
+ snapshotActivity.activity
540
+ }`
541
+ );
542
+ }
497
543
  if (outPath !== undefined) {
498
544
  const absolute = resolve(outPath);
499
545
  await mkdir(dirname(absolute), { recursive: true });
@@ -505,7 +551,8 @@ async function bind(inputPath, fromPath, outPath) {
505
551
  exitCode: 0,
506
552
  path: outPath,
507
553
  binding,
508
- ...(snapshotRef === undefined ? {} : { snapshotRef })
554
+ ...(snapshotRef === undefined ? {} : { snapshotRef }),
555
+ activityCheck: snapshotActivity === undefined ? "unverified" : "matched"
509
556
  })}\n`);
510
557
  return;
511
558
  }
@@ -532,16 +579,22 @@ function help() {
532
579
  "Commands:",
533
580
  " validate --input <envelope.json>",
534
581
  " Validate one generation step envelope offline (no device, no session).",
535
- " bind --input <envelope.json> --from <observe-or-step-output.json> [--out <path>]",
582
+ " bind --input <envelope.json> --from <observe-or-step-output.json> [--out <path>] [--project <root>]",
536
583
  " Fill proposal.binding (and snapshotRef when absent) from the preceding",
537
584
  " observe output, step output, or raw binding, then validate.",
585
+ " It also checks proposal.activity.before against the bound snapshot's",
586
+ " Activity (from the full output, the inline snapshot, or the snapshot",
587
+ " file under --project, default the current directory) and fails with",
588
+ " ENVELOPE_ACTIVITY_MISMATCH naming the snapshot Activity. With --out,",
589
+ " activityCheck reports \"matched\" or \"unverified\" (no readable snapshot).",
538
590
  "",
539
591
  "bind writes the bound envelope to --out; without --out the bound envelope",
540
592
  "itself is the single stdout JSON value.",
541
593
  "",
542
- "Revision rule: each accepted step advances the session revision by 2 and",
543
- "each observe by 1, so one observe-then-step cycle needs baseRevision + 3.",
544
- "Prefer binding from the latest output instead of computing revisions."
594
+ "Revision rule: observe advances the session revision by 1. A succeeded",
595
+ "step advances it by 2 for the step plus 1 for the post-action observation",
596
+ "behind nextBinding, so consecutive steps are 3 apart. Never compute",
597
+ "revisions: bind from the latest observe or step output."
545
598
  ].join("\n");
546
599
  }
547
600
 
@@ -558,11 +611,11 @@ async function main() {
558
611
  return;
559
612
  }
560
613
  if (command === "bind") {
561
- const input = options(argv, ["--input", "--from", "--out"]);
614
+ const input = options(argv, ["--input", "--from", "--out", "--project"]);
562
615
  if (input.input === undefined || input.from === undefined) {
563
616
  fail("ENVELOPE_USAGE", "bind requires --input and --from");
564
617
  }
565
- await bind(input.input, input.from, input.out);
618
+ await bind(input.input, input.from, input.out, resolve(input.project ?? "."));
566
619
  return;
567
620
  }
568
621
  fail("ENVELOPE_USAGE", `Unknown command ${command}`);
@@ -51,7 +51,11 @@ function validateBinding(session, snapshot, proposal) {
51
51
  throw new GenerationOperationError("PACKAGE_ESCAPE", "Foreground package escaped the generation target");
52
52
  }
53
53
  if (snapshot.activity !== proposal.activity.before) {
54
- throw new GenerationOperationError("SNAPSHOT_STALE", "Proposal before Activity does not match the current snapshot");
54
+ throw new GenerationOperationError("SNAPSHOT_STALE", `Proposal before Activity ${proposal.activity.before} does not match the current snapshot Activity ${snapshot.activity}`, {
55
+ field: "activity.before",
56
+ expected: snapshot.activity,
57
+ actual: proposal.activity.before
58
+ });
55
59
  }
56
60
  }
57
61
  function validateAction(snapshot, proposal) {
@@ -30,7 +30,7 @@ export interface JourneyCheckResult {
30
30
  entries: readonly JourneyCheckEntry[];
31
31
  summary: JourneyCheckSummary;
32
32
  }
33
- export type JourneyCheckErrorCode = "CONFIG_INVALID";
33
+ export type JourneyCheckErrorCode = "CONFIG_INVALID" | "JOURNEY_NOT_FOUND";
34
34
  export declare class JourneyCheckError extends Error {
35
35
  readonly code: JourneyCheckErrorCode;
36
36
  readonly name = "JourneyCheckError";
@@ -41,6 +41,12 @@ export interface JourneyCheckInput {
41
41
  config: TapHoundConfig;
42
42
  project: ProjectDescription;
43
43
  bundle: ProjectContext;
44
+ /**
45
+ * Restrict the check to these Journeys. Each selector is a project-relative
46
+ * Journey path (`.taphound/journeys/<name>.json`) or a Journey name
47
+ * (`<name>`). A selector that matches no committed Journey fails.
48
+ */
49
+ journeys?: readonly string[] | undefined;
44
50
  }
45
51
  export interface JourneyCheckDependencies {
46
52
  store: Pick<JourneyCompositionStore, "read" | "listJourneyPaths" | "readJourneyMeta">;
@@ -21,6 +21,21 @@ function journeyName(journeyPath) {
21
21
  : journeyPath;
22
22
  return withoutPrefix.slice(0, -".json".length);
23
23
  }
24
+ function selectJourneys(paths, selectors) {
25
+ if (selectors === undefined || selectors.length === 0) {
26
+ return paths;
27
+ }
28
+ const normalize = (value) => value
29
+ .replaceAll("\\", "/")
30
+ .replace(/^\.\//, "");
31
+ const wanted = new Set(selectors.map(normalize));
32
+ const selected = paths.filter((path) => (wanted.has(path) || wanted.has(journeyName(path))));
33
+ const missing = [...wanted].filter((selector) => !selected.some((path) => (path === selector || journeyName(path) === selector)));
34
+ if (missing.length > 0) {
35
+ throw new JourneyCheckError("JOURNEY_NOT_FOUND", `No committed Journey matches ${missing.join(", ")} under ${JOURNEYS_DIR}`);
36
+ }
37
+ return selected;
38
+ }
24
39
  function summarize(entries) {
25
40
  const summary = {
26
41
  total: entries.length,
@@ -54,7 +69,7 @@ export class JourneyCheckService {
54
69
  this.dependencies = dependencies;
55
70
  }
56
71
  check = async (input) => {
57
- const paths = await this.dependencies.store.listJourneyPaths(input.projectRoot);
72
+ const paths = selectJourneys(await this.dependencies.store.listJourneyPaths(input.projectRoot), input.journeys);
58
73
  const projectHash = hashGenerationBinding(input.project);
59
74
  const configHash = hashGenerationBinding(input.config);
60
75
  const modulesById = new Map(input.bundle.modules.map((module) => [module.id, module]));
@@ -9,8 +9,12 @@ export function logcatEvidenceWarning(report) {
9
9
  if (entries.length === 0)
10
10
  return undefined;
11
11
  const lines = entries.reduce((total, entry) => total + entry.droppedLines, 0);
12
- return `Warning: Logcat evidence is incomplete (${String(lines)} line(s) dropped); `
13
- + "Logcat-based expectations fail closed on drops in their window";
12
+ if (entries.every((entry) => entry.expectationImpact === "none")) {
13
+ return `Warning (non-fatal): Logcat capture is partial (${String(lines)} line(s) dropped); `
14
+ + "no Logcat expectation failed, and those expectations fail closed on drops in their window";
15
+ }
16
+ return `Warning: Logcat evidence is incomplete (${String(lines)} line(s) dropped) `
17
+ + "and a Logcat expectation failed; the drops may be the cause";
14
18
  }
15
19
  function renderSummary(report) {
16
20
  const lines = [
@@ -658,6 +658,13 @@ export class VerifyRuntime {
658
658
  ].includes(failure.code)
659
659
  ? "error"
660
660
  : "failed";
661
+ // Logcat expectations fail closed on a relevant drop, so drops can only
662
+ // have changed the outcome of a Logcat expectation that failed.
663
+ const expectationImpact = steps.some((step) => (step.expectation?.status === "failed"
664
+ && (step.expectation.type === "logcat"
665
+ || step.expectation.type === "logcatEvent")))
666
+ ? "possible"
667
+ : "none";
661
668
  const report = {
662
669
  schemaVersion: 4,
663
670
  ...(runtimes.some((runtime) => runtime.logcatStarted
@@ -675,7 +682,8 @@ export class VerifyRuntime {
675
682
  ...(metadata.lastDroppedAtMs === undefined ? {} : {
676
683
  lastDroppedAtMs: metadata.lastDroppedAtMs
677
684
  }),
678
- status: "incomplete"
685
+ status: "incomplete",
686
+ expectationImpact
679
687
  }];
680
688
  })
681
689
  }
@@ -115,7 +115,8 @@ export class IdleWaiter {
115
115
  durationMs: this.clock.now() - startedAt
116
116
  };
117
117
  }
118
- const elapsedBeforePoll = this.clock.now() - startedAt;
118
+ const pollStartedAt = this.clock.now();
119
+ const elapsedBeforePoll = pollStartedAt - startedAt;
119
120
  polls += 1;
120
121
  let observation;
121
122
  try {
@@ -246,8 +247,15 @@ export class IdleWaiter {
246
247
  ...telemetry(strategy, backend, fallbackUsed, frameActivityDetected, samplingDurationMs)
247
248
  };
248
249
  }
250
+ // The interval runs from the start of one poll to the start of the
251
+ // next: a slow sample (a full hierarchy dump) eats into the wait
252
+ // instead of being added on top of it.
253
+ const sleepMs = Math.min(config.pollIntervalMs - (this.clock.now() - pollStartedAt), remainingMs);
254
+ if (sleepMs <= 0) {
255
+ continue;
256
+ }
249
257
  try {
250
- await this.clock.sleep(Math.min(config.pollIntervalMs, remainingMs), signal);
258
+ await this.clock.sleep(sleepMs, signal);
251
259
  }
252
260
  catch (error) {
253
261
  if (isAborted(signal)) {
@@ -7,7 +7,7 @@ import { GenerationOperationError, flowReplayFailureDetails } from "../../../app
7
7
  import { GenerationSessionIdSchema, verificationPhaseLabel } from "../../../domain/generation.js";
8
8
  import { DEFAULT_DEVICE_ROLE } from "../../../domain/journey.js";
9
9
  import { ProjectRelativePathSchema } from "../../../domain/project-context.js";
10
- import { CONFIG_PATH, CONTEXT_INDEX_PATH } from "../../../domain/workspace.js";
10
+ import { CONFIG_PATH, CONTEXT_INDEX_PATH, isJourneyBriefPath, JOURNEY_BRIEF_ROOTS } from "../../../domain/workspace.js";
11
11
  import { GenerationSessionStoreError } from "../../../ports/generation-session-store.js";
12
12
  import { errorMessage, writeJson, writeLine } from "../../output.js";
13
13
  import { canonicalProjectRoot } from "../../project-root.js";
@@ -52,6 +52,9 @@ export function createStartCommand(dependencies) {
52
52
  catch (error) {
53
53
  throw new GenerationOperationError("BRIEF_INVALID", `Journey Brief path must stay within the project: ${briefRequest} (${error instanceof Error ? error.message : String(error)})`);
54
54
  }
55
+ if (!isJourneyBriefPath(briefPath)) {
56
+ throw new GenerationOperationError("BRIEF_INVALID", `Journey Brief must live under ${JOURNEY_BRIEF_ROOTS.join("/ or ")}/: ${briefPath}`);
57
+ }
55
58
  let bytes;
56
59
  try {
57
60
  bytes = await dependencies.readFile(resolve(projectRoot, briefPath));
@@ -213,6 +213,7 @@ function createCheckCommand(dependencies) {
213
213
  .option("--project <path>", "Android project root", dependencies.cwd())
214
214
  .option("--config <path>", "TapHound config path", CONFIG_PATH)
215
215
  .option("--context <path>", "Project Context index path")
216
+ .option("--journey <path-or-name...>", "Check only these Journeys (project-relative path or Journey name)")
216
217
  .option("--json", "Emit one machine-readable JSON value")
217
218
  .option("--strict", "Exit non-zero when any Journey is not fresh")
218
219
  .action(async (options) => {
@@ -245,7 +246,8 @@ function createCheckCommand(dependencies) {
245
246
  projectRoot,
246
247
  config,
247
248
  project,
248
- bundle: index.bundle
249
+ bundle: index.bundle,
250
+ journeys: options.journey
249
251
  });
250
252
  const notFresh = result.summary.total - result.summary.fresh;
251
253
  const exitCode = options.strict === true && notFresh > 0 ? 1 : 0;
@@ -851,6 +851,10 @@ export declare const TapHoundReportV4Schema: z.ZodObject<{
851
851
  logcatEvidence: z.ZodOptional<z.ZodArray<z.ZodObject<{
852
852
  role: z.ZodString;
853
853
  status: z.ZodLiteral<"incomplete">;
854
+ expectationImpact: z.ZodEnum<{
855
+ none: "none";
856
+ possible: "possible";
857
+ }>;
854
858
  droppedLines: z.ZodNumber;
855
859
  droppedBytes: z.ZodNumber;
856
860
  lastDroppedAtMs: z.ZodOptional<z.ZodNumber>;
@@ -1478,6 +1482,10 @@ export declare const TapHoundReportSchema: z.ZodObject<{
1478
1482
  logcatEvidence: z.ZodOptional<z.ZodArray<z.ZodObject<{
1479
1483
  role: z.ZodString;
1480
1484
  status: z.ZodLiteral<"incomplete">;
1485
+ expectationImpact: z.ZodEnum<{
1486
+ none: "none";
1487
+ possible: "possible";
1488
+ }>;
1481
1489
  droppedLines: z.ZodNumber;
1482
1490
  droppedBytes: z.ZodNumber;
1483
1491
  lastDroppedAtMs: z.ZodOptional<z.ZodNumber>;
@@ -264,6 +264,7 @@ const ReportFields = {
264
264
  logcatEvidence: z.array(z.strictObject({
265
265
  role: DeviceRoleSchema,
266
266
  status: z.literal("incomplete"),
267
+ expectationImpact: z.enum(["none", "possible"]),
267
268
  droppedLines: z.number().int().positive(),
268
269
  droppedBytes: z.number().int().positive(),
269
270
  lastDroppedAtMs: z.number().nonnegative().optional()
@@ -12,6 +12,15 @@ export declare const JOURNEY_SOURCES_DIR = ".taphound/sources";
12
12
  export declare const JOURNEYS_DIR = ".taphound/journeys";
13
13
  export declare const CONTRACTS_DIR = ".taphound/contracts";
14
14
  export declare const BASELINES_DIR = ".taphound/baselines";
15
+ export declare const BRIEFS_DIR = ".taphound/briefs";
16
+ export declare const SUITES_DIR = ".taphound/suites";
17
+ /**
18
+ * Journey Briefs are committed project material: standalone Briefs live under
19
+ * `.taphound/briefs/`, Case Suite Briefs under `.taphound/suites/<suite-id>/`.
20
+ * Nothing else in the project may be bound as a Brief.
21
+ */
22
+ export declare const JOURNEY_BRIEF_ROOTS: readonly [".taphound/briefs", ".taphound/suites"];
23
+ export declare function isJourneyBriefPath(path: string): boolean;
15
24
  export declare const BUILD_DIR = ".taphound/build";
16
25
  export declare const GENERATIONS_DIR = ".taphound/build/generations";
17
26
  export declare const JOBS_DIR = ".taphound/build/jobs";
@@ -14,6 +14,18 @@ export const JOURNEY_SOURCES_DIR = `${TAPHOUND_DIR}/sources`;
14
14
  export const JOURNEYS_DIR = `${TAPHOUND_DIR}/journeys`;
15
15
  export const CONTRACTS_DIR = `${TAPHOUND_DIR}/contracts`;
16
16
  export const BASELINES_DIR = `${TAPHOUND_DIR}/baselines`;
17
+ export const BRIEFS_DIR = `${TAPHOUND_DIR}/briefs`;
18
+ export const SUITES_DIR = `${TAPHOUND_DIR}/suites`;
19
+ /**
20
+ * Journey Briefs are committed project material: standalone Briefs live under
21
+ * `.taphound/briefs/`, Case Suite Briefs under `.taphound/suites/<suite-id>/`.
22
+ * Nothing else in the project may be bound as a Brief.
23
+ */
24
+ export const JOURNEY_BRIEF_ROOTS = [BRIEFS_DIR, SUITES_DIR];
25
+ export function isJourneyBriefPath(path) {
26
+ const normalized = posix.normalize(path.replaceAll("\\", "/"));
27
+ return JOURNEY_BRIEF_ROOTS.some((root) => normalized.startsWith(`${root}/`));
28
+ }
17
29
  export const BUILD_DIR = `${TAPHOUND_DIR}/build`;
18
30
  export const GENERATIONS_DIR = `${BUILD_DIR}/generations`;
19
31
  export const JOBS_DIR = `${BUILD_DIR}/jobs`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taphound",
3
- "version": "0.2.0-dev.12",
3
+ "version": "0.2.0-dev.13",
4
4
  "description": "Deterministic app journey recording and verification",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -30,7 +30,8 @@
30
30
  "files": [
31
31
  "dist",
32
32
  "assets/brand/taphound-mark.svg",
33
- "assets/skills"
33
+ "assets/skills",
34
+ "scripts/feedback-pack.mjs"
34
35
  ],
35
36
  "engines": {
36
37
  "node": ">=22"
@@ -0,0 +1,399 @@
1
+ #!/usr/bin/env node
2
+ // Redact and pack TapHound generation evidence for a feedback report.
3
+ // Standalone: needs only Node.js and `tar`, not a TapHound checkout.
4
+ //
5
+ // Usage (run from the Android project root, or pass --project):
6
+ // node feedback-pack.mjs [--project <root>] [--package <pkg>]
7
+ // [--generation <id>]... [--run <runId>]... [--out <file.tgz>]
8
+ //
9
+ // Unlike `taphound diagnose export` (allowlisted counters, no evidence), this
10
+ // keeps step timing, idle telemetry, and full layout snapshots, which is what
11
+ // timing and screen-state problems need. See docs/diagnostics.md.
12
+ //
13
+ // Collects every JSON file under .taphound/build/generations/<id>/ (including
14
+ // active .<id>.work bundles) and, optionally, .taphound/build/runs/<runId>/
15
+ // report.json. Screenshots, Logcat text, and other non-JSON files are never
16
+ // copied. Package, Activity/class names, resource ids, UI text, Logcat
17
+ // tags/patterns, device serials, and absolute paths are replaced by stable
18
+ // pseudonyms. The pseudonym mapping is written next to the archive, never
19
+ // inside it, so you can translate references back.
20
+
21
+ import { spawnSync } from "node:child_process";
22
+ import process from "node:process";
23
+ import {
24
+ existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync,
25
+ statSync, writeFileSync
26
+ } from "node:fs";
27
+ import { homedir, tmpdir } from "node:os";
28
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
29
+
30
+ const HELP = `Usage: node feedback-pack.mjs [options]
31
+
32
+ --project <root> Android project root (default: current directory)
33
+ --package <pkg> App package (default: run.packageName in .taphound/config.json)
34
+ --org-prefix <pfx> Extra class-name prefix to redact, e.g. com.acme (repeatable)
35
+ --generation <id> Only this generation id (repeatable; default: all)
36
+ --run <runId> Also include .taphound/build/runs/<runId>/report.json (repeatable)
37
+ --out <file> Archive path (default: .taphound/build/diagnostics/
38
+ taphound-feedback-<timestamp>.tgz)
39
+ --help Show this help
40
+ `;
41
+
42
+ // Keys whose string values are free UI/business text.
43
+ const TEXT_KEYS = new Set([
44
+ "text", "contentDescription", "hint", "title", "label", "tooltipText",
45
+ "pattern", "tag", "event", "literal", "inputText"
46
+ ]);
47
+ // Keys whose whole subtree holds business values.
48
+ const VALUE_TREE_KEYS = new Set(["fields", "values", "captures", "bindings"]);
49
+ const SERIAL_KEYS = new Set(["deviceSerial", "serial", "device"]);
50
+ // Keys holding diagnostic sentences that may quote UI text.
51
+ const FREE_TEXT_KEYS = new Set([
52
+ "message", "reason", "detail", "details", "error", "summary", "diagnostic",
53
+ "description", "remediation", "stderr", "stdout", "hint"
54
+ ]);
55
+ // Packages kept verbatim: they describe the platform, not the app.
56
+ const PUBLIC_PREFIXES = [
57
+ "android.", "androidx.", "com.android.", "com.google.android.", "java.",
58
+ "kotlin.", "dalvik.", "io.appium.", "com.github.uiautomator"
59
+ ];
60
+
61
+ const QUALIFIED = /^(?:[A-Za-z_$][\w$]*\.)+[A-Za-z_$][\w$]*$/;
62
+ const RESOURCE_ID = /^([A-Za-z_][\w.]*):(id|string|drawable|layout)\/(.+)$/;
63
+ const HEX_HASH = /^[a-f\d]{40,64}$/;
64
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z?$/;
65
+
66
+ function parseArgs(argv) {
67
+ const options = {
68
+ project: process.cwd(), package: undefined, orgPrefixes: [],
69
+ generations: [], runs: [], out: undefined, help: false
70
+ };
71
+ for (let index = 0; index < argv.length; index += 1) {
72
+ const flag = argv[index];
73
+ const value = () => {
74
+ const next = argv[index + 1];
75
+ if (next === undefined || next.startsWith("--")) {
76
+ fail(`${flag} requires a value`);
77
+ }
78
+ index += 1;
79
+ return next;
80
+ };
81
+ switch (flag) {
82
+ case "--project": options.project = value(); break;
83
+ case "--package": options.package = value(); break;
84
+ case "--org-prefix": options.orgPrefixes.push(value()); break;
85
+ case "--generation": options.generations.push(value()); break;
86
+ case "--run": options.runs.push(value()); break;
87
+ case "--out": options.out = value(); break;
88
+ case "--help": case "-h": options.help = true; break;
89
+ default: fail(`Unknown option ${flag}\n\n${HELP}`);
90
+ }
91
+ }
92
+ options.project = resolve(options.project);
93
+ return options;
94
+ }
95
+
96
+ function fail(message) {
97
+ process.stderr.write(`error: ${message}\n`);
98
+ process.exit(1);
99
+ }
100
+
101
+ function readPackage(projectRoot) {
102
+ const configPath = join(projectRoot, ".taphound", "config.json");
103
+ if (!existsSync(configPath)) return undefined;
104
+ try {
105
+ return JSON.parse(readFileSync(configPath, "utf8"))?.run?.packageName;
106
+ } catch {
107
+ return undefined;
108
+ }
109
+ }
110
+
111
+ function listJsonFiles(directory) {
112
+ const files = [];
113
+ const walk = (current) => {
114
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
115
+ const path = join(current, entry.name);
116
+ if (entry.isDirectory()) walk(path);
117
+ // Skip macOS AppleDouble "._*" companions of copied files.
118
+ else if (entry.isFile() && entry.name.endsWith(".json")
119
+ && !entry.name.startsWith("._")) files.push(path);
120
+ }
121
+ };
122
+ walk(directory);
123
+ return files.sort();
124
+ }
125
+
126
+ function collectSources(options) {
127
+ const buildRoot = join(options.project, ".taphound", "build");
128
+ const generationsRoot = join(buildRoot, "generations");
129
+ if (!existsSync(generationsRoot)) {
130
+ fail(`No generations directory at ${generationsRoot}`);
131
+ }
132
+ const wanted = new Set(options.generations);
133
+ const bundles = readdirSync(generationsRoot, { withFileTypes: true })
134
+ .filter((entry) => entry.isDirectory() && entry.name !== ".locks")
135
+ .filter((entry) => {
136
+ const id = entry.name.replace(/^\.(.+)\.work$/, "$1");
137
+ return wanted.size === 0 || wanted.has(id);
138
+ })
139
+ .map((entry) => join(generationsRoot, entry.name));
140
+ if (bundles.length === 0) fail("No matching generation bundles found");
141
+
142
+ const files = bundles.flatMap(listJsonFiles);
143
+ for (const runId of options.runs) {
144
+ const report = join(buildRoot, "runs", runId, "report.json");
145
+ if (!existsSync(report)) fail(`Run report not found: ${report}`);
146
+ files.push(report);
147
+ const verdict = join(buildRoot, "runs", runId, "verdict.json");
148
+ if (existsSync(verdict)) files.push(verdict);
149
+ }
150
+ return { files, bundles };
151
+ }
152
+
153
+ class Redactor {
154
+ constructor({ packageName, orgPrefixes, projectRoot }) {
155
+ this.appPackage = packageName;
156
+ this.prefixes = [...new Set([
157
+ packageName,
158
+ packageName.split(".").slice(0, 2).join("."),
159
+ ...orgPrefixes
160
+ ])].filter((prefix) => prefix.includes("."));
161
+ this.paths = [[projectRoot, "<project>"], [homedir(), "~"]]
162
+ .filter(([path]) => path.length > 1);
163
+ this.maps = {
164
+ package: new Map([[packageName, "com.example.app"]]),
165
+ class: new Map(),
166
+ resource: new Map(),
167
+ text: new Map(),
168
+ resourceName: new Map(),
169
+ serial: new Map()
170
+ };
171
+ }
172
+
173
+ isPrivateName(value) {
174
+ if (PUBLIC_PREFIXES.some((prefix) => value.startsWith(prefix))) return false;
175
+ return this.prefixes.some(
176
+ (prefix) => value === prefix || value.startsWith(`${prefix}.`)
177
+ );
178
+ }
179
+
180
+ pseudo(kind, value, make) {
181
+ const map = this.maps[kind];
182
+ if (!map.has(value)) map.set(value, make(map.size + 1));
183
+ return map.get(value);
184
+ }
185
+
186
+ packageFor(value) {
187
+ if (this.maps.package.has(value)) return this.maps.package.get(value);
188
+ if (value.startsWith(`${this.appPackage}.`)) return undefined;
189
+ return this.pseudo("package", value, (n) => `com.example.other${n}`);
190
+ }
191
+
192
+ classFor(value) {
193
+ if (this.maps.package.has(value)) return this.maps.package.get(value);
194
+ return this.pseudo("class", value, (n) => {
195
+ const last = value.split(".").at(-1) ?? "";
196
+ const kind = /Activity$/.test(last) ? "Activity"
197
+ : /Fragment$/.test(last) ? "Fragment" : "Class";
198
+ return `com.example.app.${kind}${n}`;
199
+ });
200
+ }
201
+
202
+ // Pass 1: learn every sensitive value.
203
+ learn(value, key, inValueTree) {
204
+ if (Array.isArray(value)) {
205
+ for (const item of value) this.learn(item, key, inValueTree);
206
+ return;
207
+ }
208
+ if (value !== null && typeof value === "object") {
209
+ for (const [childKey, child] of Object.entries(value)) {
210
+ this.learn(child, childKey, inValueTree || VALUE_TREE_KEYS.has(childKey));
211
+ }
212
+ return;
213
+ }
214
+ if (typeof value !== "string" || value.length === 0) return;
215
+ if (SERIAL_KEYS.has(key) && !QUALIFIED.test(value)) {
216
+ this.pseudo("serial", value, (n) => `device-${n}`);
217
+ return;
218
+ }
219
+ if (key === "resourceId" && !value.includes(":")) {
220
+ // Some UI backends report bare resource names (e.g. "btn_submit").
221
+ this.pseudo("resourceName", value, (n) => `r${n}`);
222
+ return;
223
+ }
224
+ const resource = RESOURCE_ID.exec(value);
225
+ if (resource !== null) {
226
+ const [, owner, type] = resource;
227
+ if (owner !== "android" && !PUBLIC_PREFIXES.some((p) => `${owner}.`.startsWith(p))) {
228
+ this.pseudo("resource", value, (n) => {
229
+ const pkg = owner === this.appPackage ? "com.example.app" : "com.example.other";
230
+ return `${pkg}:${type}/r${n}`;
231
+ });
232
+ }
233
+ return;
234
+ }
235
+ if (QUALIFIED.test(value) && this.isPrivateName(value)) {
236
+ if (key.toLowerCase().includes("package")) this.packageFor(value);
237
+ else this.classFor(value);
238
+ return;
239
+ }
240
+ if (TEXT_KEYS.has(key) || inValueTree) {
241
+ if (HEX_HASH.test(value) || ISO_DATE.test(value)) return;
242
+ this.pseudo("text", value, (n) => `T${n}`);
243
+ }
244
+ }
245
+
246
+ finish() {
247
+ // Longest originals first so a substring never shadows its container.
248
+ const byLength = (a, b) => b[0].length - a[0].length;
249
+ const names = [...this.paths];
250
+ for (const kind of ["serial", "resource", "class", "package"]) {
251
+ for (const pair of this.maps[kind]) names.push(pair);
252
+ }
253
+ this.nameSubstrings = names.sort(byLength);
254
+ this.textSubstrings = [...this.maps.text, ...this.maps.resourceName]
255
+ .filter(([original]) => original.length >= 3)
256
+ .sort(byLength);
257
+ this.exactNames = new Map(names);
258
+ }
259
+
260
+ // Pass 2: rewrite values. Keys stay verbatim so the schema remains readable.
261
+ rewrite(value, key = "", inValueTree = false) {
262
+ if (Array.isArray(value)) {
263
+ return value.map((item) => this.rewrite(item, key, inValueTree));
264
+ }
265
+ if (value !== null && typeof value === "object") {
266
+ return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [
267
+ childKey,
268
+ this.rewrite(child, childKey, inValueTree || VALUE_TREE_KEYS.has(childKey))
269
+ ]));
270
+ }
271
+ if (typeof value !== "string") return value;
272
+ if (this.exactNames.has(value)) return this.exactNames.get(value);
273
+ if (key === "resourceId" && this.maps.resourceName.has(value)) {
274
+ return this.maps.resourceName.get(value);
275
+ }
276
+ if ((TEXT_KEYS.has(key) || inValueTree) && this.maps.text.has(value)) {
277
+ return this.maps.text.get(value);
278
+ }
279
+ const withNames = this.rewriteString(value, this.nameSubstrings);
280
+ return FREE_TEXT_KEYS.has(key)
281
+ ? this.rewriteString(withNames, this.textSubstrings)
282
+ : withNames;
283
+ }
284
+
285
+ rewriteString(value, pairs = this.nameSubstrings) {
286
+ // Placeholders keep one replacement's output from matching another original.
287
+ let result = value;
288
+ const pending = [];
289
+ for (const [original, replacement] of pairs) {
290
+ if (!result.includes(original)) continue;
291
+ const token = `\u0000${pending.length}\u0000`;
292
+ result = result.split(original).join(token);
293
+ pending.push([token, replacement]);
294
+ }
295
+ for (const [token, replacement] of pending) {
296
+ result = result.split(token).join(replacement);
297
+ }
298
+ return result;
299
+ }
300
+
301
+ mapping() {
302
+ return Object.fromEntries(Object.entries(this.maps).map(
303
+ ([kind, map]) => [kind, Object.fromEntries(
304
+ [...map].map(([original, replacement]) => [replacement, original])
305
+ )]
306
+ ));
307
+ }
308
+
309
+ counts() {
310
+ return Object.fromEntries(Object.entries(this.maps).map(
311
+ ([kind, map]) => [kind, map.size]
312
+ ));
313
+ }
314
+ }
315
+
316
+ function main() {
317
+ const options = parseArgs(process.argv.slice(2));
318
+ if (options.help) {
319
+ process.stdout.write(HELP);
320
+ return;
321
+ }
322
+ const packageName = options.package ?? readPackage(options.project);
323
+ if (typeof packageName !== "string" || !QUALIFIED.test(packageName)) {
324
+ fail("Cannot determine the app package; pass --package <pkg>");
325
+ }
326
+ const { files, bundles } = collectSources(options);
327
+ const redactor = new Redactor({
328
+ packageName, orgPrefixes: options.orgPrefixes, projectRoot: options.project
329
+ });
330
+
331
+ const documents = [];
332
+ for (const file of files) {
333
+ try {
334
+ documents.push([file, JSON.parse(readFileSync(file, "utf8"))]);
335
+ } catch {
336
+ process.stderr.write(`skip (not valid JSON): ${relative(options.project, file)}\n`);
337
+ }
338
+ }
339
+ for (const [, document] of documents) redactor.learn(document, "", false);
340
+ redactor.finish();
341
+
342
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
343
+ // The diagnostics directory sits in the Git-ignored build subtree.
344
+ const out = resolve(options.out ?? join(
345
+ options.project, ".taphound", "build", "diagnostics",
346
+ `taphound-feedback-${stamp}.tgz`
347
+ ));
348
+ mkdirSync(dirname(out), { recursive: true });
349
+ const staging = mkdtempSync(join(tmpdir(), "taphound-feedback-"));
350
+ const root = join(staging, "taphound-feedback");
351
+ try {
352
+ for (const [file, document] of documents) {
353
+ const relativePath = redactor.rewriteString(
354
+ relative(options.project, file).split(sep).join("/")
355
+ );
356
+ const target = join(root, ...relativePath.split("/"));
357
+ mkdirSync(dirname(target), { recursive: true });
358
+ writeFileSync(target, `${JSON.stringify(redactor.rewrite(document), null, 2)}\n`);
359
+ }
360
+ writeFileSync(join(root, "README.txt"), [
361
+ "TapHound feedback bundle (redacted).",
362
+ `Generation bundles: ${bundles.length}`,
363
+ `JSON files: ${documents.length}`,
364
+ `Pseudonyms: ${JSON.stringify(redactor.counts())}`,
365
+ "Screenshots, Logcat text, and non-JSON files are excluded.",
366
+ ""
367
+ ].join("\n"));
368
+
369
+ const tar = spawnSync(
370
+ "tar", ["-czf", out, "-C", staging, "taphound-feedback"],
371
+ {
372
+ shell: false,
373
+ stdio: ["ignore", "inherit", "inherit"],
374
+ // Stop macOS bsdtar from adding AppleDouble "._*" xattr entries.
375
+ env: { ...process.env, COPYFILE_DISABLE: "1" }
376
+ }
377
+ );
378
+ if (tar.error !== undefined || tar.status !== 0) {
379
+ fail(`tar failed${tar.error ? `: ${tar.error.message}` : ""}`);
380
+ }
381
+ } finally {
382
+ rmSync(staging, { recursive: true, force: true });
383
+ }
384
+
385
+ const mappingPath = out.replace(/\.t(?:ar\.)?gz$/, "") + ".mapping.json";
386
+ writeFileSync(mappingPath, `${JSON.stringify(redactor.mapping(), null, 2)}\n`);
387
+
388
+ process.stdout.write([
389
+ `Archive: ${out} (${statSync(out).size} bytes)`,
390
+ `Mapping: ${mappingPath} <- keep this local, do NOT share`,
391
+ `Bundles: ${bundles.map((bundle) => basename(bundle)).join(", ")}`,
392
+ `Files: ${documents.length} JSON`,
393
+ `Redacted: ${JSON.stringify(redactor.counts())}`,
394
+ "Review: tar -xzf <archive> and grep for anything still sensitive before sharing.",
395
+ ""
396
+ ].join("\n"));
397
+ }
398
+
399
+ main();