@mjasnikovs/pi-task 0.37.4 → 0.37.5

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.
@@ -52,6 +52,7 @@ import * as path from 'node:path';
52
52
  import { runRepoHealthCheck, discoverHealthCommands } from './repo-health-check.js';
53
53
  import { readAcceptDebts, recheckAcceptDebts, writeAcceptDebts, buildAcceptDebtNote, annotateDebtConflicts } from './accept-debt.js';
54
54
  import { readDeclaredScripts, missingDeclaredScripts, runnableDeclaredScripts } from './launch-contract.js';
55
+ import { readLaunchManifest, inertLaunchContractNote } from './launch-manifest.js';
55
56
  import { readEnvNotes, parseEnvNotes, isExcuseNote } from './env-notes.js';
56
57
  import { runRenderCheck } from './render-check.js';
57
58
  import { collectProjectEnv, pinnedLocalPort, runDeepRenderCheck } from './deep-render-check.js';
@@ -1378,11 +1379,28 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1378
1379
  // scripts that fell through decompose and shipped missing, unchecked. Diff the
1379
1380
  // plan-time-extracted declared scripts against the manifest; a missing one is a
1380
1381
  // launch-surface defect. FP-safe: empty declared list (nothing grounded) → no check.
1382
+ //
1383
+ // THE DIFF IS INERT WITHOUT A MANIFEST (nexttask 16A). It used to diff against
1384
+ // `Object.keys(packageScripts(cwd))`, whose catch returns {} — so a project with
1385
+ // NO package.json was indistinguishable from one with no scripts, and every
1386
+ // declared script was reported missing in wording naming a file the project was
1387
+ // never meant to have. Nothing upstream is npm-shaped (the extractor scrapes any
1388
+ // design that says "script"), and this text seeds the autofix child's prompt, so
1389
+ // on a CMake/cargo project the likely repair was to write a package.json.
1390
+ // readLaunchManifest resolves package.json, else a Makefile's targets, else
1391
+ // nothing — and nothing means no failure plus a note, never a silent pass.
1381
1392
  const declared = await readDeclaredScripts(cwd);
1393
+ const contractNotes = [];
1382
1394
  if (declared.length > 0) {
1383
- const missing = missingDeclaredScripts(declared, Object.keys(packageScripts(cwd)));
1384
- if (missing.length > 0) {
1385
- fail(`launch contract: the design declares script(s) the shipped package.json does not expose: ${missing.join(', ')} (declared: ${declared.join(', ')})`);
1395
+ const manifest = readLaunchManifest(cwd);
1396
+ if (manifest.kind === 'none') {
1397
+ contractNotes.push(inertLaunchContractNote(declared, manifest));
1398
+ }
1399
+ else {
1400
+ const missing = missingDeclaredScripts(declared, manifest.names);
1401
+ if (missing.length > 0) {
1402
+ fail(`launch contract: the design declares script(s) the shipped ${manifest.file} does not expose: ${missing.join(', ')} (declared: ${declared.join(', ')})`);
1403
+ }
1386
1404
  }
1387
1405
  }
1388
1406
  // Serve-entry closure (mx5 run 18, nexttask 2B): the tree builds a server app,
@@ -1416,7 +1434,12 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1416
1434
  // harvest lever refuted at discoverIntegrationCommands it cannot inject a fabricated
1417
1435
  // failure.
1418
1436
  if (lockCmds.length === 0 && cmds.length === 0 && !boot && failures.length === 0) {
1419
- const note = unobservedVerdict({ discovered: 0, observed: 0 }) ?? '';
1437
+ // The inert-contract note rides here too: a non-npm project carrying a launch
1438
+ // contract usually discovers no command either, and that is exactly the run
1439
+ // whose silence must not read as "the contract was checked and was fine".
1440
+ const note = [unobservedVerdict({ discovered: 0, observed: 0 }) ?? '', ...contractNotes]
1441
+ .filter(n => n !== '')
1442
+ .join(' ');
1420
1443
  return withDebts({ ok: true, unobserved: note, reason: note });
1421
1444
  }
1422
1445
  const ran = [];
@@ -1471,6 +1494,11 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1471
1494
  // A declared script the manifest doesn't expose is already a launch-contract
1472
1495
  // failure above; executing it too would double-report (pre-aggregation the
1473
1496
  // contract diff early-returned, so this loop could assume presence).
1497
+ // EXECUTION STAYS npm-ONLY. The diff above now also speaks Makefile (16A),
1498
+ // but the runner below is literally `bun run <name>`; on a Makefile project
1499
+ // `present` is empty, so every declared target is skipped rather than run
1500
+ // through the wrong tool. Widening the RUNNER is a separate lever with its
1501
+ // own A/B, not a free rider on an inertness fix.
1474
1502
  const present = new Set(Object.keys(packageScripts(cwd)).map(s => s.toLowerCase()));
1475
1503
  const scripts = packageScripts(cwd);
1476
1504
  // CONFIG-GAP INPUTS (mx5 run 20), read once: the tracked file list and the
@@ -1704,7 +1732,8 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
1704
1732
  const unobserved = [
1705
1733
  bootUnobserved,
1706
1734
  unobservedVerdict({ discovered: dynAttempted, observed: dynObserved }),
1707
- ...configGapNotes
1735
+ ...configGapNotes,
1736
+ ...contractNotes
1708
1737
  ]
1709
1738
  .filter(n => n !== null)
1710
1739
  .join(' ');
@@ -0,0 +1,37 @@
1
+ /** Which manifest kind the diff resolved for a tree. `none` ⇒ the diff is INERT. */
2
+ export type LaunchManifestKind = 'npm' | 'make' | 'none';
3
+ export interface LaunchManifest {
4
+ kind: LaunchManifestKind;
5
+ /** The manifest the diff is taken against, for the failure text. '' when none. */
6
+ file: string;
7
+ /** Entrypoint names the manifest exposes: npm script keys / Makefile targets. */
8
+ names: string[];
9
+ /** Why nothing is checkable (kind === 'none' only). */
10
+ why?: string;
11
+ }
12
+ /**
13
+ * The explicit targets a Makefile declares. Deliberately conservative — this list
14
+ * only ever has to answer "does the project expose `migrate`", so a missed exotic
15
+ * target costs a false FAIL and is the one error worth avoiding:
16
+ * • recipe lines (leading TAB) are skipped — they are shell, not targets;
17
+ * • `:=` / `::=` / `?=` / `+=` assignments are skipped;
18
+ * • pattern rules (`%.o: %.c`) and dot-targets (`.PHONY:`) fail TARGET_NAME_RE,
19
+ * which is also why the names listed AFTER `.PHONY:` are ignored here: they are
20
+ * prerequisites, and every one of them that is real is declared by its own rule.
21
+ * Multiple targets on one line (`build test:`) all count.
22
+ */
23
+ export declare function makeTargets(src: string): string[];
24
+ /**
25
+ * Resolve the manifest the launch contract may be diffed against.
26
+ *
27
+ * A package.json that exists but does not parse resolves to `none`, not to "zero
28
+ * scripts": diffing against a manifest you could not read is exactly the mistake
29
+ * this module exists to stop, and a broken manifest is the static checks' business.
30
+ */
31
+ export declare function readLaunchManifest(cwd: string): LaunchManifest;
32
+ /**
33
+ * The one line an INERT contract leaves behind. It rides in the gate's UNOBSERVED
34
+ * channel rather than in `warnings` for the reason nexttask 16A gives: a check that
35
+ * silently did nothing must not read later as a check that passed.
36
+ */
37
+ export declare function inertLaunchContractNote(declared: string[], manifest: LaunchManifest): string;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * launch-manifest — WHICH manifest the launch-contract diff is entitled to diff
3
+ * against, and whether there is one at all (nexttask 16A).
4
+ *
5
+ * The defect this closes (unobserved, proven by construction). The extraction end
6
+ * of the launch contract has no ecosystem test anywhere in it:
7
+ * `enumerateScriptCandidates` scrapes backticked, script-name-shaped tokens out of
8
+ * any design paragraph that says "script", and `keepGroundedScripts` can only DROP
9
+ * a candidate. So a CMake / cargo / poetry / Makefile design saying *"the Makefile
10
+ * must expose `build`, `test`, `migrate`"* records exactly the same artifact an npm
11
+ * design does. The diff end hardcoded the ecosystem anyway:
12
+ *
13
+ * missingDeclaredScripts(declared, Object.keys(packageScripts(cwd)))
14
+ * …where packageScripts' catch returns {} — so "this project has no
15
+ * package.json" was indistinguishable from "its package.json declares no
16
+ * scripts", and every declared script was reported missing, at rank 1, in
17
+ * wording that NAMES a file the project was never meant to have.
18
+ *
19
+ * That text seeds the autofix child's prompt (final-gate.ts's FinalGateOutcome.reason),
20
+ * so the most likely repair on a CMake project was to write a package.json.
21
+ *
22
+ * IAR1 is how close this got: `plan-debug.log:10` records "launch-contract
23
+ * extraction: 0 grounded script(s) kept from 3 emitted" — a non-npm project that
24
+ * reached extraction and emitted three candidates, saved only by its design not
25
+ * backticking them in a "script" paragraph.
26
+ *
27
+ * THE RULE, and it is deliberately two ecosystems wide, not three:
28
+ * • package.json present and parseable ⇒ diff against its `scripts` keys, exactly
29
+ * as before (byte-identical failure text on every npm tree — mx5's missing
30
+ * `build`/`seed` is a TRUE positive and must keep failing).
31
+ * • no package.json but a Makefile ⇒ diff against its TARGETS. The capability was
32
+ * already in final-gate.ts (`makeHasTarget`); the diff simply never called it.
33
+ * • neither ⇒ the check is INERT. No failure, and a note saying the contract was
34
+ * recorded but is not checkable here, so the silence is not read later as a pass
35
+ * (the repo-health-check / env-template-closure discipline: ENOENT = pass, and a
36
+ * check must never invent a file the project chose not to have).
37
+ *
38
+ * cargo/poetry/gradle are NOT here on purpose. The corpus on this box contains one
39
+ * project that ever recorded a launch contract (mx5, npm) and one non-npm project
40
+ * that reached extraction (IAR1, CMake). A third ecosystem would be a guess.
41
+ */
42
+ import { existsSync, readFileSync } from 'node:fs';
43
+ import * as path from 'node:path';
44
+ /** GNU make's own lookup order, and nothing beyond it — this is one ecosystem. */
45
+ const MAKEFILE_NAMES = ['GNUmakefile', 'makefile', 'Makefile'];
46
+ /** A target we are willing to name in a failure: an ordinary word-shaped target. */
47
+ const TARGET_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,39}$/i;
48
+ /**
49
+ * A variable assignment, in every form make accepts (`=`, `:=`, `::=`, `?=`, `+=`,
50
+ * `!=`, with an optional `override`/`export`). Matched BEFORE the target rule
51
+ * because `FLAGS ::= -O2` otherwise reads as a target named `FLAGS`.
52
+ */
53
+ const ASSIGN_RE = /^(?:override\s+|export\s+)?[A-Za-z_][A-Za-z0-9_.]*\s*(?:::|:|\?|\+|!)?=/;
54
+ /**
55
+ * The explicit targets a Makefile declares. Deliberately conservative — this list
56
+ * only ever has to answer "does the project expose `migrate`", so a missed exotic
57
+ * target costs a false FAIL and is the one error worth avoiding:
58
+ * • recipe lines (leading TAB) are skipped — they are shell, not targets;
59
+ * • `:=` / `::=` / `?=` / `+=` assignments are skipped;
60
+ * • pattern rules (`%.o: %.c`) and dot-targets (`.PHONY:`) fail TARGET_NAME_RE,
61
+ * which is also why the names listed AFTER `.PHONY:` are ignored here: they are
62
+ * prerequisites, and every one of them that is real is declared by its own rule.
63
+ * Multiple targets on one line (`build test:`) all count.
64
+ */
65
+ export function makeTargets(src) {
66
+ const out = [];
67
+ const seen = new Set();
68
+ for (const raw of src.replace(/\r\n?/g, '\n').split('\n')) {
69
+ if (raw.startsWith('\t') || raw.trim().length === 0 || raw.trimStart().startsWith('#'))
70
+ continue;
71
+ if (ASSIGN_RE.test(raw))
72
+ continue;
73
+ const m = /^([^:#=]+):(?![=])/.exec(raw);
74
+ if (!m)
75
+ continue;
76
+ for (const tok of m[1].trim().split(/\s+/)) {
77
+ if (!TARGET_NAME_RE.test(tok))
78
+ continue;
79
+ const key = tok.toLowerCase();
80
+ if (seen.has(key))
81
+ continue;
82
+ seen.add(key);
83
+ out.push(tok);
84
+ }
85
+ }
86
+ return out;
87
+ }
88
+ /**
89
+ * Resolve the manifest the launch contract may be diffed against.
90
+ *
91
+ * A package.json that exists but does not parse resolves to `none`, not to "zero
92
+ * scripts": diffing against a manifest you could not read is exactly the mistake
93
+ * this module exists to stop, and a broken manifest is the static checks' business.
94
+ */
95
+ export function readLaunchManifest(cwd) {
96
+ const pkg = path.join(cwd, 'package.json');
97
+ if (existsSync(pkg)) {
98
+ try {
99
+ const j = JSON.parse(readFileSync(pkg, 'utf8'));
100
+ return { kind: 'npm', file: 'package.json', names: Object.keys(j.scripts ?? {}) };
101
+ }
102
+ catch {
103
+ return { kind: 'none', file: '', names: [], why: 'its package.json could not be parsed' };
104
+ }
105
+ }
106
+ for (const name of MAKEFILE_NAMES) {
107
+ const mk = path.join(cwd, name);
108
+ if (!existsSync(mk))
109
+ continue;
110
+ try {
111
+ return { kind: 'make', file: name, names: makeTargets(readFileSync(mk, 'utf8')) };
112
+ }
113
+ catch {
114
+ break;
115
+ }
116
+ }
117
+ return { kind: 'none', file: '', names: [], why: 'it has no npm manifest and no Makefile' };
118
+ }
119
+ /**
120
+ * The one line an INERT contract leaves behind. It rides in the gate's UNOBSERVED
121
+ * channel rather than in `warnings` for the reason nexttask 16A gives: a check that
122
+ * silently did nothing must not read later as a check that passed.
123
+ */
124
+ export function inertLaunchContractNote(declared, manifest) {
125
+ return (`launch contract: ${declared.length} declared script(s) (${declared.join(', ')}) were recorded, `
126
+ + `but this project could not be diffed against a manifest — ${manifest.why ?? 'no manifest'}. `
127
+ + 'The contract was NOT checked here.');
128
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.37.4",
3
+ "version": "0.37.5",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",