@tech-leads-club/harness-toolkit 0.5.0 → 0.6.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.
@@ -11,7 +11,11 @@ function readJsonFile<T>(path: string): T | null {
11
11
  return null;
12
12
  }
13
13
  try {
14
- return JSON.parse(readFileSync(path, "utf8")) as T;
14
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
15
+ // why: a JSON Schema meta-key, not a Policy field — deepMerge's `...patch` spread would otherwise
16
+ // carry it straight into the merged runtime Policy object.
17
+ delete parsed.$schema;
18
+ return parsed as T;
15
19
  } catch {
16
20
  return null;
17
21
  }
@@ -13,10 +13,35 @@
13
13
  /** A leaf the project config names, and the value it would have had without naming it. */
14
14
  export type ShadowedKey = { path: string; value: unknown };
15
15
 
16
+ /** A leaf the project config names that `resolved` (the shipped `Policy` shape) has no counterpart for. */
17
+ export type UnknownKey = { path: string; value: unknown };
18
+
19
+ /** A leaf present in both, whose runtime shape disagrees with the default's. */
20
+ export type TypeMismatch = { path: string; expected: string; actual: string };
21
+
16
22
  function isPlainObject(value: unknown): value is Record<string, unknown> {
17
23
  return typeof value === "object" && value !== null && !Array.isArray(value);
18
24
  }
19
25
 
26
+ /**
27
+ * why: `typeof null` is `"object"`, indistinguishable from a real object without this. A field whose
28
+ * default is `null` (`evidenceDir`, `lintCommand`, `minEffort`, ...) is a `T | null` union in `Policy`
29
+ * — comparing `typeof` directly against that default would flag every valid non-null override as a
30
+ * mismatch, which is why the mismatch check below exempts `below === null` entirely.
31
+ */
32
+ function typeLabel(value: unknown): string {
33
+ if (value === null) {
34
+ return "null";
35
+ }
36
+ if (Array.isArray(value)) {
37
+ return "array";
38
+ }
39
+ if (isPlainObject(value)) {
40
+ return "object";
41
+ }
42
+ return typeof value;
43
+ }
44
+
20
45
  /**
21
46
  * why a JSON comparison rather than a deep walk of its own: the values are config leaves — scalars and small
22
47
  * arrays — and `codePaths: ["src"]` has to compare equal to `codePaths: ["src"]`. A second structural comparator
@@ -36,13 +61,22 @@ function sameValue(a: unknown, b: unknown): boolean {
36
61
  * `resolved` is the policy as it would be with the project config absent: the shipped defaults merged with this
37
62
  * machine's tier.
38
63
  */
64
+ type WalkResult = {
65
+ shadowed: ShadowedKey[];
66
+ kept: Record<string, unknown>;
67
+ unknown: UnknownKey[];
68
+ mismatched: TypeMismatch[];
69
+ };
70
+
39
71
  function walk(
40
72
  project: Record<string, unknown>,
41
73
  resolved: Record<string, unknown>,
42
74
  prefix: string,
43
- ): { shadowed: ShadowedKey[]; kept: Record<string, unknown> } {
75
+ ): WalkResult {
44
76
  const shadowed: ShadowedKey[] = [];
45
77
  const kept: Record<string, unknown> = {};
78
+ const unknown: UnknownKey[] = [];
79
+ const mismatched: TypeMismatch[] = [];
46
80
  for (const [key, value] of Object.entries(project)) {
47
81
  // why kept and never reported: `version` marks the config's shape rather than a setting, so naming it is
48
82
  // required rather than redundant.
@@ -52,9 +86,17 @@ function walk(
52
86
  }
53
87
  const path = prefix === "" ? key : `${prefix}.${key}`;
54
88
  const below = resolved[key];
89
+ // why: `projectName` is exempt — a real, optional `Policy` field `DEFAULTS` never sets, so it is
90
+ // legitimately absent from `resolved`'s own keys. Kept separate from the branches below: a key
91
+ // absent from `resolved` still needs `shadowed`/`kept` judged as before; this only adds to `unknown`.
92
+ if (!(key in resolved) && !(prefix === "" && key === "projectName")) {
93
+ unknown.push({ path, value });
94
+ }
55
95
  if (isPlainObject(value) && isPlainObject(below)) {
56
96
  const inner = walk(value, below, path);
57
97
  shadowed.push(...inner.shadowed);
98
+ unknown.push(...inner.unknown);
99
+ mismatched.push(...inner.mismatched);
58
100
  // why empty blocks go: `{ shipGate: {} }` decides nothing, and leaving it behind would make a pruned config
59
101
  // read as though it had opinions.
60
102
  if (Object.keys(inner.kept).length > 0) {
@@ -62,13 +104,18 @@ function walk(
62
104
  }
63
105
  continue;
64
106
  }
107
+ // why: `below === null` is exempt — see `typeLabel`'s doc, a `T | null` default cannot be told
108
+ // apart from a real mismatch by `typeof` alone, and a false positive here is worse than a miss.
109
+ if (below !== null && below !== undefined && typeLabel(value) !== typeLabel(below)) {
110
+ mismatched.push({ path, expected: typeLabel(below), actual: typeLabel(value) });
111
+ }
65
112
  if (sameValue(value, below)) {
66
113
  shadowed.push({ path, value });
67
114
  } else {
68
115
  kept[key] = value;
69
116
  }
70
117
  }
71
- return { shadowed, kept };
118
+ return { shadowed, kept, unknown, mismatched };
72
119
  }
73
120
 
74
121
  /** What `doctor` reports: every leaf whose value the tiers below already resolve to. */
@@ -79,6 +126,25 @@ export function shadowedKeys(
79
126
  return walk(project, resolved, "").shadowed;
80
127
  }
81
128
 
129
+ /**
130
+ * What `doctor` reports: every leaf `resolved` (the shipped `Policy` shape) has no counterpart for —
131
+ * the `format` bug's exact shape, a key that does nothing because nothing reads it.
132
+ */
133
+ export function unknownKeys(
134
+ project: Record<string, unknown>,
135
+ resolved: Record<string, unknown>,
136
+ ): UnknownKey[] {
137
+ return walk(project, resolved, "").unknown;
138
+ }
139
+
140
+ /** What `doctor` reports: every leaf present in both, whose runtime shape disagrees with the default's. */
141
+ export function typeMismatches(
142
+ project: Record<string, unknown>,
143
+ resolved: Record<string, unknown>,
144
+ ): TypeMismatch[] {
145
+ return walk(project, resolved, "").mismatched;
146
+ }
147
+
82
148
  /**
83
149
  * What `init` writes: the same config with every restatement removed.
84
150
  *
@@ -470,6 +470,15 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
470
470
  const changedFiles = await listChangedRepoFiles(root, turnBase);
471
471
  const codeTargets = filterCodeTargets(changedFiles, policy.codePaths);
472
472
  const testTargets = filterTestTargets(changedFiles);
473
+ // why: the comment rail scopes by the syntax catalog (40+ languages), not `codeTargets`'s nine-extension
474
+ // regex — a Terraform/SQL-heavy project changed no file that regex would keep, so the rail never ran.
475
+ // Scoped to `codePaths` here, not inside `filterCommentTargets`, to avoid an import cycle through `policy.loader.ts`.
476
+ const commentScope = changedFiles.filter((file) =>
477
+ coreFacade.policy.isUnderCodePaths(file, policy.codePaths),
478
+ );
479
+ // why: `unknown_extensions` reports over `commentScope` (broader), not this — a language gap is still named
480
+ // even after this excludes it from the scan.
481
+ const commentTargets = coreFacade.commentPolicy.filterCommentTargets(commentScope);
473
482
  // why: read from the snapshot taken before this handler patches anything, so a credit written by the previous
474
483
  // stop is still visible when the gate it belongs to runs below.
475
484
  const pendingCredit = handoff.pending_lesson_credit;
@@ -619,12 +628,12 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
619
628
  // rate cannot — was the rule ever needed — by running the checker while the prose is absent. A measurement that
620
629
  // can change what it measures is not a measurement ([/decisions/ad-027.md](/decisions/ad-027.md)).
621
630
  if (
622
- codeTargets.length > 0 &&
631
+ commentTargets.length > 0 &&
623
632
  coreFacade.observe.shouldObserve(policy.observe, "comments", policy.comments.enabled)
624
633
  ) {
625
634
  const hits = await coreFacade.commentPolicy.scanAddedComments(
626
635
  root,
627
- codeTargets,
636
+ commentTargets,
628
637
  policy.comments.mode,
629
638
  turnBase,
630
639
  );
@@ -641,15 +650,17 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
641
650
  rule: "comments",
642
651
  // why: a language the catalog does not carry produces no findings, which reads identically to "the
643
652
  // property held". Naming the extensions is the difference between a clean reading and a blind spot.
644
- unknown_extensions: coreFacade.commentPolicy.unknownExtensions(codeTargets).join(",") || "none",
653
+ // Scoped to `commentScope`, not `commentTargets` — the target set is already syntax-known by
654
+ // construction, so it could never report the gap it exists to name.
655
+ unknown_extensions: coreFacade.commentPolicy.unknownExtensions(commentScope).join(",") || "none",
645
656
  },
646
657
  });
647
658
  }
648
659
 
649
- if (policy.comments.enabled && policy.comments.onViolation === "followup" && codeTargets.length > 0) {
660
+ if (policy.comments.enabled && policy.comments.onViolation === "followup" && commentTargets.length > 0) {
650
661
  const hits = await coreFacade.commentPolicy.scanAddedComments(
651
662
  root,
652
- codeTargets,
663
+ commentTargets,
653
664
  policy.comments.mode,
654
665
  turnBase,
655
666
  );
package/tools/doctor.ts CHANGED
@@ -578,6 +578,53 @@ export function checkShadowedPolicy(root: string): Check[] {
578
578
  ];
579
579
  }
580
580
 
581
+ /**
582
+ * invariant: a malformed config is reported distinctly from an absent one. `readProjectPolicyRaw` used to
583
+ * collapse both to `null` — the silent half of the bug this closes, since a trailing comma degrades the
584
+ * whole project tier to defaults with nothing telling the operator why.
585
+ */
586
+ export function checkConfigKeys(root: string): Check[] {
587
+ const status = coreFacade.capability.readProjectPolicyStatus(root);
588
+ if (status.status === "absent") {
589
+ return [];
590
+ }
591
+ if (status.status === "malformed") {
592
+ return [
593
+ {
594
+ level: "warn",
595
+ name: "project policy parses",
596
+ detail: `${projectConfigPath(root)} failed to parse: ${status.error}. Until fixed, this tier silently falls back to defaults.`,
597
+ },
598
+ ];
599
+ }
600
+ // why: DEFAULTS, not resolvedWithoutProjectTier() — the machine tier is operator content, not the shape
601
+ // the harness reads. An unknown key present in both tiers used to recurse into it instead of naming it,
602
+ // because resolvedWithoutProjectTier() carries the machine tier's own keys, unknown or not, straight through.
603
+ const resolved = coreFacade.policy.DEFAULTS as unknown as Record<string, unknown>;
604
+ const unknown = coreFacade.policy.unknownKeys(status.value, resolved);
605
+ const mismatched = coreFacade.policy.typeMismatches(status.value, resolved);
606
+ const checks: Check[] = [];
607
+ if (unknown.length > 0) {
608
+ const shown = unknown.slice(0, 6).map((key) => key.path);
609
+ const rest = unknown.length - shown.length;
610
+ checks.push({
611
+ level: "warn",
612
+ name: "project policy has an unknown key",
613
+ detail: `${plural(unknown.length, "key")} in this project's config ${unknown.length === 1 ? "matches" : "match"} nothing the harness reads: ${shown.join(", ")}${rest > 0 ? `, and ${rest} more` : ""}. Delete ${unknown.length === 1 ? "it" : "them"}, or check for a typo.`,
614
+ });
615
+ }
616
+ if (mismatched.length > 0) {
617
+ const shown = mismatched.slice(0, 6).map((m) => `${m.path} (expected ${m.expected}, got ${m.actual})`);
618
+ const rest = mismatched.length - shown.length;
619
+ checks.push({
620
+ level: "warn",
621
+ name: "project policy has a type mismatch",
622
+ detail: `${plural(mismatched.length, "key")} in this project's config ${mismatched.length === 1 ? "does" : "do"} not match the type the harness expects: ${shown.join(", ")}${rest > 0 ? `, and ${rest} more` : ""}.`,
623
+ });
624
+ }
625
+ return checks;
626
+ }
627
+
581
628
  export function checkLessonBudget(root: string): Check[] {
582
629
  const policy = coreFacade.policy.loadPolicy(root);
583
630
  const config = policy.intelligence.lessons;
@@ -751,6 +798,7 @@ export function checkProjectPolicy(root: string): Check[] {
751
798
  ...checkLessonHealth(root),
752
799
  ...checkLessonBudget(root),
753
800
  ...checkShadowedPolicy(root),
801
+ ...checkConfigKeys(root),
754
802
  ...checkSubagentAllowlist(root),
755
803
  ...checkPolicyDivergence(root),
756
804
  ...checkGateScope(root),
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join, sep } from "node:path";
3
+ import { NPM_PACKAGE, runtimeVersion } from "../bin/tlc-cli.ts";
3
4
  import { applyCursorWiring, renderCursorHooksDocument } from "../bin/write-user-hooks.mjs";
4
5
  import type { WiringEntry } from "../src/contracts/index.ts";
5
6
  import { coreFacade } from "../src/core/index.ts";
@@ -234,6 +235,20 @@ export function configLine(outcome: ApplyOutcome): string {
234
235
  : `wrote ${outcome.configPath}`;
235
236
  }
236
237
 
238
+ /**
239
+ * why: major.minor, not bare major — this package is pre-1.0, where a 0.x minor bump can also break,
240
+ * so pinning only the major would silently serve a schema from the wrong minor. An unreadable
241
+ * `package.json` falls back to unpkg's latest resolution rather than a version string that goes stale.
242
+ */
243
+ function schemaUrl(): string {
244
+ const version = runtimeVersion(runtimeHome());
245
+ if (version === null) {
246
+ return `https://unpkg.com/${NPM_PACKAGE}/schema.json`;
247
+ }
248
+ const [major, minor] = version.split(".");
249
+ return `https://unpkg.com/${NPM_PACKAGE}@${major}.${minor}/schema.json`;
250
+ }
251
+
237
252
  export function applyPlan(
238
253
  root: string,
239
254
  flags: InitFlags,
@@ -254,7 +269,10 @@ export function applyPlan(
254
269
  const kept = existsSync(configPath) && !flags.stdinJson;
255
270
  if (!kept) {
256
271
  mkdirSync(dirname(configPath), { recursive: true });
257
- writeFileSync(configPath, `${JSON.stringify(policy, null, 2)}\n`);
272
+ writeFileSync(
273
+ configPath,
274
+ `${JSON.stringify({ $schema: schemaUrl(), ...(policy as Record<string, unknown>) }, null, 2)}\n`,
275
+ );
258
276
  }
259
277
 
260
278
  const launcher = launcherPath();