@timekast/factory 2.3.1 → 2.5.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.
@@ -0,0 +1,13 @@
1
+ {
2
+ "473a92392f7357596cf877cf4703a49ee03786141313e0959734c8c537561006": "0c2922a8fce7c8073d3740392caaedbdd1b502efd0da07b988fca271dd32bc93",
3
+ "ac139661924928e1080053b6032409c6b105f5c929355ce95ff5a91ee03cd100": "f24cc44dd20bb99edf5511e1202948fe3fe35ee063e283790eaabdf07eac6cee",
4
+ "9784f61835263225d26c4f2d5646e9540597667173510e4865c64439aff26950": "b582d978287acf77175fdc0048d23771da7b8c6f04d519eb98253d6b726d624a",
5
+ "04664118924eb3a936fff0c37818befe0b950e8cc5fc8ced04917d4575b1dae8": "69fc8443158543232b07c6e0177a4630b25287f7b298811192ec5ecc65586fa9",
6
+ "a2fd91e21cf3110ad7c59b54b209ae2228e9797e16ef346e040af01dd640f1ef": "d1f75b3591fc12ac29c670183376cd1a1c79f30149be0aa28c5e412a6879227b",
7
+ "0b834d5e7f2e14d2d617b665bd768b89876d2bd8e3f17244a03194c02d03c528": "7ad35befedc2260fb75df0d6bd4c42720c478e02f97f1d43e23769fd7516da78",
8
+ "2ab69db4fcab8cd10637b41004503caaedf30855af7779bc19cc85ea39f9b62a": "49cb2ac5d07b69fa9d35c7ab97acd23a311c0fe76cbacd74e137e7dce5300ef2",
9
+ "8ab33214c9204aebfb655a9f8b44fdf14e3d6e553b6142014f54b441400b82af": "2cc1afb006290a1ddf88b43b9fdc9f12354f1c22c5983c52a9d47c796a0c44ff",
10
+ "6248c3c08b75455d5e0af8fd38089ac48a501ed851d4982b1fb2bbbade76f8ed": "bc50ff7698b6e7f9573649015295a58b3667d3eed72e8369f1178b3f2d553b53",
11
+ "62f90996cf30610266fc95b8be85ac6e79b2156d03083415dc7c063b9efe4205": "d48522be6e87f07ae60c8cd26bda469014d0fe15632f3ff8a0a50562a58d44d9",
12
+ "5a11e07728b61229a0cbcc5f9b31daf8c71e8c701f5daf4b026328226481d167": "92ad1c42bd0f3f4e93ba9a3ff358d084083ad87acc8719dfc7211edd76c30348"
13
+ }
@@ -32,7 +32,7 @@ import { createHash } from 'node:crypto';
32
32
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
33
33
  import path from 'node:path';
34
34
  import { CLIError } from './cli-error.js';
35
- import { CLAUDE_MD_FILE, LOCKFILE_FILE, MANIFEST_FILE, TIMEKAST_DIR, } from './constants.js';
35
+ import { CLAUDE_MD_FILE, KIT_DEPS_FILE, LOCKFILE_FILE, MANIFEST_FILE, TIMEKAST_DIR, } from './constants.js';
36
36
  /**
37
37
  * Write the initial lockfile in `destDir/.timekast/lockfile.json` (used by
38
38
  * `new` / `add`). On the initial install the lockfile carries the tarball's
@@ -54,9 +54,11 @@ export function writeInitialLockfile(destDir, manifestRaw) {
54
54
  const stamped = { ...manifest, factoryVersion: manifest.version };
55
55
  // If the tarball already unpacked manifest.json into .timekast/, drop it: the
56
56
  // stamped lockfile is the SSOT and we do not keep a duplicate manifest around.
57
- const unpackedManifest = path.join(timekastDir, MANIFEST_FILE);
58
- if (existsSync(unpackedManifest)) {
59
- rmSync(unpackedManifest, { force: true });
57
+ // Same for the dependency list (Factory#78): it is read from the staged tarball, never kept.
58
+ for (const unpacked of [MANIFEST_FILE, KIT_DEPS_FILE]) {
59
+ const abs = path.join(timekastDir, unpacked);
60
+ if (existsSync(abs))
61
+ rmSync(abs, { force: true });
60
62
  }
61
63
  writeLockfile(destDir, stamped);
62
64
  }
@@ -124,7 +126,10 @@ export function parseLockfile(raw, source = 'lockfile') {
124
126
  if (typeof e.path !== 'string' || typeof e.hash !== 'string') {
125
127
  throw new CLIError(`El ${source} tiene una entrada sin \`path\`/\`hash\` en files[${i}].`);
126
128
  }
127
- return { path: e.path, hash: e.hash };
129
+ const keys = Array.isArray(e.keys) && e.keys.every((k) => typeof k === 'string')
130
+ ? { keys: e.keys }
131
+ : {};
132
+ return { path: e.path, hash: e.hash, ...keys };
128
133
  });
129
134
  return {
130
135
  version: obj.version,
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Git plumbing for `factory prune`: list what the repo tracks, and commit a set of
3
+ * untrack + add operations WITHOUT going through the shared index.
4
+ *
5
+ * Why a private index and not the kit's usual commit-by-pathspec (`GIT.md §3.6.1`): a
6
+ * pathspec commit takes the listed paths' content from the WORKING TREE, so a path removed
7
+ * with `git rm --cached` (still on disk, now ignored) is put right back — the commit records
8
+ * no deletion and the methodology stays published. Staging the removal in the real index
9
+ * instead would leave it there for any other commit to swallow. A throwaway index seeded from
10
+ * HEAD gives both properties: the commit holds exactly these operations, and nothing another
11
+ * session staged can ride along.
12
+ */
13
+ import { existsSync, rmSync } from 'node:fs';
14
+ import path from 'node:path';
15
+ import { execa } from 'execa';
16
+ import { CLIError } from './cli-error.js';
17
+ /** Every path the index tracks (POSIX, repo-relative). */
18
+ export async function trackedFiles(rootDir) {
19
+ const res = await execa('git', ['ls-files', '-z'], { cwd: rootDir, reject: false });
20
+ if (res.exitCode !== 0) {
21
+ throw new CLIError(`No se pudo listar los archivos del repo (\`git ls-files\`): ${res.stderr}`);
22
+ }
23
+ return res.stdout.split('\0').filter(Boolean);
24
+ }
25
+ /**
26
+ * Commit `remove` + `add` on top of HEAD through a private index, then bring the real index
27
+ * of those paths in line with the new HEAD. Hooks run as for any commit. Nothing to commit →
28
+ * `{ committed: false }`, no empty commit.
29
+ */
30
+ export async function commitWithPrivateIndex(rootDir, input) {
31
+ const gitDirRes = await execa('git', ['rev-parse', '--absolute-git-dir'], {
32
+ cwd: rootDir,
33
+ reject: false,
34
+ });
35
+ if (gitDirRes.exitCode !== 0) {
36
+ throw new CLIError('Esto no es un repo git.');
37
+ }
38
+ const indexFile = path.join(gitDirRes.stdout.trim(), `tk-prune-index-${process.pid}`);
39
+ const env = { ...process.env, GIT_INDEX_FILE: indexFile };
40
+ const run = (args) => execa('git', args, { cwd: rootDir, env, reject: false });
41
+ try {
42
+ const head = await execa('git', ['rev-parse', '--verify', '-q', 'HEAD'], {
43
+ cwd: rootDir,
44
+ reject: false,
45
+ });
46
+ const seeded = await run(head.exitCode === 0 ? ['read-tree', 'HEAD'] : ['read-tree', '--empty']);
47
+ if (seeded.exitCode !== 0)
48
+ throw new CLIError(`\`git read-tree\` falló: ${seeded.stderr}`);
49
+ if (input.remove.length > 0) {
50
+ const rm = await run(['rm', '--cached', '-q', '--ignore-unmatch', '--', ...input.remove]);
51
+ if (rm.exitCode !== 0)
52
+ throw new CLIError(`\`git rm --cached\` falló: ${rm.stderr}`);
53
+ }
54
+ if (input.add.length > 0) {
55
+ const add = await run(['add', '-f', '--', ...input.add]);
56
+ if (add.exitCode !== 0)
57
+ throw new CLIError(`\`git add\` falló: ${add.stderr}`);
58
+ }
59
+ const diff = await run(['diff', '--cached', '--quiet']);
60
+ if (diff.exitCode === 0)
61
+ return { committed: false };
62
+ const commit = await run(['commit', '-q', '-m', input.message]);
63
+ if (commit.exitCode !== 0) {
64
+ throw new CLIError(`El commit falló (¿un hook lo rechazó o falta identidad git?):\n${commit.stderr || commit.stdout}`);
65
+ }
66
+ const sha = await execa('git', ['rev-parse', '--short', 'HEAD'], { cwd: rootDir });
67
+ // The real index still holds the pre-commit state of these paths: reset them to the new
68
+ // HEAD so `git status` shows the removals as done, not as staged re-additions.
69
+ await execa('git', ['reset', '-q', '--', ...input.remove, ...input.add], {
70
+ cwd: rootDir,
71
+ reject: false,
72
+ });
73
+ return { committed: true, sha: sha.stdout.trim() };
74
+ }
75
+ finally {
76
+ for (const f of [indexFile, `${indexFile}.lock`]) {
77
+ if (existsSync(f))
78
+ rmSync(f, { force: true });
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The hard refusal `/integrate` runs on a collaborator's pull request BEFORE checking it out
3
+ * (`factory prune check-pr`). A member runs the PR's code on their own machine — a risk the
4
+ * org accepted for product code, and only for product code — so any change to what runs
5
+ * around the product (hooks, CI, scripts, tooling config, the methodology, the prune switch)
6
+ * is refused by path, mechanically, never by the agent's judgement. A PR refused here goes
7
+ * back to the collaborator, or a member redoes it.
8
+ *
9
+ * SSOT of the list: `REJECTED_PATHS` below. `tk-integrate/methodology/rejected-paths.md`
10
+ * explains it and points here.
11
+ */
12
+ import { CLIError } from './cli-error.js';
13
+ export const REJECTED_PATHS = [
14
+ { pattern: '.claude/', why: 'el cerebro del kit (reglas, hooks del agente, políticas)' },
15
+ { pattern: 'CLAUDE.md', why: 'instrucciones del agente' },
16
+ { pattern: '.husky/', why: 'hooks de git: corren en la máquina de quien commitea' },
17
+ { pattern: '.github/', why: 'CI y configuración de GitHub' },
18
+ { pattern: 'scripts/', why: 'scripts que corren los hooks y `pnpm verify`' },
19
+ { pattern: '.gitignore', why: 'decide qué de la metodología entra al repo' },
20
+ { pattern: '.timekast/', why: 'el interruptor del repo compartido y el estado del kit' },
21
+ {
22
+ pattern: '**/*.config.*',
23
+ why: 'configuración de herramientas (next, vitest, eslint, playwright…)',
24
+ },
25
+ { pattern: 'vitest.setup*', why: 'código que corre antes de cada test' },
26
+ { pattern: '.npmrc', why: 'configuración de pnpm (registro, scripts)' },
27
+ { pattern: '.pnpmfile.cjs', why: 'código que pnpm ejecuta al instalar' },
28
+ { pattern: 'pnpm-workspace.yaml', why: 'configuración de pnpm' },
29
+ ];
30
+ /** The `package.json` fields a PR may not change (scripts run on the member's machine). */
31
+ export const REJECTED_PACKAGE_FIELDS = ['scripts', 'pnpm'];
32
+ function segmentRx(pattern) {
33
+ return new RegExp(`^${pattern
34
+ .split('*')
35
+ .map((p) => p.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
36
+ .join('[^/]*')}$`);
37
+ }
38
+ /** Does `relPath` fall under `pattern`? A leading `**` + `/` matches the basename at any depth. */
39
+ export function pathRejected(pattern, relPath) {
40
+ if (pattern.startsWith('**/')) {
41
+ const base = relPath.split('/').pop() ?? relPath;
42
+ return segmentRx(pattern.slice(3)).test(base);
43
+ }
44
+ if (pattern.endsWith('/'))
45
+ return relPath.startsWith(pattern);
46
+ return segmentRx(pattern).test(relPath);
47
+ }
48
+ /** Read the PR's files from GitHub (no checkout) and apply the refusal. */
49
+ export async function checkPr(repo, pr, deps) {
50
+ const filesRes = await deps.gh([
51
+ 'api',
52
+ '--paginate',
53
+ `repos/${repo}/pulls/${pr}/files?per_page=100`,
54
+ '--jq',
55
+ '.[] | [.filename, (.previous_filename // "")] | @tsv',
56
+ ]);
57
+ if (filesRes.exitCode !== 0) {
58
+ throw new CLIError(`No pude leer los archivos del PR #${pr} de \`${repo}\`: ${filesRes.stderr}`);
59
+ }
60
+ const files = [
61
+ ...new Set(filesRes.stdout
62
+ .split('\n')
63
+ .flatMap((line) => line.split('\t'))
64
+ .map((p) => p.trim())
65
+ .filter(Boolean)),
66
+ ].sort();
67
+ const rejected = [];
68
+ for (const p of files) {
69
+ const hit = REJECTED_PATHS.find((r) => pathRejected(r.pattern, p));
70
+ if (hit)
71
+ rejected.push({ path: p, why: hit.why });
72
+ }
73
+ const packageFields = [];
74
+ if (files.includes('package.json')) {
75
+ const meta = await deps.gh([
76
+ 'pr',
77
+ 'view',
78
+ String(pr),
79
+ '--repo',
80
+ repo,
81
+ '--json',
82
+ 'baseRefOid,headRefOid',
83
+ '--jq',
84
+ '[.baseRefOid, .headRefOid] | @tsv',
85
+ ]);
86
+ if (meta.exitCode !== 0)
87
+ throw new CLIError(`No pude leer el PR #${pr}: ${meta.stderr}`);
88
+ const [base, head] = meta.stdout.trim().split('\t');
89
+ const read = async (ref) => {
90
+ const r = await deps.gh([
91
+ 'api',
92
+ `repos/${repo}/contents/package.json?ref=${ref}`,
93
+ '-H',
94
+ 'Accept: application/vnd.github.raw',
95
+ ]);
96
+ if (r.exitCode !== 0)
97
+ return null;
98
+ try {
99
+ return JSON.parse(r.stdout);
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ };
105
+ const [before, after] = [await read(base), await read(head)];
106
+ if (!before || !after) {
107
+ // Unreadable or invalid JSON on either side: the comparison cannot clear it.
108
+ packageFields.push('(no se pudo comparar package.json)');
109
+ }
110
+ else {
111
+ for (const field of REJECTED_PACKAGE_FIELDS) {
112
+ if (JSON.stringify(before[field] ?? null) !== JSON.stringify(after[field] ?? null)) {
113
+ packageFields.push(field);
114
+ }
115
+ }
116
+ }
117
+ }
118
+ return { rejected, packageFields, files };
119
+ }